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 ( -
- - -
- + - {isFamilyOpen && ( -
-
- - Áreas profissionais - - {activeSelected.length > 0 && ( - +
{ + if (event.key === "Escape") { + setIsFamilyOpen(false); + familyDropdownRef.current?.querySelector("button")?.focus(); + } + }} + > +
-
- {activeOptions.map((family) => { - const isSelected = activeSelected.includes(family.id); - return ( -
+ )}
- )} -
- + - + +
- +
+ - + - -
+ + +
+ +
+ + + {activeSelected.length > 0 + ? activeSelected.map((id) => ( + + + {activeOptions.find((family) => family.id === id)?.label ?? + id} + + {setSelectedFamilies && ( + + )} + + )) + : 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 ( +
+
+ ); +} 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 ( - - - + + +
+
+ + {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}%`} - - - + +
+ + +
); 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" > - +
@@ -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