diff --git a/frontend/docs/PAV-182-match-vagas-salvas.md b/frontend/docs/PAV-182-match-vagas-salvas.md new file mode 100644 index 00000000..0ce14995 --- /dev/null +++ b/frontend/docs/PAV-182-match-vagas-salvas.md @@ -0,0 +1,73 @@ +# PAV-182 — Viabilidade de recuperar Match das vagas salvas + +## Resultado da auditoria + +O contrato atual não permite recuperar uma vaga específica do catálogo por +identificador estável. O enriquecimento das vagas salvas não foi implementado. +O Kanban mantém `Não calculado` quando a API não fornece compatibilidade. + +## Endpoint e perfil + +- O frontend usa `searchDashboardJobs`, em + `src/features/jobs/api/dashboardJobsApi.ts`, para consultar `GET /jobs/search`. +- O endpoint também está disponível sob `/api/v1/jobs/search` conforme a montagem + das rotas em `backend/src/app.ts`. +- O cliente utiliza cookies da sessão (`withCredentials`). O controller envia + `req.session.userId` para `SearchJobsService`, que carrega o perfil atual por + `JobProfileMatchService` e utiliza o cálculo existente de compatibilidade. +- A busca devolve `matchScore`, `matchSource` e `matchedTechnologies` quando + disponíveis. Ausência de score não equivale a um percentual calculado de zero. + +## Contrato de consulta + +O parser `backend/src/modules/jobs/parsers/jobSearchQuery.parser.ts` aceita: + +- `keywords`, `family`, `familyMode`, `technology`, `company`; +- `model`/`type`, `level`, `seniority`, `contract`/`contractType`/`jobTypes`; +- `location`, `continent`, `country`, `state`, `city`; +- `matchSort`/`sort` e paginação `page`, `limit`. + +Não há filtro de ID original, ID externo, URL, `jobLink` ou lote de IDs/URLs. +Enviar esses parâmetros não cria uma consulta por identidade: o parser não os +inclui nos filtros. As rotas públicas de catálogo são `/jobs/search` e +`/jobs/filters/options`; não existe rota de detalhe ou lookup do catálogo. +O limite de resultados por página é 100, definido em `backend/src/lib/pagination.ts`. + +## Identificadores e correspondência + +- A vaga salva possui um UUID de `saved_jobs`, diferente do ID do catálogo. +- Ela preserva `jobLink`, título, empresa, localização, fonte e uma keyword, + mas não uma associação ao ID original do catálogo nem os dados de Match. +- O frontend gera um ID de apresentação `recommended::<índice>` para a + busca. Esse valor não é um identificador de catálogo aceito pelo endpoint. +- Quando há URL original válida, `jobLink` permite comparar exatamente com + `url` de um resultado recebido. Isso não oferece um mecanismo para localizar + esse resultado sem percorrer a busca, nem garante unicidade no catálogo. +- Se a URL original estiver ausente, o frontend gera um link `canddate.local` + com dados textuais e índice da página; esse fallback não identifica uma vaga + original de forma estável. +- Título igual, empresa parcial ou primeiro resultado textual não comprovam + identidade. Providers diferentes e vagas homônimas não podem ser associados + automaticamente. A busca por empresa usa substring; keywords usam índices + de busca, sem contrato de correspondência exata por URL. + +## Decisão de implementação e performance + +Nenhuma nova requisição foi adicionada. Não há busca por card, varredura do +catálogo, paginação ilimitada ou cache novo de pontuações. A página atual de +resultados não foi utilizada como mecanismo de recuperação das vagas salvas: +ela não garante disponibilidade após recarregar e pode excluir essas vagas +pelos filtros ativos. + +## Dependência para uma solução definitiva + +É necessário um contrato autenticado de consulta em lote por identidade estável, +ou enriquecimento de `/saved-jobs` no servidor com associação ao catálogo. +Esse contrato deve resolver correspondências ausentes ou ambíguas explicitamente, +reutilizar o mesmo serviço de Match e o perfil atual, preservar zero real e +retornar score ausente quando não houver cálculo. Deve também definir expiração +e invalidação por mudanças de perfil/catálogo. Não basta persistir um percentual +fixo nem reaproveitar pontuações antigas pelo título. + +Essa alteração depende de backend e ficou fora desta etapa. Não há contrato +novo, endpoint proposto implementado ou alteração do cálculo de compatibilidade. diff --git a/frontend/src/app/dashboard/DashboardPage.tsx b/frontend/src/app/dashboard/DashboardPage.tsx index 1e2fc942..0a2fd54c 100644 --- a/frontend/src/app/dashboard/DashboardPage.tsx +++ b/frontend/src/app/dashboard/DashboardPage.tsx @@ -21,7 +21,7 @@ import { FALLBACK_JOB_FAMILIES, type JobFamilyOption, } from "@/features/jobs/api/dashboardJobsApi"; -import type { CareerChecklist, SearchPreferences, TechnologyExperience, UserProfile } from "@/features/profile/domain/user"; +import type { CareerChecklist, SearchPreferences, UserProfile } from "@/features/profile/domain/user"; import type { Job, JobModelFilter, JobStatus, MatchSort, NewJob } from "@/features/jobs/domain/job"; import { type ContinentFilter, @@ -42,109 +42,6 @@ function getSection(pathname: string) { return "home"; } -function normalizeMatchText(value: string) { - return value - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^\p{L}\p{N}]+/gu, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function matchAliases(technology: string) { - const normalized = normalizeMatchText(technology); - const aliases = new Set([normalized]); - - if (normalized.endsWith(" js")) { - aliases.add(normalized.replace(/\s+js$/, "js")); - } - if (normalized.endsWith("js") && normalized.length > 2) { - aliases.add(normalized.replace(/js$/, " js")); - } - - return [...aliases].filter(Boolean); -} - -function textMatchesAlias(text: string, alias: string) { - if (!alias) return false; - if (alias.includes(" ")) return text.includes(alias); - return ` ${text} `.includes(` ${alias} `); -} - -function jobMatchText(job: Job) { - const rawPayload = job.rawPayload ?? {}; - const rawValues = Object.values(rawPayload).flatMap((value) => - Array.isArray(value) ? value : [value], - ); - - return normalizeMatchText( - [ - job.jobTitle, - job.company, - job.location, - job.type, - job.level, - job.tags.join(" "), - ...rawValues.map((value) => (typeof value === "string" ? value : "")), - ].join(" "), - ); -} - -function scoreJobWithTechnologies( - job: Job, - technologies: TechnologyExperience[], -): Job { - if (job.rawPayload?.matchSource === "backend_profile") return job; - - const normalizedTechnologies = [ - ...new Set( - technologies - .filter((technology) => technology.name.trim()) - .map((technology) => ({ - name: technology.name.trim(), - years: Math.max(0, technology.years), - })), - ), - ]; - if (normalizedTechnologies.length === 0) return job; - - const text = jobMatchText(job); - const matchedTechnologies = normalizedTechnologies.filter((technology) => - matchAliases(technology.name).some((alias) => - textMatchesAlias(text, alias), - ), - ); - - const totalWeight = normalizedTechnologies.reduce( - (total, technology) => total + Math.max(1, technology.years), - 0, - ); - const matchedWeight = matchedTechnologies.reduce( - (total, technology) => total + Math.max(1, technology.years), - 0, - ); - const coverage = matchedWeight / totalWeight; - const score = - matchedTechnologies.length === 0 - ? 45 - : Math.min( - 99, - 55 + - Math.round(coverage * 35) + - Math.min(matchedTechnologies.length * 4, 9), - ); - - return { - ...job, - matchScore: score, - rawPayload: { - ...(job.rawPayload ?? {}), - matchedTechnologies: matchedTechnologies.map((item) => item.name), - }, - }; -} - export default function NewDashboardPage() { const { user, refreshUser } = useAuth(); const location = useLocation(); @@ -165,6 +62,20 @@ export default function NewDashboardPage() { const [matchSort, setMatchSort] = useState( () => (searchParams.get("matchSort") as MatchSort) || "default", ); + const [stateFilter, setStateFilter] = useState( + () => searchParams.get("state") || "", + ); + const [cityFilter, setCityFilter] = useState( + () => searchParams.get("city") || "", + ); + // Captura os filtros da URL uma vez; alterações posteriores usam a busca + // com debounce, sem disparar outra carga inicial sem a ordenação atual. + const [initialRecommendationFilters] = useState(() => ({ + state: stateFilter.trim(), + city: cityFilter.trim(), + family: (searchParams.get("family") ?? "").split(",").filter(Boolean).join(","), + matchSort, + })); const [familyOptions, setFamilyOptions] = useState( FALLBACK_JOB_FAMILIES, ); @@ -228,6 +139,10 @@ export default function NewDashboardPage() { } else { nextParams.delete("matchSort"); } + if (stateFilter.trim()) nextParams.set("state", stateFilter.trim()); + else nextParams.delete("state"); + if (cityFilter.trim()) nextParams.set("city", cityFilter.trim()); + else nextParams.delete("city"); if (nextParams.toString() !== searchParams.toString()) { setSearchParams(nextParams, { replace: true }); @@ -239,6 +154,8 @@ export default function NewDashboardPage() { countryFilter, matchSort, searchParams, + stateFilter, + cityFilter, setSearchParams, ]); @@ -281,15 +198,28 @@ export default function NewDashboardPage() { keywords: [], filters: { ...modelFilterToApiFilter(preferredModelFilter), - ...(selectedFamilies.length > 0 + ...(initialRecommendationFilters.state + ? { state: initialRecommendationFilters.state } + : {}), + ...(initialRecommendationFilters.city + ? { city: initialRecommendationFilters.city } + : {}), + ...(initialRecommendationFilters.matchSort !== "default" + ? { matchSort: initialRecommendationFilters.matchSort } + : {}), + ...(initialRecommendationFilters.family ? { - family: selectedFamilies.join(","), + family: initialRecommendationFilters.family, familyMode: "primary", } : {}), }, }, - [isLoadingUserData, preferredModelFilter, selectedFamilies], + [ + isLoadingUserData, + preferredModelFilter, + initialRecommendationFilters, + ], ); const { trackedJobs, @@ -306,24 +236,11 @@ export default function NewDashboardPage() { initialRecommendationSearch, }); - const matchedTrackedJobs = useMemo( - () => - trackedJobs.map((job) => - scoreJobWithTechnologies(job, userProfile.technologyExperiences), - ), - [trackedJobs, userProfile.technologyExperiences], - ); - const matchedRecommendedJobs = useMemo( - () => - // O backend ja calcula o match com o mesmo perfil; so recalculamos aqui - // quando a API nao devolveu score (usuario sem perfil carregado). - recommendedJobs.map((job) => - job.matchScore > 0 - ? job - : scoreJobWithTechnologies(job, userProfile.technologyExperiences), - ), - [recommendedJobs, userProfile.technologyExperiences], - ); + // Vagas salvas também usam apenas a compatibilidade recebida da API. + const matchedTrackedJobs = trackedJobs; + // A API ordena globalmente por matchScore. Preservamos a mesma métrica + // recebida, inclusive zero e ausência, sem calcular um score após paginar. + const matchedRecommendedJobs = recommendedJobs; // O filtro de modelo e aplicado pelo backend (indice do Valkey + pos-filtro). // Refiltrar aqui escondia vagas ja filtradas e quebrava a contagem da pagina. const displayedRecommendedJobs = matchedRecommendedJobs; @@ -407,6 +324,14 @@ export default function NewDashboardPage() { setHasUserChangedJobFilters(true); setMatchSort(value); }, []); + const handleStateFilterChange = useCallback((value: string) => { + setHasUserChangedJobFilters(true); + setStateFilter(value); + }, []); + const handleCityFilterChange = useCallback((value: string) => { + setHasUserChangedJobFilters(true); + setCityFilter(value); + }, []); const handleSelectedFamiliesChange = useCallback((value: string[]) => { setHasUserChangedJobFilters(true); @@ -497,6 +422,8 @@ export default function NewDashboardPage() { ? { country: countryFilter, location: countryFilter } : {}), ...(matchSort !== "default" ? { matchSort } : {}), + ...(stateFilter.trim() ? { state: stateFilter.trim() } : {}), + ...(cityFilter.trim() ? { city: cityFilter.trim() } : {}), ...(selectedFamilies.length > 0 ? { family: selectedFamilies.join(","), @@ -523,6 +450,8 @@ export default function NewDashboardPage() { countryFilter, continentFilter, filterLevel, + stateFilter, + cityFilter, filterType, matchSort, selectedFamilies, @@ -563,6 +492,8 @@ export default function NewDashboardPage() { matchSort, selectedFamilies, section, + stateFilter, + cityFilter, ]); const handleRecommendationPageSizeChange = async (limit: number) => { @@ -600,6 +531,10 @@ export default function NewDashboardPage() { setContinentFilter={handleContinentFilterChange} countryFilter={countryFilter} setCountryFilter={handleCountryFilterChange} + stateFilter={stateFilter} + setStateFilter={handleStateFilterChange} + cityFilter={cityFilter} + setCityFilter={handleCityFilterChange} matchSort={matchSort} setMatchSort={handleMatchSortChange} selectedFamilies={selectedFamilies} diff --git a/frontend/src/app/dashboard/components/Sidebar.tsx b/frontend/src/app/dashboard/components/Sidebar.tsx index b8bddbac..6bf959a4 100644 --- a/frontend/src/app/dashboard/components/Sidebar.tsx +++ b/frontend/src/app/dashboard/components/Sidebar.tsx @@ -39,7 +39,7 @@ export function Sidebar() { className={cn( "flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors", active - ? "bg-primary text-primary-foreground shadow-sm" + ? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]" : "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300", )} > @@ -62,7 +62,7 @@ export function Sidebar() { className={cn( "flex h-10 items-center gap-3 rounded-md px-3 text-sm font-semibold transition-colors", active - ? "bg-primary text-primary-foreground shadow-sm" + ? "bg-[var(--menu-active-background)] text-[color:var(--menu-active-foreground)] shadow-sm ring-1 ring-inset ring-[color:var(--menu-active-border)]" : "text-slate-500 hover:bg-muted hover:text-foreground dark:text-slate-300", )} > diff --git a/frontend/src/app/dashboard/theme/colors.ts b/frontend/src/app/dashboard/theme/colors.ts index 62cc4014..69ad64c2 100644 --- a/frontend/src/app/dashboard/theme/colors.ts +++ b/frontend/src/app/dashboard/theme/colors.ts @@ -3,6 +3,9 @@ export interface Colors { surface: string; surfaceHover: string; input: string; + inputBorder: string; + primary: string; + primaryHover: string; textPrimary: string; textSecondary: string; textMuted: string; @@ -15,22 +18,27 @@ export const lightColors: Colors = { surface: "#ffffff", surfaceHover: "#f1f5f9", input: "#f1f5f9", + inputBorder: "#cbd5e1", + primary: "#15803d", + primaryHover: "#166534", textPrimary: "#0f172a", textSecondary: "#475569", - textMuted: "#94a3b8", - border: "#edf2f7", - borderFocus: "#6366f1", + textMuted: "#64748b", + border: "#e2e8f0", + borderFocus: "#15803d", }; export const darkColors: Colors = { - background: "#0f172a", - surface: "#1e293b", - surfaceHover: "#334155", - input: "#1e293b", - textPrimary: "#f8fafc", - textSecondary: "#cbd5e1", + background: "#0b0f19", + surface: "#131b2e", + surfaceHover: "#1e293b", + input: "#070a12", + inputBorder: "#26354a", + primary: "#16a34a", + primaryHover: "#22c55e", + textPrimary: "#ffffff", + textSecondary: "#9ca3af", textMuted: "#94a3b8", - border: "#202d40", - borderFocus: "#818cf8", + border: "#1e293b", + borderFocus: "#22c55e", }; - diff --git a/frontend/src/features/applications/components/KanbanColumn.tsx b/frontend/src/features/applications/components/KanbanColumn.tsx index 0004a8d3..7d33a7e3 100644 --- a/frontend/src/features/applications/components/KanbanColumn.tsx +++ b/frontend/src/features/applications/components/KanbanColumn.tsx @@ -111,7 +111,7 @@ export function KanbanColumn({ {job.posted} - {job.matchScore}% Match + {job.matchScore === null ? "Não calculado" : `${job.matchScore}% Match`} diff --git a/frontend/src/features/home/components/WelcomeBanner.tsx b/frontend/src/features/home/components/WelcomeBanner.tsx index 3bbaa915..1afdcd58 100644 --- a/frontend/src/features/home/components/WelcomeBanner.tsx +++ b/frontend/src/features/home/components/WelcomeBanner.tsx @@ -13,7 +13,7 @@ export function WelcomeBanner({ onExploreJobs, }: WelcomeBannerProps) { return ( - + @@ -26,7 +26,7 @@ export function WelcomeBanner({ Que bom te ver de volta, {userProfile.displayName}! - + Sua conta de nível{" "} {userProfile.level} monitora atualmente {jobsCount}{" "} diff --git a/frontend/src/features/jobs/api/dashboardJobsApi.ts b/frontend/src/features/jobs/api/dashboardJobsApi.ts index b14b306c..12eb204f 100644 --- a/frontend/src/features/jobs/api/dashboardJobsApi.ts +++ b/frontend/src/features/jobs/api/dashboardJobsApi.ts @@ -252,14 +252,6 @@ function inferLevel(title: string): JobLevel { return "Pleno"; } -function stableMatchScore(value: string) { - const hash = [...value].reduce( - (total, character) => total + character.charCodeAt(0), - 0, - ); - return 70 + (hash % 26); -} - function formatPublicationDate(value: string | null | undefined) { const rawDate = value?.trim(); if (!rawDate) return "Não informado"; @@ -329,7 +321,7 @@ export function toRecommendedJob(job: ApiSearchJob, index: number): Job { ), level: inferLevel(title), // Sem perfil o backend nao calcula match; nao inventamos um score aqui. - matchScore: typeof job.matchScore === "number" ? job.matchScore : 0, + matchScore: typeof job.matchScore === "number" ? job.matchScore : null, tags: tags.length > 0 ? tags : ["Geral"], posted: formatPublicationDate(job.postedAt), status: "saved", @@ -353,7 +345,8 @@ export function toDashboardSavedJob(job: ApiSavedJob): Job { salary: "A combinar", type: inferTypeFromText([title, location].join(" ")), level: inferLevel(title), - matchScore: stableMatchScore(`${title}:${job.company ?? ""}`), + // O contrato de vagas salvas ainda não retorna compatibilidade. + matchScore: null, tags: job.keyword?.trim() ? [job.keyword.trim()] : ["Geral"], posted: "Não informado", status: job.status, diff --git a/frontend/src/features/jobs/components/FormattedJobDescription.tsx b/frontend/src/features/jobs/components/FormattedJobDescription.tsx index ba39af96..357c85fd 100644 --- a/frontend/src/features/jobs/components/FormattedJobDescription.tsx +++ b/frontend/src/features/jobs/components/FormattedJobDescription.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode } from "react"; +import { normalizeDescriptionText } from "../utils/jobDetailPresentation"; interface FormattedJobDescriptionProps { description: string; @@ -54,7 +55,7 @@ function safeExternalUrl(value: string | null) { function renderNode(node: Node, key: string): ReactNode { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; + return node.parentElement?.closest("pre, code") ? node.textContent : normalizeDescriptionText(node.textContent ?? ""); } if (node.nodeType !== Node.ELEMENT_NODE) { @@ -134,6 +135,9 @@ export function FormattedJobDescription({ "text/html", ); const hasMarkup = /<\/?[a-z][\s\S]*>/i.test(decodedDescription); + document.querySelectorAll("br + br + br").forEach((element) => { + if (!element.closest("pre, code")) element.remove(); + }); return ( @@ -143,7 +147,7 @@ export function FormattedJobDescription({ ) ) : ( - {document.body.textContent ?? decodedDescription} + {normalizeDescriptionText(document.body.textContent ?? decodedDescription).trim()} )} diff --git a/frontend/src/features/jobs/components/JobDetailModal.tsx b/frontend/src/features/jobs/components/JobDetailModal.tsx index 735c3dab..39c1536c 100644 --- a/frontend/src/features/jobs/components/JobDetailModal.tsx +++ b/frontend/src/features/jobs/components/JobDetailModal.tsx @@ -1,5 +1,5 @@ import { ExternalLink } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { jobStatusClasses, jobStatuses } from "../constants"; import { getDashboardSavedJobEvents, @@ -9,6 +9,8 @@ import type { Job, JobStatus, JobTimelineEvent } from "../domain/job"; import { Modal } from "@/shared/ui/Modal"; import { ApplicationNotesSection } from "@/features/applications/components/ApplicationNotesSection"; import { FormattedJobDescription } from "@/features/jobs/components/FormattedJobDescription"; +import { JobLocation } from "./JobLocation"; +import { jobDetailLabel, formatJobPublicationDate, formatCompatibilityReason } from "../utils/jobDetailPresentation"; interface JobDetailModalProps { job: Job; @@ -19,22 +21,6 @@ interface JobDetailModalProps { timelineVersion?: number; } -const payloadLabels: Record = { - id: "ID", - title: "Título", - company: "Empresa", - location: "Local", - url: "URL", - salary: "Salário", - modality: "Modalidade", - description: "Descrição", - postedAt: "Publicado em", - source: "Fonte", - sources: "Fontes", - keyword: "Keyword", - keywords: "Keywords", -}; - /** * Campos do payload bruto que já têm representação amigável em outro lugar * do detalhe (tiles principais, subtítulo do modal ou link "Abrir vaga") — @@ -91,7 +77,7 @@ function timelineMetadataText(metadata: Record | null) { if (!metadata) return ""; return Object.entries(metadata) - .map(([key, value]) => `${key}: ${payloadValueToText(value)}`) + .map(([key, value]) => `${jobDetailLabel(key)}: ${payloadValueToText(value)}`) .join(" • "); } @@ -102,13 +88,13 @@ function InfoTile({ valueClassName = "", }: { label: string; - value: string; + value: ReactNode; valueClassName?: string; }) { return ( {label} - {value} + {value} ); } @@ -202,14 +188,14 @@ export function JobDetailModal({ - + } /> {classification.primaryFamily ? ( @@ -261,11 +247,11 @@ export function JobDetailModal({ {classification.reasons && classification.reasons.length > 0 ? ( - Justificativas de match + Justificativas da compatibilidade {classification.reasons.map((reason, idx) => ( - {reason} + {formatCompatibilityReason(reason)} ))} @@ -287,7 +273,8 @@ export function JobDetailModal({ {payloadEntries.map(([key, value]) => { - const text = payloadValueToText(value); + const isPublication = key === "postedAt" || key === "publishedAt"; + const text = isPublication ? formatJobPublicationDate(value) : payloadValueToText(value); return ( - {payloadLabels[key] ?? key} + {jobDetailLabel(key)} {isExternalUrl(text) ? ( ) : ( - + {text} )} diff --git a/frontend/src/features/jobs/components/JobFilter.tsx b/frontend/src/features/jobs/components/JobFilter.tsx index ce466787..97fd70e3 100644 --- a/frontend/src/features/jobs/components/JobFilter.tsx +++ b/frontend/src/features/jobs/components/JobFilter.tsx @@ -1,7 +1,25 @@ -import { Search, ChevronDown } from "lucide-react"; -import { useState, useRef, useEffect, useMemo } from "react"; +import { + Search, + ChevronDown, + Filter, + Flag, + Globe2, + MapPinned, + Building2, + X, +} from "lucide-react"; +import { + useState, + useRef, + useEffect, + useMemo, + useId, + type CSSProperties, +} from "react"; +import { cn } from "@/shared/utils/utils"; +import { darkColors, lightColors } from "@/app/dashboard/theme/colors"; import { jobLevels } from "../constants"; -import type { JobModelFilter, MatchSort } from "../domain/job"; +import type { JobModelFilter } from "../domain/job"; import { jobModelFilterOptions } from "@/features/jobs/domain/jobModelFilters"; import { continentOptions, @@ -21,8 +39,10 @@ interface JobFilterProps { setContinentFilter: (value: ContinentFilter) => void; countryFilter: CountryFilter; setCountryFilter: (value: CountryFilter) => void; - matchSort: MatchSort; - setMatchSort: (value: MatchSort) => void; + stateFilter: string; + setStateFilter: (value: string) => void; + cityFilter: string; + setCityFilter: (value: string) => void; selectedFamilies?: string[]; setSelectedFamilies?: (value: string[]) => void; familyOptions?: Array<{ id: string; label: string }>; @@ -39,14 +59,17 @@ export function JobFilter({ setContinentFilter, countryFilter, setCountryFilter, - matchSort, - setMatchSort, + stateFilter, + setStateFilter, + cityFilter, + setCityFilter, selectedFamilies = [], setSelectedFamilies, familyOptions = [], }: JobFilterProps) { const [isFamilyOpen, setIsFamilyOpen] = useState(false); const familyDropdownRef = useRef(null); + const familyDropdownId = useId(); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -64,7 +87,7 @@ export function JobFilter({ const familyLabel = useMemo(() => { const list = selectedFamilies ?? []; const opts = familyOptions ?? []; - if (list.length === 0) return "Área profissional (Todas)"; + if (list.length === 0) return "Áreas profissionais (Todas)"; if (list.length === 1) { const found = opts.find((f) => f.id === list[0]); return found ? found.label : list[0]; @@ -74,150 +97,313 @@ export function JobFilter({ const activeSelected = selectedFamilies ?? []; const activeOptions = familyOptions ?? []; + const inputColors = { + "--filter-input-light": lightColors.input, + "--filter-input-dark": darkColors.input, + } as CSSProperties; + const controlClassName = + "h-10 w-full min-w-0 rounded-md border border-input bg-[var(--filter-input-light)] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground hover:border-primary/40 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/25 dark:bg-[var(--filter-input-dark)]"; + const otherActiveFilters = [ + searchQuery.trim() && `Busca: ${searchQuery.trim()}`, + filterType !== "Todos" && + (jobModelFilterOptions.find((option) => option.value === filterType) + ?.label ?? + filterType), + filterLevel !== "Todos" && filterLevel, + continentFilter !== "Todos" && continentFilter, + countryFilter !== "Todos" && countryFilter, + stateFilter.trim() && `Estado / Região: ${stateFilter.trim()}`, + cityFilter.trim() && `Cidade: ${cityFilter.trim()}`, + ].filter(Boolean); return ( - - - - setSearchQuery(event.target.value)} - maxLength={100} - placeholder="Buscar por cargo, empresa ou keywords..." - className="h-11 w-full rounded-lg border border-input bg-background pl-10 pr-3 text-sm outline-none transition-colors focus:border-ring" - /> - - - - setIsFamilyOpen(!isFamilyOpen)} - className="flex h-11 w-full items-center justify-between rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - aria-expanded={isFamilyOpen} - aria-label="Filtrar por área profissional" + <> + + - {familyLabel} - - + + Buscar vagas + + setSearchQuery(event.target.value)} + maxLength={100} + placeholder="Buscar por cargo, empresa ou keywords..." + className={cn(controlClassName, "pl-10")} + /> + - {isFamilyOpen && ( - - - - Áreas profissionais - - {activeSelected.length > 0 && ( - setSelectedFamilies?.([])} - className="text-xs font-semibold text-primary hover:underline" - > - Limpar - + { + if (event.key === "Escape") { + setIsFamilyOpen(false); + familyDropdownRef.current?.querySelector("button")?.focus(); + } + }} + > + setIsFamilyOpen(!isFamilyOpen)} + className={cn( + controlClassName, + "flex items-center justify-between gap-2", + activeSelected.length > 0 && "border-primary/60", )} - - - {activeOptions.map((family) => { - const isSelected = activeSelected.includes(family.id); - return ( - + {familyLabel} + + + + {isFamilyOpen && ( + + + + Áreas profissionais + + setSelectedFamilies?.([])} + disabled={ + activeSelected.length === 0 || !setSelectedFamilies + } + className="rounded-sm text-xs font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" > - { - if (!setSelectedFamilies) return; - if (e.target.checked) { - setSelectedFamilies([ - ...activeSelected, - family.id, - ]); - } else { - setSelectedFamilies( - activeSelected.filter((id) => id !== family.id), - ); - } - }} - className="h-4 w-4 rounded border-input text-primary focus:ring-ring" - /> - - {family.label} - - - ); - })} - + Limpar + + + + {activeOptions.map((family) => { + const isSelected = activeSelected.includes(family.id); + return ( + + { + if (!setSelectedFamilies) return; + if (e.target.checked) { + setSelectedFamilies([ + ...activeSelected, + family.id, + ]); + } else { + setSelectedFamilies( + activeSelected.filter((id) => id !== family.id), + ); + } + }} + className="h-4 w-4 rounded border-input text-primary focus:ring-ring" + /> + + {family.label} + + + ); + })} + + + )} - )} - - setFilterLevel(event.target.value)} - className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - > - Sênioridade (Todos) - {jobLevels.map((level) => ( - {level} - ))} - + + setFilterType(event.target.value as JobModelFilter) + } + className={cn( + controlClassName, + filterType !== "Todos" && "border-primary/60", + )} + > + Modalidade (Todas) + {jobModelFilterOptions + .filter((option) => option.value !== "Todos") + .map((option) => ( + + {option.label} + + ))} + - - setFilterType(event.target.value as JobModelFilter) - } - className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - > - Modelo (Todos) - {jobModelFilterOptions - .filter((option) => option.value !== "Todos") - .map((option) => ( - - {option.label} - - ))} - + setFilterLevel(event.target.value)} + className={cn( + controlClassName, + filterLevel !== "Todos" && "border-primary/60", + )} + > + Senioridade + {jobLevels.map((level) => ( + {level} + ))} + + - - setContinentFilter(event.target.value as ContinentFilter) - } - className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - > - {continentOptions.map((continent) => ( - - {continent === "Todos" ? "Continente (Todos)" : continent} - - ))} - + + + + + Continente + + + setContinentFilter(event.target.value as ContinentFilter) + } + className={cn( + controlClassName, + continentFilter !== "Todos" && "border-primary/60", + )} + > + {continentOptions.map((continent) => ( + + {continent === "Todos" ? "Continente (Todos)" : continent} + + ))} + + - - setCountryFilter(event.target.value as CountryFilter) - } - className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - > - {countryOptions.map((country) => ( - - {country === "Todos" ? "País (Todos)" : country} - - ))} - + + + + País + + + setCountryFilter(event.target.value as CountryFilter) + } + className={cn( + controlClassName, + countryFilter !== "Todos" && "border-primary/60", + )} + > + {countryOptions.map((country) => ( + + {country === "Todos" ? "País (Todos)" : country} + + ))} + + - setMatchSort(event.target.value as MatchSort)} - className="h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring" - > - Match (padrão) - Maior match - Menor match - - + + + + Estado / Região + + setStateFilter(event.target.value)} + maxLength={100} + placeholder="Ex: SP, SC..." + title="Para estados brasileiros, use a sigla, como SP ou SC." + className={cn( + controlClassName, + stateFilter.trim() && "border-primary/60", + )} + /> + + + + + Cidade + + setCityFilter(event.target.value)} + maxLength={100} + placeholder="Ex: São Paulo, Lisboa..." + className={cn( + controlClassName, + cityFilter.trim() && "border-primary/60", + )} + /> + + + + + + + Filtros ativos: + + {activeSelected.length > 0 + ? activeSelected.map((id) => ( + + + {activeOptions.find((family) => family.id === id)?.label ?? + id} + + {setSelectedFamilies && ( + family.id === id)?.label ?? id}`} + onClick={() => + setSelectedFamilies( + activeSelected.filter((value) => value !== id), + ) + } + className="rounded-sm p-0.5 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + + + )} + + )) + : null} + {otherActiveFilters.map((label) => ( + + {label} + + ))} + {activeSelected.length === 0 && otherActiveFilters.length === 0 && ( + + Nenhum filtro específico selecionado. + + )} + + + > ); } diff --git a/frontend/src/features/jobs/components/JobLocation.tsx b/frontend/src/features/jobs/components/JobLocation.tsx new file mode 100644 index 00000000..c6303fff --- /dev/null +++ b/frontend/src/features/jobs/components/JobLocation.tsx @@ -0,0 +1,72 @@ +import { MapPin, X } from "lucide-react"; +import { Popover } from "radix-ui"; +import { formatJobLocation } from "../utils/formatJobLocation"; + +export function JobLocation({ + location, + structured, +}: { + location: string; + structured?: Record; +}) { + const formatted = formatJobLocation(location, structured); + return ( + + + + + {formatted.primary} + + {formatted.additional.length > 0 && ( + + + + +{formatted.additional.length}{" "} + {formatted.additional.length === 1 ? "local" : "locais"} + + + + + + + Outras localidades + + + + + + + {formatted.additional.map((value) => ( + + {value} + + ))} + + + + + )} + + + ); +} diff --git a/frontend/src/features/jobs/components/JobRow.tsx b/frontend/src/features/jobs/components/JobRow.tsx index a0538686..3cc5aea7 100644 --- a/frontend/src/features/jobs/components/JobRow.tsx +++ b/frontend/src/features/jobs/components/JobRow.tsx @@ -1,6 +1,8 @@ import { jobStatuses } from "../constants"; import type { Job, JobStatus } from "../domain/job"; import { getFamilyLabel } from "@/features/jobs/api/dashboardJobsApi"; +import { JobLocation } from "./JobLocation"; +import { TechnologyTags } from "./TechnologyTags"; interface JobRowProps { job: Job; @@ -32,77 +34,166 @@ function matchedTechnologies(job: Job) { } function primaryFamily(job: Job) { - const classification = job.rawPayload?.classification as { primaryFamily?: string } | undefined; + const classification = job.rawPayload?.classification as + { primaryFamily?: string } | undefined; if (!classification?.primaryFamily) return null; return getFamilyLabel(classification.primaryFamily); } +function stringValues(value: unknown): string[] { + return Array.isArray(value) + ? [ + ...new Set( + value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim()) + .filter(Boolean), + ), + ] + : []; +} + +function jobTechnologies(job: Job) { + const classification = job.rawPayload?.classification as + { technologies?: unknown } | undefined; + const classified = stringValues(classification?.technologies); + if (classified.length > 0) return classified; + const matched = stringValues(job.rawPayload?.matchedTechnologies); + return matched.length > 0 + ? matched + : stringValues(job.tags).filter((tag) => tag !== "Geral"); +} + export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { const matched = matchedTechnologies(job); const fam = primaryFamily(job); + const technologies = jobTechnologies(job); return ( - - - onOpen(job)} - className="block text-left" - > - - {job.jobTitle} - - - {job.company} - {fam ? • {fam} : null} - - + + + + + onOpen(job)} + className="min-w-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + + {job.jobTitle} + + + {job.company} + + + {technologies.length > 0 && ( + + )} + + {fam ? ( + + {fam} + + ) : null} + + + + + Localização regional + + - + + + Fonte + {normalizeSource(job.source)} - + + + Modelo + {job.type} - + + + Nível + {job.level} - + + + Publicada em + {job.posted} - + + + Match + 0 + job.matchScore === null + ? "Compatibilidade não calculada" + : matched.length > 0 ? `Tecnologias em comum: ${matched.join(", ")}` : "Sem tecnologias do perfil identificadas no texto da vaga" } > - {job.matchScore}% + {job.matchScore === null ? "Não calculado" : `${job.matchScore}%`} - - onOpen(job)} - className="mr-3 text-xs font-bold text-slate-600 hover:text-foreground" - > - Detalhes - - onStatusChange(job.id, "saved")} - className="rounded-md bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90" - title={`Salvar vaga. Status atual: ${jobStatuses[job.status]}`} - > - Salvar - + + + onOpen(job)} + className="inline-flex h-8 items-center justify-center rounded border border-input bg-background px-2.5 text-[10px] font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + > + Detalhes + + onStatusChange(job.id, "saved")} + className="inline-flex h-8 items-center justify-center rounded border border-primary/25 bg-primary/10 px-2.5 text-[10px] font-semibold text-accent transition-colors hover:bg-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" + title={`Salvar vaga. Status atual: ${jobStatuses[job.status]}`} + > + Salvar + + ); diff --git a/frontend/src/features/jobs/components/JobTab.tsx b/frontend/src/features/jobs/components/JobTab.tsx index 9f01be0d..d170e2cf 100644 --- a/frontend/src/features/jobs/components/JobTab.tsx +++ b/frontend/src/features/jobs/components/JobTab.tsx @@ -29,6 +29,10 @@ interface JobTabProps { setContinentFilter: (value: ContinentFilter) => void; countryFilter: CountryFilter; setCountryFilter: (value: CountryFilter) => void; + stateFilter: string; + setStateFilter: (value: string) => void; + cityFilter: string; + setCityFilter: (value: string) => void; matchSort: MatchSort; setMatchSort: (value: MatchSort) => void; selectedFamilies: string[]; @@ -62,6 +66,10 @@ export function JobTab({ setContinentFilter, countryFilter, setCountryFilter, + stateFilter, + setStateFilter, + cityFilter, + setCityFilter, matchSort, setMatchSort, selectedFamilies, @@ -86,12 +94,12 @@ export function JobTab({ return ( - - - + + + Vagas abertas recomendadas - + Encontramos oportunidades ideais com base nas preferências do seu perfil. @@ -100,9 +108,9 @@ export function JobTab({ type="button" onClick={() => onSearchJobs()} disabled={isSearching} - className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-primary px-5 text-sm font-bold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-wait disabled:opacity-60" + className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-wait disabled:opacity-60" > - + {isSearching ? "Procurando..." : "Buscar vagas"} @@ -118,8 +126,10 @@ export function JobTab({ setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} - matchSort={matchSort} - setMatchSort={setMatchSort} + stateFilter={stateFilter} + setStateFilter={setStateFilter} + cityFilter={cityFilter} + setCityFilter={setCityFilter} selectedFamilies={selectedFamilies} setSelectedFamilies={setSelectedFamilies} familyOptions={familyOptions} @@ -136,6 +146,8 @@ export function JobTab({ = { @@ -53,6 +53,16 @@ function compareJobs( column: SortColumn, direction: SortDirection, ) { + if (column === "match") { + const score = (job: Job) => + typeof job.matchScore === "number" && Number.isFinite(job.matchScore) + ? job.matchScore + : 0; + return direction === "desc" + ? score(second) - score(first) + : score(first) - score(second); + } + if (column === "source") { return compareNullableValues( first.source.trim() || null, @@ -82,6 +92,8 @@ function compareJobs( interface JobTableProps { jobs: Job[]; + matchSort?: MatchSort; + onMatchSortChange?: (value: MatchSort) => void; onOpenJob: (job: Job) => void; onStatusChange: (jobId: string, status: JobStatus) => void; pagination?: { @@ -96,6 +108,8 @@ interface JobTableProps { export function JobTable({ jobs, + matchSort = "default", + onMatchSortChange, onOpenJob, onStatusChange, pagination, @@ -108,9 +122,22 @@ export function JobTable({ column: SortColumn; direction: SortDirection; } | null>(null); + const activeSort = useMemo( + () => + sort ?? + (matchSort !== "default" + ? { column: "match" as const, direction: matchSort } + : null), + [sort, matchSort], + ); const sortedJobs = useMemo(() => { - if (!sort) return jobs; + // Com paginação remota, Match é ordenado pela API sobre todos os resultados. + if ( + !activeSort || + (activeSort.column === "match" && (onMatchSortChange || pagination)) + ) + return jobs; return jobs .map((job, index) => ({ job, index })) @@ -119,12 +146,12 @@ export function JobTable({ compareJobs( first.job, second.job, - sort.column, - sort.direction, + activeSort.column, + activeSort.direction, ) || first.index - second.index, ) .map(({ job }) => job); - }, [jobs, sort]); + }, [jobs, activeSort, onMatchSortChange, pagination]); if (jobs.length === 0) { return ( @@ -165,6 +192,16 @@ export function JobTable({ ); const toggleSort = (column: SortColumn) => { + if (column === "match" && onMatchSortChange) { + setSort(null); + setRequestedPage(1); + onMatchSortChange( + activeSort?.column === "match" && activeSort.direction === "desc" + ? "asc" + : "desc", + ); + return; + } setSort((current) => { if (current?.column === column) { return { @@ -175,17 +212,18 @@ export function JobTable({ return { column, - direction: column === "posted" ? "desc" : "asc", + direction: column === "posted" || column === "match" ? "desc" : "asc", }; }); + if (column === "match") setRequestedPage(1); }; const sortIcon = (column: SortColumn) => { - if (sort?.column !== column) { + if (activeSort?.column !== column) { return ; } - return sort.direction === "asc" ? ( + return activeSort.direction === "asc" ? ( ) : ( @@ -193,40 +231,73 @@ export function JobTable({ }; const ariaSort = (column: SortColumn) => { - if (sort?.column !== column) return "none"; - return sort.direction === "asc" ? "ascending" : "descending"; + if (activeSort?.column !== column) return "none"; + return activeSort.direction === "asc" ? "ascending" : "descending"; }; return ( - + - - - - Cargo / empresa + + + + + Cargo / empresa / área + + + Localização regional + toggleSort("source")} - className="inline-flex items-center gap-1.5 hover:text-foreground" + className={`inline-flex items-center gap-1.5 ${activeSort?.column === "source" ? "text-primary hover:text-primary" : "hover:text-foreground"}`} aria-label="Ordenar por fonte" > Fonte {sortIcon("source")} - Modelo + Modelo + + toggleSort("level")} - className="inline-flex items-center gap-1.5 hover:text-foreground" + className={`inline-flex items-center gap-1.5 ${activeSort?.column === "level" ? "text-primary hover:text-primary" : "hover:text-foreground"}`} aria-label="Ordenar por nível" > Nível @@ -234,24 +305,48 @@ export function JobTable({ toggleSort("posted")} - className="inline-flex items-center gap-1.5 hover:text-foreground" + className={`inline-flex items-center gap-1.5 ${activeSort?.column === "posted" ? "text-primary hover:text-primary" : "hover:text-foreground"}`} aria-label="Ordenar por data de publicação" > Publicada em {sortIcon("posted")} - Match - Ações + + toggleSort("match")} + disabled={Boolean(pagination && !onMatchSortChange)} + className={`inline-flex items-center gap-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 ${activeSort?.column === "match" ? "text-primary hover:text-primary" : "hover:text-foreground"}`} + aria-label="Ordenar por match" + > + Match + {sortIcon("match")} + + + + Ações + - + {visibleJobs.map((job) => ( - - + + Exibindo {firstVisibleItem}-{lastVisibleItem} de {totalItems} vagas diff --git a/frontend/src/features/jobs/components/TechnologyTags.tsx b/frontend/src/features/jobs/components/TechnologyTags.tsx new file mode 100644 index 00000000..be47a02b --- /dev/null +++ b/frontend/src/features/jobs/components/TechnologyTags.tsx @@ -0,0 +1,147 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Popover } from "radix-ui"; +import { X } from "lucide-react"; + +const tagClassName = + "shrink-0 whitespace-nowrap rounded border border-input bg-background px-1.5 py-0.5 font-mono text-[9px] leading-3 text-foreground"; + +export function TechnologyTags({ technologies }: { technologies: string[] }) { + const lineRef = useRef(null); + const measurementRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(technologies.length); + + useLayoutEffect(() => { + let active = true; + const measure = () => { + const line = lineRef.current; + const measurement = measurementRef.current; + if (!active || !line || !measurement) return; + const available = line.getBoundingClientRect().width; + if (available <= 0) return; + const widths = Array.from( + measurement.querySelectorAll("[data-tag-width]"), + (tag) => tag.getBoundingClientRect().width, + ); + const gap = Number.parseFloat(getComputedStyle(line).columnGap) || 6; + const total = + widths.reduce((sum, width) => sum + width, 0) + + Math.max(0, widths.length - 1) * gap; + let count = widths.length; + if (total > available) { + const counter = + measurement + .querySelector("[data-overflow-width]") + ?.getBoundingClientRect().width ?? 0; + let used = counter; + count = 0; + for (const width of widths) { + if (used + gap + width > available) break; + used += gap + width; + count++; + } + } + setVisibleCount((current) => (current === count ? current : count)); + }; + measure(); + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(measure); + if (lineRef.current) observer?.observe(lineRef.current); + window.addEventListener("resize", measure); + void document.fonts?.ready.then(measure); + return () => { + active = false; + observer?.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [technologies]); + + const count = Math.min(visibleCount, technologies.length); + const remaining = technologies.slice(count); + + return ( + + + + {technologies.slice(0, count).map((technology) => ( + + {technology} + + ))} + {remaining.length > 0 && ( + + + +{remaining.length} + + + )} + + + {technologies.map((technology) => ( + + ))} + + + + {remaining.length > 0 && ( + + + + + Outras tecnologias e palavras-chave + + + + + + + {remaining.map((technology) => ( + + {technology} + + ))} + + + + )} + + ); +} diff --git a/frontend/src/features/jobs/domain/job.ts b/frontend/src/features/jobs/domain/job.ts index 3b633545..9ec74c99 100644 --- a/frontend/src/features/jobs/domain/job.ts +++ b/frontend/src/features/jobs/domain/job.ts @@ -24,7 +24,7 @@ export const JobSchema = z.object({ salary: z.string().min(1), type: JobTypeSchema, level: JobLevelSchema, - matchScore: z.number().int().min(0).max(100), + matchScore: z.number().int().min(0).max(100).nullable(), tags: z.array(z.string()), posted: z.string().min(1), status: JobStatusSchema, diff --git a/frontend/src/features/jobs/hooks/useDashboardJobs.ts b/frontend/src/features/jobs/hooks/useDashboardJobs.ts index dea0256c..c6c9407a 100644 --- a/frontend/src/features/jobs/hooks/useDashboardJobs.ts +++ b/frontend/src/features/jobs/hooks/useDashboardJobs.ts @@ -51,7 +51,15 @@ function readCachedTrackedJobs(userId: string): Job[] { if (!raw) return []; const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; + // O cache antigo podia conter scores artificiais calculados no cliente. + return Array.isArray(parsed) + ? parsed.map((job: Job) => ({ + ...job, + matchScore: job.rawPayload?.matchSource === "backend_profile" + ? job.matchScore + : null, + })) + : []; } catch { return []; } diff --git a/frontend/src/features/jobs/utils/formatJobLocation.ts b/frontend/src/features/jobs/utils/formatJobLocation.ts new file mode 100644 index 00000000..5a801449 --- /dev/null +++ b/frontend/src/features/jobs/utils/formatJobLocation.ts @@ -0,0 +1,251 @@ +type Place = { city: string; state: string; country: string; extra: string[] }; +const key = (value: string) => + value + .normalize("NFD") + .replace(/\p{Diacritic}/gu, "") + .replace(/\./g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +const text = (value: unknown) => + typeof value === "string" ? value.trim() : ""; +const countries: Record = { + brasil: "Brasil", + brazil: "Brasil", + br: "Brasil", + usa: "EUA", + us: "EUA", + eua: "EUA", + "united states": "EUA", + "united states of america": "EUA", + "estados unidos": "EUA", + india: "Índia", + canada: "Canadá", + portugal: "Portugal", + germany: "Alemanha", + alemanha: "Alemanha", + france: "França", + franca: "França", + spain: "Espanha", + espanha: "Espanha", + "united kingdom": "Reino Unido", + uk: "Reino Unido", + "reino unido": "Reino Unido", +}; +function stateAliases(country: string) { + const definitions = + country === "Brasil" + ? "São Paulo:SP|Rio de Janeiro:RJ|Minas Gerais:MG|Santa Catarina:SC|Paraná:PR|Rio Grande do Sul:RS|Bahia:BA|Pernambuco:PE|Distrito Federal:DF|Ceará:CE" + : country === "EUA" + ? "Texas:TX|California:CA|New York:NY|Washington:WA|Florida:FL|Illinois:IL|Massachusetts:MA|Georgia:GA|Indiana:IN|New Jersey:NJ|Colorado:CO|Virginia:VA" + : ""; + return new Map( + definitions + .split("|") + .filter(Boolean) + .flatMap((entry) => { + const [name, code] = entry.split(":"); + return [ + [key(name), code], + [key(code), code], + ]; + }), + ); +} +const compatible = (a: Place, b: Place) => + key(a.city) === key(b.city) && + (!a.state || !b.state || key(a.state) === key(b.state)) && + (!a.country || !b.country || a.country === b.country) && + (!a.extra.length || + !b.extra.length || + a.extra.map(key).join("|") === b.extra.map(key).join("|")); + +/** Formatação de apresentação: não altera nem reinterpreta os valores da busca. */ +export function formatJobLocation( + location: unknown, + structured: Record = {}, +) { + const rawCountry = text(structured.country); + const structuredCountry = countries[key(rawCountry)] || rawCountry; + const original = text(location); + const missing = [ + "", + "local nao informado", + "localizacao nao informada", + "unknown", + "not specified", + "n/a", + ].includes(key(original)); + const chunks = (missing ? "" : original).split(/[|;\n/]+/).map((chunk) => + chunk + .replace( + /\b(?:trabalho\s+remoto|home\s+office|remote|remoto|remota)\b/gi, + "", + ) + .replace(/\s+[-–—]\s+/g, ",") + .split(",") + .map((part) => part.trim().replace(/^[-–—\s]+|[-–—\s]+$/g, "")) + .filter(Boolean), + ); + const explicitCountries = [ + ...new Set( + chunks.flatMap((parts) => + parts + .filter((_, index) => index > 0 || parts.length === 1) + .map((part) => countries[key(part)]) + .filter(Boolean), + ), + ), + ]; + const context = + structuredCountry || + (explicitCountries.length === 1 ? explicitCountries[0] : ""); + const sharedCountry = text(structured.city) ? "" : structuredCountry; + function aliasesFor(country: string, city: string) { + if (country) return stateAliases(country); + // Sem país, aceitar o nome por extenso somente quando a sigla já aparece + // para a mesma cidade. Isso não acrescenta um país nem usa aproximações. + const codes = new Set( + chunks + .filter((parts) => key(parts[0] || "") === key(city)) + .flatMap((parts) => parts.slice(1)) + .filter((part) => /^[A-Z]{2,3}$/.test(part)), + ); + if ( + key(text(structured.city)) === key(city) && + /^[A-Z]{2,3}$/.test(text(structured.state)) + ) + codes.add(text(structured.state)); + const aliases = new Map(); + for (const knownCountry of ["Brasil", "EUA"]) { + for (const [name, code] of stateAliases(knownCountry)) + if (codes.has(code)) aliases.set(name, code); + } + return aliases; + } + function normalized(place: Place): Place { + const country = countries[key(place.country)] || place.country; + const aliases = aliasesFor(country, place.city); + const state = aliases.get(key(place.state)) || place.state; + const extra = place.extra.filter( + (value) => + key(value) !== key(state) && + (!state || aliases.get(key(value)) !== state) && + countries[key(value)] !== country, + ); + return { city: place.city, state, country, extra }; + } + function parse(parts: string[], countryFallback = sharedCountry): Place { + const countryIndex = parts.findIndex( + (part, index) => + (index > 0 || parts.length === 1) && Boolean(countries[key(part)]), + ); + const country = + countryIndex >= 0 ? countries[key(parts[countryIndex])] : countryFallback; + const rest = parts.filter( + (part, index) => + index !== countryIndex && + (index === 0 || countries[key(part)] !== country), + ); + const city = rest[0] || ""; + const sameCityContext = chunks.some( + (parts) => + key(parts[0] || "") === key(city) && + parts.slice(1).some((part) => countries[key(part)] === context), + ); + const aliases = aliasesFor( + country || (sameCityContext ? context : ""), + city, + ); + let state = ""; + const extra: string[] = []; + for (const part of rest.slice(1)) { + const code = aliases.get(key(part)); + if (code && (!state || state === code)) state = code; + else if ( + key(part) !== key(city) && + !extra.some((value) => key(value) === key(part)) + ) + extra.push(part); + } + return normalized({ city, state, country, extra }); + } + const places: Place[] = []; + function add(place: Place, prefer = false) { + place = normalized(place); + if (!place.city && !place.state && !place.country && !place.extra.length) + return; + const matches = places.filter((current) => + compatible(normalized(current), place), + ); + if (matches.length === 1) { + const current = matches[0]; + if (prefer && place.city) current.city = place.city; + current.state ||= place.state; + current.country ||= place.country; + if (!current.extra.length) current.extra = [...place.extra]; + Object.assign(current, normalized(current)); + } else places.push(place); + } + for (const parts of chunks) if (parts.length) add(parse(parts)); + const city = text(structured.city), + state = text(structured.state); + if (city || state) { + const structuredPlace = normalized({ + city, + state, + country: structuredCountry, + extra: [], + }); + const represented = + !city && + places.filter((place) => { + const aliases = aliasesFor( + structuredPlace.country || place.country, + place.city, + ); + const expected = + aliases.get(key(structuredPlace.state)) || structuredPlace.state; + const actual = aliases.get(key(place.state)) || place.state; + return ( + place.city && + (!expected || key(actual) === key(expected)) && + (!place.country || + !structuredPlace.country || + place.country === structuredPlace.country) + ); + }); + if (represented && represented.length > 0) { + for (const place of represented) { + place.country ||= structuredPlace.country; + Object.assign(place, normalized(place)); + } + } else add(structuredPlace, true); + } + const labels = [ + ...new Set( + places + .filter( + (place) => + place.city || + place.state || + place.extra.length || + !places.some( + (other) => + other !== place && + other.country === place.country && + Boolean(other.city || other.state), + ), + ) + .map((place) => + [place.city, place.state, ...place.extra, place.country] + .filter(Boolean) + .join(", "), + ), + ), + ]; + return { + primary: labels[0] || "Localização não informada", + additional: labels.slice(1), + }; +} diff --git a/frontend/src/features/jobs/utils/jobDetailPresentation.ts b/frontend/src/features/jobs/utils/jobDetailPresentation.ts new file mode 100644 index 00000000..21f16e93 --- /dev/null +++ b/frontend/src/features/jobs/utils/jobDetailPresentation.ts @@ -0,0 +1,72 @@ +const labels: Record = { + id: "ID", + title: "Título", + company: "Empresa", + location: "Local", + url: "URL", + salary: "Salário", + modality: "Modalidade", + description: "Descrição", + source: "Fonte", + sources: "Fontes", + match: "Compatibilidade", + matchScore: "Índice de compatibilidade", + matchSource: "Origem da compatibilidade", + keyword: "Palavra-chave", + keywords: "Palavras-chave", + matchedTechnologies: "Tecnologias correspondentes", + matchReasons: "Justificativas da compatibilidade", + postedAt: "Publicado em", + publishedAt: "Publicado em", + additionalDetails: "Detalhes adicionais", + technologies: "Tecnologias", + seniority: "Nível", + country: "País", + state: "Estado / Região", + city: "Cidade", + continent: "Continente", +}; + +export const jobDetailLabel = (key: string) => labels[key] ?? key; + +/** Mantém o horário e o deslocamento fornecidos pela origem, sem conversão implícita. */ +export function formatJobPublicationDate(value: unknown): string { + if (typeof value !== "string") return "Data não informada"; + const match = + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/.exec( + value.trim(), + ); + if (!match) return "Data não informada"; + const [, year, month, day, hour, minute, second, zone] = match; + const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))); + if ( + date.getUTCFullYear() !== Number(year) || + date.getUTCMonth() + 1 !== Number(month) || + date.getUTCDate() !== Number(day) + ) + return "Data não informada"; + if ( + hour && + (Number(hour) > 23 || + Number(minute) > 59 || + Number(second || 0) > 59 || + (zone && Number.isNaN(Date.parse(value)))) + ) + return "Data não informada"; + return `${day}/${month}/${year}${hour ? ` às ${hour}:${minute}` : ""}`; +} + +export function normalizeDescriptionText(value: string) { + return value + .replace(/\r\n?/g, "\n") + .replace(/\u00a0/g, " ") + .replace(/[\t ]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n"); +} + +export function formatCompatibilityReason(value: string) { + return value + .replace(/^(?:titulo|título|title)\s*:/i, "Título:") + .replace(/^technology\s*:/i, "Tecnologia:"); +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 7578c864..2a33ca71 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -6,27 +6,35 @@ @layer base { :root { - --background: 33 53% 100%; - --foreground: 210 30% 15%; + --background: 210 40% 98.0392%; + --foreground: 222.2222 47.3684% 11.1765%; --card: 0 0% 100%; - --card-foreground: 210 30% 15%; + --card-foreground: 222.2222 47.3684% 11.1765%; - --primary: 140 70% 20%; + --primary: 142.4299 71.8121% 29.2157%; /* #15803d */ + --primary-hover: 142.7848 64.2276% 24.1176%; /* #166534 */ --primary-foreground: 0 0% 100%; - --secondary: 38 52% 86%; - --secondary-foreground: 210 30% 15%; + --hero-background: #166534; + --hero-foreground: #ffffff; + --hero-secondary-foreground: #dcfce7; + --menu-active-background: rgba(21, 128, 61, 0.08); + --menu-active-border: rgba(21, 128, 61, 0.22); + --menu-active-foreground: #166534; - --muted: 36 35% 91%; - --muted-foreground: 210 15% 40%; + --secondary: 210 40% 96.0784%; + --secondary-foreground: 215.2941 19.3182% 34.5098%; - --accent: 140 75% 38%; + --muted: 210 40% 96.0784%; + --muted-foreground: 215.3846 16.318% 46.8627%; + + --accent: var(--primary-hover); --accent-foreground: 0 0% 100%; - --border: 214 30% 96%; - --input: 214 24% 94%; - --ring: 140 75% 38%; + --border: 214.2857 31.8182% 91.3725%; + --input: 212.7273 26.8293% 83.9216%; /* Input border, per Tailwind's existing contract. */ + --ring: var(--primary); --radius: 0.8rem; @@ -34,26 +42,34 @@ } .dark { - --background: 215 30% 10%; - --foreground: 210 30% 95%; + --background: 222.8571 38.8889% 7.0588%; + --foreground: 0 0% 100%; + + --card: 222.2222 41.5385% 12.7451%; + --card-foreground: 0 0% 100%; - --card: 216 26% 14%; - --card-foreground: 210 30% 95%; + --primary: 142.1277 76.2162% 36.2745%; /* #16a34a */ + --primary-hover: 142.0859 70.5628% 45.2941%; /* #22c55e */ - --primary: 140 70% 20%; + --hero-background: #0f3d26; + --hero-foreground: #ffffff; + --hero-secondary-foreground: #d1fae5; + --menu-active-background: rgba(22, 163, 74, 0.12); + --menu-active-border: rgba(34, 197, 94, 0.28); + --menu-active-foreground: #22c55e; - --secondary: 216 20% 23%; - --secondary-foreground: 210 20% 94%; + --secondary: 217.2414 32.5843% 17.451%; + --secondary-foreground: 217.8947 10.6145% 64.902%; - --muted: 216 20% 19%; - --muted-foreground: 215 20% 72%; + --muted: 217.2414 32.5843% 17.451%; + --muted-foreground: 215 20.2247% 65.098%; - --accent: 140 50% 45%; + --accent: var(--primary-hover); --accent-foreground: 0 0% 100%; - --border: 216 18% 16%; - --input: 216 18% 18%; - --ring: 140 75% 38%; + --border: 217.2414 32.5843% 17.451%; + --input: 215 32.1429% 21.9608%; + --ring: var(--primary-hover); color-scheme: dark; } diff --git a/frontend/tests/unit/new_dashboard/TechnologyTags.test.tsx b/frontend/tests/unit/new_dashboard/TechnologyTags.test.tsx new file mode 100644 index 00000000..79aa4355 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/TechnologyTags.test.tsx @@ -0,0 +1,100 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TechnologyTags } from "@/features/jobs/components/TechnologyTags"; + +let available = 100; +let resize = () => {}; + +beforeEach(() => { + available = 100; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(private callback: ResizeObserverCallback) {} + observe(target: Element) { + if (target.hasAttribute("data-technology-line")) + resize = () => this.callback([], this as unknown as ResizeObserver); + } + unobserve() {} + disconnect() {} + }, + ); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + const width = this.hasAttribute("data-technology-line") + ? available + : this.hasAttribute("data-tag-width") + ? 40 + : this.hasAttribute("data-overflow-width") + ? 20 + : 0; + return { + width, + height: 16, + top: 0, + left: 0, + bottom: 16, + right: width, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }, + ); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("TechnologyTags", () => { + it("mede o espaço, abre apenas as tags restantes, fecha e recalcula no resize", async () => { + render(); + const trigger = screen.getByRole("button", { + name: "Mostrar 2 tecnologias e palavras-chave restantes", + }); + expect(trigger).toHaveTextContent("+2"); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.queryByText("Go")).not.toBeInTheDocument(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { + name: "Tecnologias e palavras-chave restantes", + }); + expect(within(dialog).getByText("TypeScript")).toBeInTheDocument(); + expect(within(dialog).getByText("Go")).toBeInTheDocument(); + expect(within(dialog).queryByText("React")).not.toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Fechar tecnologias restantes" }), + ); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + fireEvent.click(trigger); + fireEvent.keyDown(document, { key: "Escape" }); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + await waitFor(() => expect(trigger).toHaveFocus()); + available = 200; + act(() => resize()); + expect( + screen.queryByRole("button", { name: /restantes/ }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Go")).toBeInTheDocument(); + }); + + it("não apresenta +N quando todas as tags cabem", () => { + available = 200; + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("React")).toBeInTheDocument(); + expect(screen.getByText("TypeScript")).toBeInTheDocument(); + expect(screen.getByText("Go")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/api.test.ts b/frontend/tests/unit/new_dashboard/api.test.ts index 67a4960a..7211f5cc 100644 --- a/frontend/tests/unit/new_dashboard/api.test.ts +++ b/frontend/tests/unit/new_dashboard/api.test.ts @@ -17,6 +17,7 @@ import { getDashboardSavedJobEvents, getDashboardSavedJobs, searchDashboardJobs, + buildFilterParams, toRecommendedJob, updateDashboardSavedJob, } from "@/features/jobs/api/dashboardJobsApi"; @@ -36,6 +37,38 @@ import { } from "@/features/profile/api/userDashboardApi"; describe("new_dashboard api adapters", () => { + it.each(["desc", "asc"] as const)("envia cinco áreas com Match %s e preserva a página e scores da API", async (matchSort) => { + const family = "backend,fullstack,frontend,devops,software"; + apiMock.get.mockResolvedValue({ data: { + total: 100, page: 2, limit: 4, totalPages: 25, + jobs: (matchSort === "desc" ? [66, 45, 45, 20, 0, null] : [null, 0, 20, 45, 45, 66]).map((matchScore, index) => ({ + title: `Vaga ${index}`, url: `https://example.com/${index}`, + matchScore, matchSource: "backend_profile", + })), + } }); + const result = await searchDashboardJobs([], { family, familyMode: "primary", model: "remoto", matchSort }, 2, 4); + expect(apiMock.get).toHaveBeenLastCalledWith("/jobs/search", { + params: { family, familyMode: "primary", model: "remoto", matchSort, page: 2, limit: 4 }, + }); + expect(result.jobs.map((job) => job.matchScore)).toEqual(matchSort === "desc" ? [66, 45, 45, 20, 0, null] : [null, 0, 20, 45, 45, 66]); + expect(result.pagination).toMatchObject({ total: 100, page: 2, limit: 4 }); + apiMock.get.mockResolvedValue({ data: { jobs: [] } }); + await searchDashboardJobs([], { matchSort }); + expect(apiMock.get).toHaveBeenLastCalledWith("/jobs/search", { + params: { matchSort, page: 1, limit: 50 }, + }); + }); + + it("normaliza Estado/Cidade e omite campos vazios no contrato existente", () => { + expect(buildFilterParams({ matchSort: "asc" })).toEqual({ matchSort: "asc" }); + expect(buildFilterParams({ matchSort: "desc" })).toEqual({ matchSort: "desc" }); + expect(buildFilterParams({ state: " SC ", city: " Joinville " })).toEqual({ + state: "SC", + city: "Joinville", + }); + expect(buildFilterParams({ state: " ", city: "" })).toEqual({}); + }); + beforeEach(() => { apiMock.get.mockReset(); apiMock.post.mockReset(); @@ -78,6 +111,8 @@ describe("new_dashboard api adapters", () => { level: "Pleno", continent: "América do Sul", country: "Brasil", + state: "SC", + city: "Joinville", matchSort: "desc", }, 3, @@ -91,6 +126,8 @@ describe("new_dashboard api adapters", () => { level: "Pleno", continent: "América do Sul", country: "Brasil", + state: "SC", + city: "Joinville", matchSort: "desc", page: 3, limit: 25, diff --git a/frontend/tests/unit/new_dashboard/formatJobLocation.test.tsx b/frontend/tests/unit/new_dashboard/formatJobLocation.test.tsx new file mode 100644 index 00000000..f3646402 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/formatJobLocation.test.tsx @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { formatJobLocation } from "@/features/jobs/utils/formatJobLocation"; +import { JobLocation } from "@/features/jobs/components/JobLocation"; + +describe("formatJobLocation", () => { + it.each([ + ["São Paulo, SP, São Paulo, São Paulo, Brasil", "São Paulo, SP, Brasil"], + ["Austin, Texas, United States | Austin, TX", "Austin, TX, EUA"], + ["New York, NY | New York, New York, United States", "New York, NY, EUA"], + ["Remote, Brazil | Campinas, São Paulo, Brazil", "Campinas, SP, Brasil"], + ["Remote USA", "EUA"], + ["Remote India | Pune, India", "Pune, Índia"], + ["Remoto, Brasil | Campinas, SP, Brasil", "Campinas, SP, Brasil"], + ["São Paulo, Brasil, Brazil", "São Paulo, Brasil"], + ["Austin, TX, U.S.A. | Austin, Texas, Estados Unidos", "Austin, TX, EUA"], + ["", "Localização não informada"], + ["Local não informado", "Localização não informada"], + ["Remote", "Localização não informada"], + ["CA", "CA"], + ["Georgia", "Georgia"], + ])("formata %s sem inferir dados", (input, primary) => { + expect(formatJobLocation(input)).toEqual({ primary, additional: [] }); + }); + it("deduplica localidades com país estruturado e preserva locais distintos", () => { + expect( + formatJobLocation( + "Los Angeles, California; Novato, California; Los Angeles, California", + { country: "United States" }, + ), + ).toEqual({ + primary: "Los Angeles, CA, EUA", + additional: ["Novato, CA, EUA"], + }); + expect( + formatJobLocation("Los Angeles, California; Novato, California"), + ).toEqual({ + primary: "Los Angeles, California", + additional: ["Novato, California"], + }); + }); + it("preserva ambiguidades, cidades homônimas e separadores variados", () => { + expect( + formatJobLocation( + "Springfield, Illinois, USA / Springfield, Massachusetts, USA\nParis, France", + ), + ).toEqual({ + primary: "Springfield, IL, EUA", + additional: ["Springfield, MA, EUA", "Paris, França"], + }); + }); + it("prioriza campos estruturados equivalentes sem mutar os originais", () => { + const payload = Object.freeze({ + city: "Austin", + state: "Texas", + country: "United States", + }); + const original = "austin, TX, USA"; + expect(formatJobLocation(original, payload)).toEqual({ + primary: "Austin, TX, EUA", + additional: [], + }); + expect(payload).toEqual({ + city: "Austin", + state: "Texas", + country: "United States", + }); + expect(original).toBe("austin, TX, USA"); + expect(formatJobLocation(undefined)).toEqual({ + primary: "Localização não informada", + additional: [], + }); + }); + it("não substitui uma localidade distinta pelos campos estruturados", () => { + expect( + formatJobLocation("Austin, Texas, USA", { + city: "Paris", + country: "France", + }), + ).toEqual({ primary: "Austin, TX, EUA", additional: ["Paris, França"] }); + }); + it.each([ + ["New York, New York, United States | New York, NY", "New York, NY, EUA"], + ["Austin, Texas, United States | Austin, TX", "Austin, TX, EUA"], + [ + "São Paulo, São Paulo, Brasil | São Paulo, SP, Brasil", + "São Paulo, SP, Brasil", + ], + ["Remote USA | New York, NY, USA", "New York, NY, EUA"], + ["Austin, Texas | Austin, TX", "Austin, TX"], + ["Austin, TX | Austin, Texas", "Austin, TX"], + ])("unifica a identidade geográfica de %s", (value, primary) => { + expect(formatJobLocation(value)).toEqual({ primary, additional: [] }); + }); + it("não conta estado estruturado equivalente como uma nova localidade", () => { + expect( + formatJobLocation("New York, NY", { + state: "New York", + country: "United States", + }), + ).toEqual({ primary: "New York, NY, EUA", additional: [] }); + expect(formatJobLocation("New York, NY", { state: "New York" })).toEqual({ + primary: "New York, NY", + additional: [], + }); + expect( + formatJobLocation("New York, NY", { + city: "New York", + state: "New York", + }), + ).toEqual({ primary: "New York, NY", additional: [] }); + }); + it.each([ + ["New York, NY | Boston, MA", "New York, NY", "Boston, MA"], + ["Washington, DC | Arlington, VA", "Washington, DC", "Arlington, VA"], + ["Austin, TX | Dallas, TX", "Austin, TX", "Dallas, TX"], + ["Portland, OR | Portland, ME", "Portland, OR", "Portland, ME"], + ])("preserva localidades distintas em %s", (value, primary, additional) => { + expect(formatJobLocation(value)).toEqual({ + primary, + additional: [additional], + }); + }); + it("omite +N para equivalências e mantém +1 para cidades distintas", () => { + const view = render( + , + ); + expect(screen.getByText("New York, NY, EUA")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Consultar/ }), + ).not.toBeInTheDocument(); + view.rerender(); + expect(screen.getByRole("button", { name: /Consultar/ })).toHaveTextContent( + "+1 local", + ); + }); + it("permite consultar e fechar as localidades adicionais", () => { + render(); + expect(screen.getByText("Lisboa, Portugal")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { + name: "Consultar 1 localidades adicionais", + }), + ); + expect( + within( + screen.getByRole("dialog", { name: "Localidades adicionais" }), + ).getByText("Porto, Portugal"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Fechar localidades" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/hooks.test.tsx b/frontend/tests/unit/new_dashboard/hooks.test.tsx index f9b89d93..6879a6a9 100644 --- a/frontend/tests/unit/new_dashboard/hooks.test.tsx +++ b/frontend/tests/unit/new_dashboard/hooks.test.tsx @@ -357,7 +357,7 @@ describe("useDashboardJobs", () => { expect(result.current.isLoadingJobs).toBe(false); }); - expect(result.current.trackedJobs).toEqual([trackedJob]); + expect(result.current.trackedJobs).toEqual([{ ...trackedJob, matchScore: null }]); expect(onError).toHaveBeenCalledWith( "Não foi possível carregar suas vagas salvas. Não foi possível atualizar as vagas recomendadas.", ); diff --git a/frontend/tests/unit/new_dashboard/jobDetailModal.test.tsx b/frontend/tests/unit/new_dashboard/jobDetailModal.test.tsx index 5a909657..f3647c44 100644 --- a/frontend/tests/unit/new_dashboard/jobDetailModal.test.tsx +++ b/frontend/tests/unit/new_dashboard/jobDetailModal.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; vi.mock("@/features/jobs/api/dashboardJobsApi", () => ({ @@ -30,6 +30,21 @@ function job(overrides: Partial = {}): Job { const noop = () => {}; +it("normaliza locais no modal e apresenta detalhes em português sem alterar o payload", () => { + const payload = Object.freeze({ postedAt: "2026-10-28T10:00:00-03:00", matchScore: 69, matchSource: "backend_profile", keyword: "python", matchedTechnologies: ["Python"], matchReasons: ["Original English reason"] }); + const location = "São Paulo, SP | São Paulo, São Paulo, Brazil | São Paulo, Brazil"; + const view = render(); + expect(screen.getByText("São Paulo, SP, Brasil")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Consultar/ })).not.toBeInTheDocument(); + for (const label of ["Compatibilidade", "Índice de compatibilidade", "Origem da compatibilidade", "Palavra-chave", "Tecnologias correspondentes", "Justificativas da compatibilidade"]) expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.getByText("28/10/2026 às 10:00")).toHaveAttribute("title", payload.postedAt); + expect(payload.keyword).toBe("python"); + expect(location).toContain("Brazil"); + view.rerender(); + fireEvent.click(screen.getByRole("button", { name: /Consultar 1/ })); + expect(screen.getByText("Porto, Portugal")).toBeInTheDocument(); +}); + describe("JobDetailModal — informações principais (JDM-01, JDM-02)", () => { it("mostra local, modalidade, nível, fonte e salário em tiles rotulados", () => { render( @@ -51,7 +66,7 @@ describe("JobDetailModal — informações principais (JDM-01, JDM-02)", () => { expect(screen.getByText("LinkedIn")).toBeInTheDocument(); expect(screen.getByText("Salário")).toBeInTheDocument(); expect(screen.getByText("A combinar")).toBeInTheDocument(); - expect(screen.getByText("Match")).toBeInTheDocument(); + expect(screen.getByText("Compatibilidade")).toBeInTheDocument(); expect(screen.getByText("88%")).toBeInTheDocument(); }); @@ -124,7 +139,7 @@ describe("JobDetailModal — detalhes adicionais sem duplicar dado (JDM-04, JDM- expect(screen.getByText("Detalhes adicionais")).toBeInTheDocument(); expect(screen.getByText("Publicado em")).toBeInTheDocument(); - expect(screen.getByText("2026-01-10")).toBeInTheDocument(); + expect(screen.getByText("10/01/2026")).toBeInTheDocument(); // "Local"/"Modalidade" continuam existindo (como tiles), mas sem um 2º // bloco "Local"/"Modalidade" vindo do payload bruto. expect(screen.getAllByText("Local")).toHaveLength(1); diff --git a/frontend/tests/unit/new_dashboard/jobDetailPresentation.test.tsx b/frontend/tests/unit/new_dashboard/jobDetailPresentation.test.tsx new file mode 100644 index 00000000..037b0fb1 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/jobDetailPresentation.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { + formatJobPublicationDate, + jobDetailLabel, + normalizeDescriptionText, + formatCompatibilityReason, +} from "@/features/jobs/utils/jobDetailPresentation"; +import { FormattedJobDescription } from "@/features/jobs/components/FormattedJobDescription"; + +describe("apresentação dos detalhes da vaga", () => { + it.each([ + ["2026-10-28", "28/10/2026"], + ["2026-10-28T10:00:00-03:00", "28/10/2026 às 10:00"], + ["2026-10-28T23:40:00Z", "28/10/2026 às 23:40"], + ["2026-10-28T00:05:00+05:30", "28/10/2026 às 00:05"], + ["2026-02-30", "Data não informada"], + ["2026-10-28T28:00:00Z", "Data não informada"], + ["inválida", "Data não informada"], + [undefined, "Data não informada"], + ])("formata a publicação %s sem inventar horário", (value, expected) => { + expect(formatJobPublicationDate(value)).toBe(expected); + }); + it.each([ + ["match", "Compatibilidade"], + ["matchScore", "Índice de compatibilidade"], + ["matchSource", "Origem da compatibilidade"], + ["keyword", "Palavra-chave"], + ["matchedTechnologies", "Tecnologias correspondentes"], + ["matchReasons", "Justificativas da compatibilidade"], + ["postedAt", "Publicado em"], + ["publishedAt", "Publicado em"], + ["additionalDetails", "Detalhes adicionais"], + ])("traduz o rótulo %s", (value, expected) => + expect(jobDetailLabel(value)).toBe(expected), + ); + it("preserva idioma, parágrafos, listas, tecnologias e espaços de código", () => { + const description = + "About us & benefitsSalary: $5000ReactTypeScript npm test"; + const { container } = render( + , + ); + expect(container.querySelectorAll("p")).toHaveLength(2); + expect(screen.getByText("About us & benefits")).toBeInTheDocument(); + expect(screen.getByText("Salary: $5000")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + expect(container.querySelector("code")?.textContent).toBe(" npm test"); + expect(description).toContain("About us "); + expect( + normalizeDescriptionText( + " Benefits offered\n\n\n Remote work", + ).trim(), + ).toBe("Benefits offered\n\nRemote work"); + expect(formatCompatibilityReason("titulo: product manager")).toBe( + "Título: product manager", + ); + expect(formatCompatibilityReason("English reason about React")).toBe( + "English reason about React", + ); + }); +}); diff --git a/frontend/tests/unit/new_dashboard/jobs.test.tsx b/frontend/tests/unit/new_dashboard/jobs.test.tsx index aa3d3d8f..8f3ca5ec 100644 --- a/frontend/tests/unit/new_dashboard/jobs.test.tsx +++ b/frontend/tests/unit/new_dashboard/jobs.test.tsx @@ -23,7 +23,8 @@ const dashboardApiMock = vi.hoisted(() => ({ deleteDashboardApplicationNote: vi.fn(), })); -vi.mock("@/features/jobs/api/dashboardJobsApi", () => ({ +vi.mock("@/features/jobs/api/dashboardJobsApi", async (importOriginal) => ({ + ...await importOriginal(), getDashboardSavedJobEvents: dashboardApiMock.getDashboardSavedJobEvents, getDashboardApplicationNotes: dashboardApiMock.getDashboardApplicationNotes, createDashboardApplicationNote: dashboardApiMock.createDashboardApplicationNote, @@ -146,7 +147,7 @@ describe("new_dashboard job components", () => { expect(entries[1]).toHaveTextContent( /candidatura enviada para em entrevista/i, ); - expect(entries[0]).toHaveTextContent("source: dashboard"); + expect(entries[0]).toHaveTextContent("Fonte: dashboard"); view.rerender( { const setFilterLevel = vi.fn(); const setContinentFilter = vi.fn(); const setCountryFilter = vi.fn(); - const setMatchSort = vi.fn(); + const setStateFilter = vi.fn(); + const setCityFilter = vi.fn(); const setSelectedFamilies = vi.fn(); render( @@ -223,8 +225,10 @@ describe("new_dashboard job components", () => { setContinentFilter={setContinentFilter} countryFilter={"Todos" as CountryFilter} setCountryFilter={setCountryFilter} - matchSort="default" - setMatchSort={setMatchSort} + stateFilter="" + setStateFilter={setStateFilter} + cityFilter="" + setCityFilter={setCityFilter} selectedFamilies={[]} setSelectedFamilies={setSelectedFamilies} familyOptions={[{ id: "dev", label: "Desenvolvimento" }]} @@ -234,18 +238,18 @@ describe("new_dashboard job components", () => { const searchInput = screen.getByPlaceholderText( /buscar por cargo, empresa ou keywords/i, ); - const filterGrid = searchInput.closest("label")?.parentElement; - expect(searchInput).toHaveAttribute("maxlength", "100"); - expect(filterGrid).toHaveClass( - "grid-cols-1", - "sm:grid-cols-2", - "xl:grid-cols-3", - "2xl:grid-cols-[minmax(280px,1fr)_220px_repeat(5,minmax(0,180px))]", - ); - screen.getAllByRole("combobox").forEach((select) => { - expect(select).toHaveClass("w-full", "min-w-0"); - }); + const professionalFilters = within(screen.getByRole("group", { name: "Filtros profissionais" })); + const locationFilters = within(screen.getByRole("group", { name: "Localização" })); + expect(professionalFilters.getByRole("textbox", { name: "Buscar vagas" })).toBe(searchInput); + expect(professionalFilters.getByRole("combobox", { name: "Modalidade" })).toBeInTheDocument(); + expect(professionalFilters.getByRole("combobox", { name: "Senioridade" })).toBeInTheDocument(); + expect(locationFilters.getByRole("combobox", { name: "Continente" })).toBeInTheDocument(); + expect(locationFilters.getByRole("combobox", { name: "País" })).toBeInTheDocument(); + expect(locationFilters.getByRole("textbox", { name: "Estado / Região" })).toBeInTheDocument(); + expect(locationFilters.getByRole("textbox", { name: "Cidade" })).toBeInTheDocument(); + expect(locationFilters.queryByRole("combobox", { name: "Ordenação" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Ordenação" })).not.toBeInTheDocument(); fireEvent.change(searchInput, { target: { value: "react" }, @@ -255,21 +259,20 @@ describe("new_dashboard job components", () => { ); fireEvent.click(screen.getByText("Desenvolvimento")); - fireEvent.change(screen.getAllByRole("combobox")[0], { + fireEvent.change(screen.getByRole("combobox", { name: "Senioridade" }), { target: { value: "Sênior" }, }); - fireEvent.change(screen.getAllByRole("combobox")[1], { + fireEvent.change(screen.getByRole("combobox", { name: "Modalidade" }), { target: { value: "Remoto" }, }); - fireEvent.change(screen.getAllByRole("combobox")[2], { + fireEvent.change(screen.getByRole("combobox", { name: "Continente" }), { target: { value: "Europa" }, }); - fireEvent.change(screen.getAllByRole("combobox")[3], { + fireEvent.change(screen.getByRole("combobox", { name: "País" }), { target: { value: "Portugal" }, }); - fireEvent.change(screen.getAllByRole("combobox")[4], { - target: { value: "desc" }, - }); + fireEvent.change(screen.getByRole("textbox", { name: "Estado / Região" }), { target: { value: "SC" } }); + fireEvent.change(screen.getByRole("textbox", { name: "Cidade" }), { target: { value: "Joinville" } }); expect(setSearchQuery).toHaveBeenCalledWith("react"); expect(setSelectedFamilies).toHaveBeenCalledWith(["dev"]); @@ -277,7 +280,79 @@ describe("new_dashboard job components", () => { expect(setFilterType).toHaveBeenCalledWith("Remoto"); expect(setContinentFilter).toHaveBeenCalledWith("Europa"); expect(setCountryFilter).toHaveBeenCalledWith("Portugal"); - expect(setMatchSort).toHaveBeenCalledWith("desc"); + expect(setStateFilter).toHaveBeenCalledWith("SC"); + expect(setCityFilter).toHaveBeenCalledWith("Joinville"); + }); + + it("mantém seleção múltipla, tags removíveis, limpeza e fechamento do dropdown", () => { + function FiltersHarness() { + const [selectedFamilies, setSelectedFamilies] = useState([]); + return ( + {}} + filterType="Todos" + setFilterType={() => {}} + filterLevel="Todos" + setFilterLevel={() => {}} + continentFilter="Todos" + setContinentFilter={() => {}} + countryFilter="Todos" + setCountryFilter={() => {}} + stateFilter="" + setStateFilter={() => {}} + cityFilter="" + setCityFilter={() => {}} + selectedFamilies={selectedFamilies} + setSelectedFamilies={setSelectedFamilies} + familyOptions={[ + { id: "backend", label: "Backend" }, + { id: "frontend", label: "Frontend" }, + ]} + /> + ); + } + render(); + const trigger = screen.getByRole("button", { + name: "Filtrar por área profissional", + }); + fireEvent.click(trigger); + const dropdown = screen.getByRole("group", { + name: "Áreas profissionais disponíveis", + }); + expect(trigger).toHaveAttribute("aria-controls", dropdown.id); + fireEvent.click(within(dropdown).getByRole("checkbox", { name: "Backend" })); + fireEvent.click(within(dropdown).getByRole("checkbox", { name: "Frontend" })); + expect(trigger).toHaveTextContent("2 áreas selecionadas"); + expect( + within(dropdown) + .getAllByRole("checkbox") + .filter((checkbox) => (checkbox as HTMLInputElement).checked), + ).toHaveLength(2); + fireEvent.click(screen.getByRole("button", { name: "Remover área Backend" })); + expect( + within(dropdown).getByRole("checkbox", { name: "Backend" }), + ).not.toBeChecked(); + expect( + within(dropdown).getByRole("checkbox", { name: "Frontend" }), + ).toBeChecked(); + fireEvent.click(within(dropdown).getByRole("button", { name: "Limpar" })); + expect( + within(dropdown) + .getAllByRole("checkbox") + .every((checkbox) => !(checkbox as HTMLInputElement).checked), + ).toBe(true); + expect( + screen.queryByRole("button", { name: /Remover área/ }), + ).not.toBeInTheDocument(); + fireEvent.keyDown(dropdown, { key: "Escape" }); + expect( + screen.queryByRole("group", { name: "Áreas profissionais disponíveis" }), + ).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + fireEvent.click(trigger); + fireEvent.mouseDown(document.body); + expect(trigger).toHaveAttribute("aria-expanded", "false"); }); it("renderiza a tabela vazia e paginação local e remota", () => { @@ -299,7 +374,7 @@ describe("new_dashboard job components", () => { expect(screen.getByText(/exibindo 1-10 de 12 vagas/i)).toBeInTheDocument(); expect( - screen.getByRole("columnheader", { name: "Cargo / empresa" }), + screen.getByRole("columnheader", { name: "Cargo / empresa / área" }), ).toBeInTheDocument(); expect( screen.getByRole("columnheader", { name: /publicada em/i }), @@ -351,6 +426,71 @@ describe("new_dashboard job components", () => { expect(onStatusChange).toHaveBeenCalledWith(baseJob.id, "saved"); }); + it("apresenta área, tecnologias e localização sem fabricar dados ausentes", () => { + const job: Job = { ...baseJob, rawPayload: { classification: { primaryFamily: "backend", technologies: ["Go", "PostgreSQL"] } } }; + const view = render( {}} onStatusChange={() => {}} />); + expect(screen.getByText("Backend")).toBeInTheDocument(); + expect(screen.getByText("Go")).toBeInTheDocument(); + expect(screen.getByText("PostgreSQL")).toBeInTheDocument(); + expect(screen.getByText(baseJob.location)).toBeInTheDocument(); + expect(screen.getByText("88%")).toHaveClass("bg-primary/10", "border-primary/25"); + expect(screen.queryByText("América do Sul")).not.toBeInTheDocument(); + view.rerender( {}} onStatusChange={() => {}} />); + expect(screen.queryByLabelText("Tecnologias e palavras-chave")).not.toBeInTheDocument(); + expect(screen.queryByText("Backend")).not.toBeInTheDocument(); + expect(screen.getByText(baseJob.location)).toBeInTheDocument(); + }); + + it("exibe ausência como Não calculado e preserva zero real", () => { + const view = render( {}} onStatusChange={() => {}} />); + expect(screen.getByText("Não calculado")).toBeInTheDocument(); + expect(screen.queryByText("45%")).not.toBeInTheDocument(); + view.rerender( {}} onStatusChange={() => {}} />); + expect(screen.getByText("0%")).toBeInTheDocument(); + }); + + it("ordena Match antes da paginação local e trata valores nulos como zero", () => { + const jobs = Array.from({ length: 12 }, (_, index) => ({ ...baseJob, id: `match-${index}`, jobTitle: `Match ${index}`, matchScore: index === 11 ? 100 : index })); + jobs[0].matchScore = null as unknown as number; + jobs[1].matchScore = undefined as unknown as number; + render( {}} onStatusChange={() => {}} />); + const titles = () => screen.getAllByRole("row").slice(1).map(row => within(row).getAllByRole("cell")[0].querySelector("button")?.textContent); + expect(titles()[0]).toBe("Match 0ACME"); + const trigger = screen.getByRole("button", { name: "Ordenar por match" }); + fireEvent.click(trigger); + expect(screen.getByRole("columnheader", { name: "Match" })).toHaveAttribute("aria-sort", "descending"); + expect(titles()[0]).toBe("Match 11ACME"); + fireEvent.click(trigger); + expect(screen.getByRole("columnheader", { name: "Match" })).toHaveAttribute("aria-sort", "ascending"); + expect(titles().slice(0, 3)).toEqual(["Match 0ACME", "Match 1ACME", "Match 2ACME"]); + }); + + it("delega Match à API sem reordenar a página recebida e mantém o estado na paginação", () => { + const directions: string[] = []; + const onPageChange = vi.fn(); + function RemoteTable() { + const [matchSort, setMatchSort] = useState<"default" | "asc" | "desc">("default"); + return { directions.push(value); setMatchSort(value); }} pagination={{ total: 100, page: 2, limit: 2, totalPages: 50 }} onPageChange={onPageChange} onOpenJob={() => {}} onStatusChange={() => {}} />; + } + render(); + const trigger = screen.getByRole("button", { name: "Ordenar por match" }); + expect(screen.getByRole("columnheader", { name: "Match" })).toHaveAttribute("aria-sort", "none"); + fireEvent.click(trigger); + expect(directions).toEqual(["desc"]); + expect(screen.getAllByRole("row")[1]).toHaveTextContent("Menor"); + fireEvent.click(screen.getByRole("button", { name: "Próxima página" })); + expect(onPageChange).toHaveBeenCalledWith(3); + expect(screen.getByRole("columnheader", { name: "Match" })).toHaveAttribute("aria-sort", "descending"); + fireEvent.click(trigger); + expect(directions).toEqual(["desc", "asc"]); + expect(screen.getByRole("columnheader", { name: "Match" })).toHaveAttribute("aria-sort", "ascending"); + }); + + it("desabilita Match paginado quando não há callback de ordenação global", () => { + render( {}} onStatusChange={() => {}} />); + expect(screen.getByRole("button", { name: "Ordenar por match" })).toBeDisabled(); + }); + it("ordena a tabela por fonte, nível e data de publicação", () => { const jobs: Job[] = [ { @@ -390,9 +530,11 @@ describe("new_dashboard job components", () => { screen .getAllByRole("row") .slice(1) - .map((row) => within(row).getAllByRole("cell")[0].textContent); + .map((row) => within(row).getAllByRole("cell")[0].querySelector("button")?.textContent); fireEvent.click(screen.getByRole("button", { name: /ordenar por fonte/i })); + expect(screen.getByRole("button", { name: /ordenar por fonte/i })).toHaveClass("text-primary"); + expect(screen.getByRole("button", { name: /ordenar por nível/i })).not.toHaveClass("text-primary"); expect(visibleTitles()).toEqual([ "Vaga AdzunaACME", "Vaga GreenhouseACME", @@ -403,6 +545,8 @@ describe("new_dashboard job components", () => { ).toHaveAttribute("aria-sort", "ascending"); fireEvent.click(screen.getByRole("button", { name: /ordenar por nível/i })); + expect(screen.getByRole("button", { name: /ordenar por nível/i })).toHaveClass("text-primary"); + expect(screen.getByRole("button", { name: /ordenar por fonte/i })).not.toHaveClass("text-primary"); expect(visibleTitles()).toEqual([ "Vaga AdzunaACME", "Vaga GreenhouseACME", @@ -642,6 +786,8 @@ describe("new_dashboard job components", () => { useState("Todos"); const [countryFilter, setCountryFilter] = useState("Todos"); + const [stateFilter, setStateFilter] = useState(""); + const [cityFilter, setCityFilter] = useState(""); const [matchSort, setMatchSort] = useState<"default" | "desc" | "asc">( "default", ); @@ -674,6 +820,10 @@ describe("new_dashboard job components", () => { setContinentFilter={setContinentFilter} countryFilter={countryFilter} setCountryFilter={setCountryFilter} + stateFilter={stateFilter} + setStateFilter={setStateFilter} + cityFilter={cityFilter} + setCityFilter={setCityFilter} matchSort={matchSort} setMatchSort={setMatchSort} selectedFamilies={[]} @@ -694,7 +844,7 @@ describe("new_dashboard job components", () => { expect(screen.getByText("Remote React")).toBeInTheDocument(); expect(screen.getByText("Onsite Java")).toBeInTheDocument(); - fireEvent.change(screen.getAllByRole("combobox")[1], { + fireEvent.change(screen.getByRole("combobox", { name: "Modalidade" }), { target: { value: "Remoto" }, }); expect(screen.getByText("Remote React")).toBeInTheDocument(); diff --git a/frontend/tests/unit/new_dashboard/page.test.tsx b/frontend/tests/unit/new_dashboard/page.test.tsx index 0eeb2c85..9ad227bd 100644 --- a/frontend/tests/unit/new_dashboard/page.test.tsx +++ b/frontend/tests/unit/new_dashboard/page.test.tsx @@ -36,6 +36,7 @@ vi.mock("@/features/auth/application/AuthContext", () => ({ vi.mock("@/features/dashboard/components/DashboardTab", () => ({ DashboardTab: (props: any) => ( + {props.jobs.map((job: any) => job.matchScore ?? "Não calculado").join(",")} props.onOpenJob({ id: "job-1" })}>dash-open props.onStatusChange("job-1", "applied")}> dash-status @@ -87,6 +88,7 @@ vi.mock("@/features/jobs/components/JobDetailModal", () => ({ vi.mock("@/features/jobs/components/JobTab", () => ({ JobTab: (props: any) => ( + {props.jobs.map((job: any) => (job.matchScore ?? "Não calculado")).join(",")} props.setSearchQuery("react")}>set-search props.setFilterType("Remoto")}>set-type props.setFilterLevel("Junior")}>set-level @@ -94,7 +96,10 @@ vi.mock("@/features/jobs/components/JobTab", () => ({ set-continent props.setCountryFilter("Brasil")}>set-country - props.setMatchSort("best")}>set-sort + props.setStateFilter(event.target.value)} /> + props.setCityFilter(event.target.value)} /> + props.setMatchSort(props.matchSort === "desc" ? "asc" : "desc")}>set-sort + props.setSelectedFamilies(["backend", "fullstack", "frontend", "devops", "software"])}>set-families props.onSearchJobs()}>jobtab-search props.onPageChange(2)}>jobtab-page props.onPageSizeChange(20)}>jobtab-size @@ -174,11 +179,15 @@ const mockChangeJobStatus = vi ); const mockChangeJobNotesLocally = vi.fn(); const mockSaveJobNotes = vi.fn().mockResolvedValue(undefined); +const mockUseDashboardJobs = vi.fn(); let mockJobs: any = {}; vi.mock("@/features/jobs/hooks/useDashboardJobs", () => ({ - useDashboardJobs: () => mockJobs, + useDashboardJobs: (...args: any[]) => { + mockUseDashboardJobs(...args); + return mockJobs; + }, })); /* -------------------------------------------------------------------------- */ @@ -283,131 +292,20 @@ describe("NewDashboardPage - jobId via URL", () => { }); }); -describe("NewDashboardPage - scoreJobWithTechnologies (via DashboardTab)", () => { - const baseJob = (overrides: any = {}) => ({ - id: "j1", - jobTitle: "Frontend Developer", - company: "ACME", - location: "Brasil", - type: "full-time", - level: "senior", - tags: ["react", "typescript"], - rawPayload: {}, - matchScore: 0, - ...overrides, - }); - - it("mantém score quando rawPayload.matchSource é backend_profile", () => { - defaultJobs({ - trackedJobs: [ - baseJob({ - matchScore: 88, - rawPayload: { matchSource: "backend_profile" }, - }), - ], - }); - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("retorna job inalterado quando não há tecnologias", () => { - defaultJobs({ trackedJobs: [baseJob()] }); - mockUserData.userProfile.technologyExperiences = []; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("ignora tecnologias com nome vazio (branch filter)", () => { - defaultJobs({ trackedJobs: [baseJob()] }); - mockUserData.userProfile.technologyExperiences = [ - { name: " ", years: 3 }, - { name: "React", years: 5 }, - ]; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("atribui score 45 quando nenhuma tecnologia casa", () => { - defaultJobs({ trackedJobs: [baseJob({ tags: [] })] }); - mockUserData.userProfile.technologyExperiences = [ - { name: "cobol", years: 5 }, - ]; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("calcula score com tecnologias casadas e cobre aliases js", () => { - defaultJobs({ - trackedJobs: [ - baseJob({ - jobTitle: "Vaga Node js", - tags: ["Node JS"], - rawPayload: { description: "React and Vue" }, - }), - ], - }); - mockUserData.userProfile.technologyExperiences = [ - { name: "Node js", years: 4 }, - { name: "react", years: 3 }, - ]; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("cobre branch matchAliases com 'js' no final (<- ' js')", () => { - defaultJobs({ - trackedJobs: [baseJob({ jobTitle: "Dev", tags: [] })], - }); - mockUserData.userProfile.technologyExperiences = [ - { name: "nodejs", years: 2 }, - ]; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("cobre textMatchesAlias com aliases contendo espaço", () => { - defaultJobs({ - trackedJobs: [ - baseJob({ - jobTitle: "Vaga de node js sênior", - tags: [], - rawPayload: {}, - }), - ], - }); - mockUserData.userProfile.technologyExperiences = [ - { name: "node js", years: 3 }, - ]; - mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); - }); - - it("cobre rawPayload com arrays e valores não-string", () => { - defaultJobs({ - trackedJobs: [ - baseJob({ - rawPayload: { - lista: ["react", "vue"], - numero: 42, - nulo: null, - texto: "graphql", - }, - }), - ], - }); - mockUserData.userProfile.technologyExperiences = [ - { name: "react", years: 3 }, - ]; +describe("NewDashboardPage - compatibilidade autoritativa", () => { + it.each([83, 0, null])("preserva score salvo %s sem recalcular com outro perfil", (matchScore) => { mockPathname = "/dashboard"; - render(); - expect(screen.getByTestId("dashboard-tab")).toBeInTheDocument(); + defaultJobs({ trackedJobs: [{ id: "salva", jobTitle: "Backend", tags: [], matchScore }] }); + mockUserData.userProfile.technologyExperiences = [{ name: "React", years: 5 }]; + const view = render(); + const expected = matchScore === null ? "Não calculado" : String(matchScore); + expect(screen.getByTestId("tracked-scores")).toHaveTextContent(expected); + mockUserData.userProfile.technologyExperiences = [{ name: "Cobol", years: 1 }]; + view.rerender(); + expect(screen.getByTestId("tracked-scores")).toHaveTextContent(expected); + defaultJobs({ trackedJobs: [{ id: "salva", jobTitle: "Backend", tags: [], matchScore: 66 }] }); + view.rerender(); + expect(screen.getByTestId("tracked-scores")).toHaveTextContent("66"); }); it("não recalcula score em recommendedJobs quando matchScore > 0", () => { @@ -431,7 +329,7 @@ describe("NewDashboardPage - scoreJobWithTechnologies (via DashboardTab)", () => expect(screen.getByTestId("job-tab")).toBeInTheDocument(); }); - it("recalcula score em recommendedJobs quando matchScore = 0", () => { + it("preserva zero real em recommendedJobs", () => { defaultJobs({ recommendedJobs: [ { @@ -863,6 +761,157 @@ describe("NewDashboardPage - useEffect de busca automática - bordas", () => { }); describe("NewDashboardPage - buildRecommendationSearch com todos os filtros", () => { + it("preserva scores e empates recebidos da API sem recalcular pelo perfil local", () => { + mockPathname = "/vagas"; + mockUserData.userProfile.technologyExperiences = [{ name: "React", years: 5 }]; + defaultJobs({ recommendedJobs: [45, 94, 45, 0].map((matchScore, index) => ({ + id: `score-${index}`, jobTitle: "React Developer", company: "ACME", + location: "Brasil", type: "Remoto", level: "Pleno", tags: ["React"], + matchScore, rawPayload: { matchSource: "backend_profile" }, + })) }); + render(); + expect(screen.getByTestId("recommended-scores")).toHaveTextContent("45,94,45,0"); + }); + + it("não inventa score quando falta compatibilidade da API", () => { + mockPathname = "/vagas"; + mockUserData.userProfile.technologyExperiences = [{ name: "Cobol", years: 5 }]; + defaultJobs({ recommendedJobs: [{ + id: "sem-score", jobTitle: "Frontend Developer", company: "ACME", + location: "Brasil", type: "Remoto", level: "Pleno", tags: ["React"], + matchScore: null, rawPayload: {}, + }] }); + render(); + expect(screen.getByTestId("recommended-scores")).toHaveTextContent("Não calculado"); + }); + + it.each(["desc", "asc"])("preserva Match %s na carga inicial com cinco áreas da URL", (matchSort) => { + mockPathname = "/vagas"; + const family = "backend,fullstack,frontend,devops,software"; + mockSearchParamsValue = new URLSearchParams({ family, familyMode: "primary", matchSort }); + render(); + expect(mockUseDashboardJobs.mock.lastCall?.[1].initialRecommendationSearch.filters).toEqual( + expect.objectContaining({ family, familyMode: "primary", matchSort }), + ); + }); + + it("troca áreas sem refazer a carga inicial e preserva Match na paginação", async () => { + vi.useFakeTimers(); + mockPathname = "/vagas"; + mockSearchParamsValue = new URLSearchParams("matchSort=desc"); + render(); + const initial = mockUseDashboardJobs.mock.lastCall?.[1].initialRecommendationSearch; + fireEvent.click(screen.getByText("set-families")); + expect(mockUseDashboardJobs.mock.lastCall?.[1].initialRecommendationSearch).toBe(initial); + await act(async () => { vi.advanceTimersByTime(500); }); + const filters = { family: "backend,fullstack,frontend,devops,software", familyMode: "primary", matchSort: "desc" }; + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith([], filters, 1); + expect((mockSetSearchParams.mock.lastCall?.[0] as URLSearchParams).get("family")).toBe(filters.family); + await act(async () => { fireEvent.click(screen.getByText("jobtab-page")); }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith([], filters, 2, 10); + fireEvent.click(screen.getByText("set-sort")); + await act(async () => { vi.advanceTimersByTime(500); }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith([], { ...filters, matchSort: "asc" }, 1); + }); + + it("alterna Match DESC/ASC preservando filtros, URL e paginação remota", async () => { + vi.useFakeTimers(); + mockPathname = "/vagas"; + render(); + fireEvent.click(screen.getByText("set-search")); + fireEvent.click(screen.getByText("set-country")); + fireEvent.change(screen.getByRole("textbox", { name: "Estado / Região" }), { target: { value: "SC" } }); + fireEvent.change(screen.getByRole("textbox", { name: "Cidade" }), { target: { value: "Joinville" } }); + fireEvent.click(screen.getByText("set-sort")); + await act(async () => { vi.advanceTimersByTime(500); }); + const filters = { country: "Brasil", state: "SC", city: "Joinville", matchSort: "desc" }; + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith(["react"], expect.objectContaining(filters), 1); + expect((mockSetSearchParams.mock.lastCall?.[0] as URLSearchParams).get("matchSort")).toBe("desc"); + await act(async () => { fireEvent.click(screen.getByText("jobtab-page")); }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith(["react"], expect.objectContaining(filters), 2, 10); + fireEvent.click(screen.getByText("set-sort")); + await act(async () => { vi.advanceTimersByTime(500); }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith(["react"], expect.objectContaining({ ...filters, matchSort: "asc" }), 1); + expect((mockSetSearchParams.mock.lastCall?.[0] as URLSearchParams).get("matchSort")).toBe("asc"); + }); + + it("envia Estado e Cidade com debounce, preserva na paginação e remove ao limpar", async () => { + vi.useFakeTimers(); + mockPathname = "/vagas"; + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Estado / Região" }), { + target: { value: " SC " }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Cidade" }), { + target: { value: " Joinville " }, + }); + await act(async () => { + vi.advanceTimersByTime(499); + }); + expect(mockRefreshRecommendations).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(1); + }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ state: "SC", city: "Joinville" }), + 1, + ); + const url = mockSetSearchParams.mock.lastCall?.[0] as URLSearchParams; + expect(url.get("state")).toBe("SC"); + expect(url.get("city")).toBe("Joinville"); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-page")); + }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ state: "SC", city: "Joinville" }), + 2, + 10, + ); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-size")); + }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ state: "SC", city: "Joinville" }), + 1, + 20, + ); + fireEvent.change(screen.getByRole("textbox", { name: "Estado / Região" }), { + target: { value: "" }, + }); + fireEvent.change(screen.getByRole("textbox", { name: "Cidade" }), { + target: { value: "" }, + }); + await act(async () => { + vi.advanceTimersByTime(500); + }); + const filters = mockRefreshRecommendations.mock.lastCall?.[1]; + expect(filters).not.toHaveProperty("state"); + expect(filters).not.toHaveProperty("city"); + }); + + it("restaura os filtros regionais da URL", async () => { + mockPathname = "/vagas"; + mockSearchParamsValue = new URLSearchParams("state=SC&city=Joinville"); + render(); + expect(screen.getByRole("textbox", { name: "Estado / Região" })).toHaveValue( + "SC", + ); + expect(screen.getByRole("textbox", { name: "Cidade" })).toHaveValue( + "Joinville", + ); + await act(async () => { + fireEvent.click(screen.getByText("jobtab-search")); + }); + expect(mockRefreshRecommendations).toHaveBeenLastCalledWith( + [], + expect.objectContaining({ state: "SC", city: "Joinville" }), + 1, + ); + }); + it("aplica todos os filtros na busca", async () => { mockPathname = "/vagas"; render(); @@ -881,9 +930,9 @@ describe("NewDashboardPage - buildRecommendationSearch com todos os filtros", () continent: "América", country: "Brasil", location: "Brasil", - matchSort: "best", + matchSort: "desc", }), 1, ); }); -}); \ No newline at end of file +}); diff --git a/frontend/tests/unit/new_dashboard/savedJobMatch.test.tsx b/frontend/tests/unit/new_dashboard/savedJobMatch.test.tsx new file mode 100644 index 00000000..93274827 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/savedJobMatch.test.tsx @@ -0,0 +1,70 @@ +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDashboardJobs } from "@/features/jobs/hooks/useDashboardJobs"; +import { KanbanBoard } from "@/features/applications/components/KanbanBoard"; + +const apiMock = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })); +vi.mock("@/shared/api/apiClient", () => ({ api: apiMock })); + +const user = { id: "match-user", email: "candidate@example.com" }; + +describe("compatibilidade entre busca, salvamento e recarga", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + it.each([83, 0, null])("não fabrica compatibilidade ao salvar score %s sem suporte no contrato", async (matchScore) => { + const catalogJob = { + title: "Backend Engineer", company: "ACME", location: "Remoto", + url: "https://example.com/backend", keyword: "TypeScript", + matchScore, matchSource: "backend_profile", matchedTechnologies: ["TypeScript"], + }; + const savedJob = { + id: "saved-uuid", jobLink: catalogJob.url, jobTitle: catalogJob.title, + company: "ACME", location: "Remoto", keyword: "TypeScript", status: "saved", + }; + let saved = false; + apiMock.get.mockImplementation(async (path: string) => ({ + data: path === "/saved-jobs" ? (saved ? [savedJob] : []) : { jobs: [catalogJob] }, + })); + apiMock.post.mockImplementation(async () => { + saved = true; + return { data: savedJob }; + }); + + const first = renderHook(() => useDashboardJobs(user)); + await waitFor(() => expect(first.result.current.isLoadingJobs).toBe(false)); + const recommended = first.result.current.recommendedJobs[0]; + expect(recommended.matchScore).toBe(matchScore); + expect(recommended.rawPayload?.matchSource).toBe("backend_profile"); + await act(async () => { await first.result.current.changeJobStatus(recommended.id, "saved"); }); + expect(apiMock.post).toHaveBeenCalledWith("/saved-jobs", expect.objectContaining({ + jobLink: catalogJob.url, status: "saved", keyword: "TypeScript", + })); + expect(apiMock.post.mock.lastCall?.[1]).not.toHaveProperty("matchScore"); + expect(first.result.current.trackedJobs[0]).toMatchObject({ id: savedJob.id, matchScore: null }); + expect(first.result.current.recommendedJobs).toEqual([]); + expect(catalogJob.matchScore).toBe(matchScore); + const board = render(); + expect(screen.getByText("Não calculado")).toBeInTheDocument(); + expect(screen.queryByText("45% Match")).not.toBeInTheDocument(); + first.unmount(); + + const reloaded = renderHook(() => useDashboardJobs(user)); + await waitFor(() => expect(reloaded.result.current.isLoadingJobs).toBe(false)); + expect(reloaded.result.current.trackedJobs[0].matchScore).toBeNull(); + board.rerender(); + expect(screen.getByText("Não calculado")).toBeInTheDocument(); + }); + + it.each([83, 0, null])("o Kanban exibe fielmente o score disponível %s", (matchScore) => { + render(); + expect(screen.getByText(matchScore === null ? "Não calculado" : `${matchScore}% Match`)).toBeInTheDocument(); + }); +});
+
Sua conta de nível{" "} {userProfile.level} monitora atualmente {jobsCount}{" "} diff --git a/frontend/src/features/jobs/api/dashboardJobsApi.ts b/frontend/src/features/jobs/api/dashboardJobsApi.ts index b14b306c..12eb204f 100644 --- a/frontend/src/features/jobs/api/dashboardJobsApi.ts +++ b/frontend/src/features/jobs/api/dashboardJobsApi.ts @@ -252,14 +252,6 @@ function inferLevel(title: string): JobLevel { return "Pleno"; } -function stableMatchScore(value: string) { - const hash = [...value].reduce( - (total, character) => total + character.charCodeAt(0), - 0, - ); - return 70 + (hash % 26); -} - function formatPublicationDate(value: string | null | undefined) { const rawDate = value?.trim(); if (!rawDate) return "Não informado"; @@ -329,7 +321,7 @@ export function toRecommendedJob(job: ApiSearchJob, index: number): Job { ), level: inferLevel(title), // Sem perfil o backend nao calcula match; nao inventamos um score aqui. - matchScore: typeof job.matchScore === "number" ? job.matchScore : 0, + matchScore: typeof job.matchScore === "number" ? job.matchScore : null, tags: tags.length > 0 ? tags : ["Geral"], posted: formatPublicationDate(job.postedAt), status: "saved", @@ -353,7 +345,8 @@ export function toDashboardSavedJob(job: ApiSavedJob): Job { salary: "A combinar", type: inferTypeFromText([title, location].join(" ")), level: inferLevel(title), - matchScore: stableMatchScore(`${title}:${job.company ?? ""}`), + // O contrato de vagas salvas ainda não retorna compatibilidade. + matchScore: null, tags: job.keyword?.trim() ? [job.keyword.trim()] : ["Geral"], posted: "Não informado", status: job.status, diff --git a/frontend/src/features/jobs/components/FormattedJobDescription.tsx b/frontend/src/features/jobs/components/FormattedJobDescription.tsx index ba39af96..357c85fd 100644 --- a/frontend/src/features/jobs/components/FormattedJobDescription.tsx +++ b/frontend/src/features/jobs/components/FormattedJobDescription.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode } from "react"; +import { normalizeDescriptionText } from "../utils/jobDetailPresentation"; interface FormattedJobDescriptionProps { description: string; @@ -54,7 +55,7 @@ function safeExternalUrl(value: string | null) { function renderNode(node: Node, key: string): ReactNode { if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; + return node.parentElement?.closest("pre, code") ? node.textContent : normalizeDescriptionText(node.textContent ?? ""); } if (node.nodeType !== Node.ELEMENT_NODE) { @@ -134,6 +135,9 @@ export function FormattedJobDescription({ "text/html", ); const hasMarkup = /<\/?[a-z][\s\S]*>/i.test(decodedDescription); + document.querySelectorAll("br + br + br").forEach((element) => { + if (!element.closest("pre, code")) element.remove(); + }); return (
- {document.body.textContent ?? decodedDescription} + {normalizeDescriptionText(document.body.textContent ?? decodedDescription).trim()}
{value}
{text}
Encontramos oportunidades ideais com base nas preferências do seu perfil.
About us & benefits
Salary: $5000
npm test