From a17e658812b457758dbd6bbb573ea435eae29cf6 Mon Sep 17 00:00:00 2001 From: hltav Date: Mon, 2 Nov 2026 18:39:56 -0300 Subject: [PATCH 1/3] feat(frontend): atualiza paletas de cores e destaques visuais --- .../src/app/dashboard/components/Sidebar.tsx | 4 +- frontend/src/app/dashboard/theme/colors.ts | 32 +++++---- .../home/components/WelcomeBanner.tsx | 4 +- frontend/src/styles/globals.css | 66 ++++++++++++------- 4 files changed, 65 insertions(+), 41 deletions(-) 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/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/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; } From 8e33251fcd608a08ef85e0672b96c67d87015cea Mon Sep 17 00:00:00 2001 From: hltav Date: Mon, 2 Nov 2026 22:25:53 -0300 Subject: [PATCH 2/3] =?UTF-8?q?feat(frontend):=20moderniza=20busca,=20filt?= =?UTF-8?q?ros=20e=20apresenta=C3=A7=C3=A3o=20das=20vagas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/app/dashboard/DashboardPage.tsx | 48 +- .../components/FormattedJobDescription.tsx | 8 +- .../jobs/components/JobDetailModal.tsx | 41 +- .../features/jobs/components/JobFilter.tsx | 468 ++++++++++++------ .../features/jobs/components/JobLocation.tsx | 72 +++ .../src/features/jobs/components/JobRow.tsx | 167 +++++-- .../src/features/jobs/components/JobTab.tsx | 28 +- .../src/features/jobs/components/JobTable.tsx | 151 ++++-- .../jobs/components/TechnologyTags.tsx | 147 ++++++ .../features/jobs/utils/formatJobLocation.ts | 251 ++++++++++ .../jobs/utils/jobDetailPresentation.ts | 72 +++ .../new_dashboard/TechnologyTags.test.tsx | 100 ++++ frontend/tests/unit/new_dashboard/api.test.ts | 15 + .../new_dashboard/formatJobLocation.test.tsx | 156 ++++++ .../new_dashboard/jobDetailModal.test.tsx | 21 +- .../jobDetailPresentation.test.tsx | 61 +++ .../tests/unit/new_dashboard/jobs.test.tsx | 196 +++++++- .../tests/unit/new_dashboard/page.test.tsx | 106 +++- 18 files changed, 1829 insertions(+), 279 deletions(-) create mode 100644 frontend/src/features/jobs/components/JobLocation.tsx create mode 100644 frontend/src/features/jobs/components/TechnologyTags.tsx create mode 100644 frontend/src/features/jobs/utils/formatJobLocation.ts create mode 100644 frontend/src/features/jobs/utils/jobDetailPresentation.ts create mode 100644 frontend/tests/unit/new_dashboard/TechnologyTags.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/formatJobLocation.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/jobDetailPresentation.test.tsx diff --git a/frontend/src/app/dashboard/DashboardPage.tsx b/frontend/src/app/dashboard/DashboardPage.tsx index 1e2fc942..c1f68566 100644 --- a/frontend/src/app/dashboard/DashboardPage.tsx +++ b/frontend/src/app/dashboard/DashboardPage.tsx @@ -165,6 +165,17 @@ 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") || "", + ); + // Preserva a carga inicial da URL sem recarregar o hook a cada tecla. + const [initialRegionalFilters] = useState(() => ({ + state: stateFilter.trim(), + city: cityFilter.trim(), + })); const [familyOptions, setFamilyOptions] = useState( FALLBACK_JOB_FAMILIES, ); @@ -228,6 +239,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 +254,8 @@ export default function NewDashboardPage() { countryFilter, matchSort, searchParams, + stateFilter, + cityFilter, setSearchParams, ]); @@ -281,6 +298,12 @@ export default function NewDashboardPage() { keywords: [], filters: { ...modelFilterToApiFilter(preferredModelFilter), + ...(initialRegionalFilters.state + ? { state: initialRegionalFilters.state } + : {}), + ...(initialRegionalFilters.city + ? { city: initialRegionalFilters.city } + : {}), ...(selectedFamilies.length > 0 ? { family: selectedFamilies.join(","), @@ -289,7 +312,12 @@ export default function NewDashboardPage() { : {}), }, }, - [isLoadingUserData, preferredModelFilter, selectedFamilies], + [ + isLoadingUserData, + preferredModelFilter, + selectedFamilies, + initialRegionalFilters, + ], ); const { trackedJobs, @@ -407,6 +435,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 +533,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 +561,8 @@ export default function NewDashboardPage() { countryFilter, continentFilter, filterLevel, + stateFilter, + cityFilter, filterType, matchSort, selectedFamilies, @@ -563,6 +603,8 @@ export default function NewDashboardPage() { matchSort, selectedFamilies, section, + stateFilter, + cityFilter, ]); const handleRecommendationPageSizeChange = async (limit: number) => { @@ -600,6 +642,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/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..263fde7d 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,13 +188,13 @@ export function JobDetailModal({
- + } /> @@ -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..299d26c6 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,52 +34,134 @@ 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 ? `Tecnologias em comum: ${matched.join(", ")}` @@ -87,22 +171,27 @@ export function JobRow({ job, onOpen, onStatusChange }: JobRowProps) { {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