From 33bbb51425fc2afa4809f838656b3b63e1bd9d6a Mon Sep 17 00:00:00 2001 From: hltav Date: Tue, 21 Jul 2026 11:35:46 -0300 Subject: [PATCH] =?UTF-8?q?feat(PAV-88):=20implementa=20prefer=C3=AAncia?= =?UTF-8?q?=20inicial=20de=20modalidade=20de=20vagas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/bruno/Users/Patch Preferences.bru | 2 +- backend/bruno/Users/Post Preferences.bru | 2 +- .../src/modules/users/schemas/user.schemas.ts | 4 +- .../new_dashboard/NewDashboardPage.tsx | 38 ++++++++++-- .../components/jobs/JobFilter.tsx | 23 ++++--- .../new_dashboard/components/jobs/JobTab.tsx | 29 +++++++-- .../components/profile/PreferencesForm.tsx | 53 ++++++++++------ .../new_dashboard/constants/initialData.ts | 1 + .../infrastructure/userDashboardApi.ts | 31 +++++++++- .../src/domains/new_dashboard/types/job.ts | 6 ++ .../src/domains/new_dashboard/types/user.ts | 2 + .../new_dashboard/utils/jobModelFilters.ts | 60 +++++++++++++++++++ frontend/tests/unit/new_dashboard/api.test.ts | 4 ++ .../unit/new_dashboard/home.profile.test.tsx | 5 +- .../tests/unit/new_dashboard/page.test.tsx | 60 ++++++++++++++++++- 15 files changed, 275 insertions(+), 45 deletions(-) create mode 100644 frontend/src/domains/new_dashboard/utils/jobModelFilters.ts diff --git a/backend/bruno/Users/Patch Preferences.bru b/backend/bruno/Users/Patch Preferences.bru index f100dc9..ab3018b 100644 --- a/backend/bruno/Users/Patch Preferences.bru +++ b/backend/bruno/Users/Patch Preferences.bru @@ -20,7 +20,7 @@ body:json { "searchLocation": "São Paulo, Brasil", "searchLanguage": "pt", "remoteOnly": true, - "jobTypes": ["full-time", "contract"], + "jobTypes": ["Remoto", "Híbrido"], "emailNotifications": false } } diff --git a/backend/bruno/Users/Post Preferences.bru b/backend/bruno/Users/Post Preferences.bru index 86a24dd..70bfe61 100644 --- a/backend/bruno/Users/Post Preferences.bru +++ b/backend/bruno/Users/Post Preferences.bru @@ -20,7 +20,7 @@ body:json { "searchLocation": "São Paulo, Brasil", "searchLanguage": "pt", "remoteOnly": true, - "jobTypes": ["full-time", "contract"], + "jobTypes": ["Remoto", "Híbrido"], "emailNotifications": false } } diff --git a/backend/src/modules/users/schemas/user.schemas.ts b/backend/src/modules/users/schemas/user.schemas.ts index b680144..105c714 100644 --- a/backend/src/modules/users/schemas/user.schemas.ts +++ b/backend/src/modules/users/schemas/user.schemas.ts @@ -38,13 +38,15 @@ export const updateProfileSchema = z // ── Preferences ─────────────────────────────────────────────────────────────── +const jobTypePreferenceSchema = z.enum(["Remoto", "Híbrido", "Presencial"]); + export const updatePreferencesSchema = z .object({ keywords: z.array(z.string().min(1)).max(20), searchLocation: z.string().min(1).max(100).nullable(), searchLanguage: z.string().length(2).nullable(), remoteOnly: z.boolean(), - jobTypes: z.array(z.string().min(1)).max(10), + jobTypes: z.array(jobTypePreferenceSchema).max(3), emailNotifications: z.boolean(), careerChecklist: z .array( diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx index 76a3664..3eb44d8 100644 --- a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -18,6 +18,7 @@ import { useUserDashboardData } from "./hooks/useUserDashboardData"; import type { CareerChecklist, Job, + JobModelFilter, JobStatus, MatchSort, NewJob, @@ -29,6 +30,11 @@ import { type ContinentFilter, type CountryFilter, } from "./utils/locationFilters"; +import { + getModelFilterFromJobTypes, + modelFilterMatchesJob, + modelFilterToApiFilter, +} from "./utils/jobModelFilters"; import { parseSearchKeywords } from "./utils/searchKeywords"; function getSection(pathname: string) { @@ -149,7 +155,7 @@ export default function NewDashboardPage() { const navigate = useNavigate(); const [searchQuery, setSearchQuery] = useState(""); - const [filterType, setFilterType] = useState("Todos"); + const [filterType, setFilterType] = useState("Todos"); const [filterLevel, setFilterLevel] = useState("Todos"); const [continentFilter, setContinentFilter] = useState("Todos"); @@ -159,12 +165,14 @@ export default function NewDashboardPage() { const [isAddJobOpen, setIsAddJobOpen] = useState(false); const [toast, setToast] = useState(""); const checklistSaveTimeout = useRef(null); + const hasUserSelectedModelFilter = useRef(false); const showToast = useCallback((message: string) => setToast(message), []); const { userProfile, setUserProfile, searchPreferences, setSearchPreferences, + isLoadingUserData, isSavingProfile, isSavingPreferences, saveUserProfile, @@ -196,6 +204,13 @@ export default function NewDashboardPage() { ), [recommendedJobs, userProfile.technologyExperiences], ); + const displayedRecommendedJobs = useMemo( + () => + matchedRecommendedJobs.filter((job) => + modelFilterMatchesJob(job, filterType), + ), + [filterType, matchedRecommendedJobs], + ); const selectedJob = [...matchedTrackedJobs, ...matchedRecommendedJobs].find( (job) => job.id === selectedJobId, @@ -230,12 +245,19 @@ export default function NewDashboardPage() { const handleSavePreferences = async (preferences: SearchPreferences) => { try { await saveSearchPreferences(preferences); + hasUserSelectedModelFilter.current = false; + setFilterType(getModelFilterFromJobTypes(preferences.jobTypes)); showToast("Preferências de busca atualizadas."); } catch { // O hook já notificou a falha preservando as preferências editadas. } }; + const handleFilterTypeChange = useCallback((value: JobModelFilter) => { + hasUserSelectedModelFilter.current = true; + setFilterType(value); + }, []); + const handleCareerChecklistChange = useCallback( (careerChecklist: CareerChecklist[]) => { const nextPreferences = { @@ -313,9 +335,7 @@ export default function NewDashboardPage() { const typedKeywords = parseSearchKeywords(searchQuery); const filters = { ...(filterLevel !== "Todos" ? { level: filterLevel } : {}), - ...(filterType !== "Todos" - ? { type: filterType, model: filterType } - : {}), + ...modelFilterToApiFilter(filterType), ...(continentFilter !== "Todos" ? { continent: continentFilter } : {}), ...(countryFilter !== "Todos" ? { country: countryFilter, location: countryFilter } @@ -347,6 +367,12 @@ export default function NewDashboardPage() { searchQuery, ]); + useEffect(() => { + if (isLoadingUserData || hasUserSelectedModelFilter.current) return; + + setFilterType(getModelFilterFromJobTypes(searchPreferences.jobTypes)); + }, [isLoadingUserData, searchPreferences.jobTypes]); + // Busca automática: dispara ao digitar (com debounce) ou ao trocar // qualquer filtro, sem precisar clicar em "Buscar vagas". const isFirstSearchRun = useRef(true); @@ -398,11 +424,11 @@ export default function NewDashboardPage() { case "vagas": return ( void; - filterType: string; - setFilterType: (value: string) => void; + filterType: JobModelFilter; + setFilterType: (value: JobModelFilter) => void; filterLevel: string; setFilterLevel: (value: string) => void; continentFilter: ContinentFilter; @@ -51,13 +52,19 @@ export function JobFilter({ +
+ diff --git a/frontend/src/domains/new_dashboard/constants/initialData.ts b/frontend/src/domains/new_dashboard/constants/initialData.ts index 010e324..a200cb2 100644 --- a/frontend/src/domains/new_dashboard/constants/initialData.ts +++ b/frontend/src/domains/new_dashboard/constants/initialData.ts @@ -210,6 +210,7 @@ export const initialPreferences: SearchPreferences = { keywords: ["React", "Frontend", "Fullstack"], searchLocation: "São Paulo, SP", remoteOnly: true, + jobTypes: ["Remoto"], emailNotifications: true, careerChecklist: [], }; diff --git a/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts b/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts index e4d3061..8bef0e4 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { api } from "@/shared/lib/apiClient"; -import type { SearchPreferences, UserProfile } from "../types"; +import type { JobType, SearchPreferences, UserProfile } from "../types"; import { initialPreferences, initialUser } from "../constants"; const ApiUserProfileSchema = z.object({ @@ -27,7 +27,10 @@ const ApiSearchPreferencesSchema = z.object({ searchLocation: z.string().nullable().optional(), searchLanguage: z.string().nullable().optional(), remoteOnly: z.boolean().nullable().optional(), - jobTypes: z.array(z.string()).nullable().optional(), + jobTypes: z + .array(z.enum(["Remoto", "Híbrido", "Presencial"])) + .nullable() + .optional(), emailNotifications: z.boolean().nullable().optional(), careerChecklist: z .array( @@ -51,6 +54,20 @@ const ApiSearchPreferencesSchema = z.object({ type ApiUserProfile = z.infer; type ApiSearchPreferences = z.infer; +function normalizePreferenceJobTypes( + jobTypes: JobType[] | null | undefined, + remoteOnly: boolean | null | undefined, +) { + if (jobTypes && jobTypes.length > 0) { + return [...new Set(jobTypes)]; + } + + if (remoteOnly === true) return ["Remoto"]; + if (remoteOnly === false) return []; + + return initialPreferences.jobTypes; +} + function fallbackNameParts(displayName: string) { const [firstName = initialUser.firstName, ...rest] = displayName.split(" "); @@ -115,6 +132,10 @@ export function toUserProfile(data: unknown): UserProfile { export function toSearchPreferences(data: unknown): SearchPreferences { const preferences = ApiSearchPreferencesSchema.parse(data); + const jobTypes = normalizePreferenceJobTypes( + preferences.jobTypes, + preferences.remoteOnly, + ); return { keywords: @@ -123,7 +144,10 @@ export function toSearchPreferences(data: unknown): SearchPreferences { : initialPreferences.keywords, searchLocation: preferences.searchLocation?.trim() || initialPreferences.searchLocation, - remoteOnly: preferences.remoteOnly ?? initialPreferences.remoteOnly, + remoteOnly: + preferences.remoteOnly ?? + (jobTypes.length === 1 && jobTypes[0] === "Remoto"), + jobTypes, emailNotifications: preferences.emailNotifications ?? initialPreferences.emailNotifications, careerChecklist: @@ -150,6 +174,7 @@ function toPreferencesPayload(preferences: SearchPreferences) { keywords: preferences.keywords, searchLocation: preferences.searchLocation || null, remoteOnly: preferences.remoteOnly, + jobTypes: preferences.jobTypes, emailNotifications: preferences.emailNotifications, careerChecklist: preferences.careerChecklist, }; diff --git a/frontend/src/domains/new_dashboard/types/job.ts b/frontend/src/domains/new_dashboard/types/job.ts index 9a5ccb0..3ca5e4b 100644 --- a/frontend/src/domains/new_dashboard/types/job.ts +++ b/frontend/src/domains/new_dashboard/types/job.ts @@ -53,3 +53,9 @@ export type JobLevel = z.infer; export type Job = z.infer; export type NewJob = z.infer; export type MatchSort = "default" | "desc" | "asc"; +export type JobModelFilter = + | "Todos" + | JobType + | "RemotoHibrido" + | "RemotoPresencial" + | "HibridoPresencial"; diff --git a/frontend/src/domains/new_dashboard/types/user.ts b/frontend/src/domains/new_dashboard/types/user.ts index acea92b..538c795 100644 --- a/frontend/src/domains/new_dashboard/types/user.ts +++ b/frontend/src/domains/new_dashboard/types/user.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { JobTypeSchema } from "./job"; export const TechnologyExperienceSchema = z.object({ name: z.string().min(1), @@ -35,6 +36,7 @@ export const SearchPreferencesSchema = z.object({ keywords: z.array(z.string()), searchLocation: z.string().min(1), remoteOnly: z.boolean(), + jobTypes: z.array(JobTypeSchema), emailNotifications: z.boolean(), careerChecklist: z.array(CareerChecklistSchema), }); diff --git a/frontend/src/domains/new_dashboard/utils/jobModelFilters.ts b/frontend/src/domains/new_dashboard/utils/jobModelFilters.ts new file mode 100644 index 0000000..cc96334 --- /dev/null +++ b/frontend/src/domains/new_dashboard/utils/jobModelFilters.ts @@ -0,0 +1,60 @@ +import type { Job, JobModelFilter, JobType } from "../types"; + +export const jobModelFilterOptions: Array<{ + value: JobModelFilter; + label: string; + types: JobType[]; +}> = [ + { value: "Todos", label: "Todas as vagas", types: [] }, + { value: "Remoto", label: "Somente remotas", types: ["Remoto"] }, + { value: "Híbrido", label: "Somente híbridas", types: ["Híbrido"] }, + { value: "Presencial", label: "Somente presenciais", types: ["Presencial"] }, + { + value: "RemotoHibrido", + label: "Remotas e híbridas", + types: ["Remoto", "Híbrido"], + }, + { + value: "RemotoPresencial", + label: "Remotas e presenciais", + types: ["Remoto", "Presencial"], + }, + { + value: "HibridoPresencial", + label: "Híbridas e presenciais", + types: ["Híbrido", "Presencial"], + }, +]; + +export function getJobTypesFromModelFilter(filterType: JobModelFilter) { + return ( + jobModelFilterOptions.find((option) => option.value === filterType) + ?.types ?? [] + ); +} + +export function getModelFilterFromJobTypes(jobTypes: JobType[]) { + const normalized = [...new Set(jobTypes)].sort().join("|"); + + return ( + jobModelFilterOptions.find( + (option) => [...option.types].sort().join("|") === normalized, + )?.value ?? "Todos" + ); +} + +export function modelFilterMatchesJob(job: Job, filterType: JobModelFilter) { + const jobTypes = getJobTypesFromModelFilter(filterType); + return jobTypes.length === 0 || jobTypes.includes(job.type); +} + +export function modelFilterToApiFilter(filterType: JobModelFilter) { + const jobTypes = getJobTypesFromModelFilter(filterType); + if (jobTypes.length !== 1) return {}; + + const [type] = jobTypes; + return { + type, + model: type, + }; +} diff --git a/frontend/tests/unit/new_dashboard/api.test.ts b/frontend/tests/unit/new_dashboard/api.test.ts index e80afc8..94c36c1 100644 --- a/frontend/tests/unit/new_dashboard/api.test.ts +++ b/frontend/tests/unit/new_dashboard/api.test.ts @@ -420,6 +420,7 @@ describe("new_dashboard api adapters", () => { keywords: ["React"], searchLocation: "Lisboa", remoteOnly: true, + jobTypes: ["Remoto"], emailNotifications: false, careerChecklist: [], }, @@ -473,6 +474,7 @@ describe("new_dashboard api adapters", () => { keywords: ["React"], searchLocation: "Brasil", remoteOnly: true, + jobTypes: ["Remoto"], emailNotifications: true, careerChecklist: [], }, @@ -494,6 +496,7 @@ describe("new_dashboard api adapters", () => { keywords: ["React"], searchLocation: "Brasil", remoteOnly: true, + jobTypes: ["Remoto"], emailNotifications: true, careerChecklist: [], }); @@ -509,6 +512,7 @@ describe("new_dashboard api adapters", () => { "/users/preferences", expect.objectContaining({ searchLocation: "Brasil", + jobTypes: ["Remoto"], careerChecklist: [], }), ); diff --git a/frontend/tests/unit/new_dashboard/home.profile.test.tsx b/frontend/tests/unit/new_dashboard/home.profile.test.tsx index 3f2451c..93df62b 100644 --- a/frontend/tests/unit/new_dashboard/home.profile.test.tsx +++ b/frontend/tests/unit/new_dashboard/home.profile.test.tsx @@ -223,7 +223,9 @@ describe("new_dashboard home and profile components", () => { fireEvent.change(screen.getByLabelText(/localidade principal de busca/i), { target: { value: "Lisboa, Portugal" }, }); - fireEvent.click(screen.getByLabelText(/apenas oportunidades remotas/i)); + fireEvent.change(screen.getByLabelText(/vagas exibidas inicialmente/i), { + target: { value: "RemotoHibrido" }, + }); fireEvent.click( screen.getByLabelText(/habilitar alertas de vagas no e-mail/i), ); @@ -235,6 +237,7 @@ describe("new_dashboard home and profile components", () => { expect.objectContaining({ searchLocation: "Lisboa, Portugal", remoteOnly: false, + jobTypes: ["Remoto", "Híbrido"], emailNotifications: false, }), ); diff --git a/frontend/tests/unit/new_dashboard/page.test.tsx b/frontend/tests/unit/new_dashboard/page.test.tsx index f37f289..42ef32a 100644 --- a/frontend/tests/unit/new_dashboard/page.test.tsx +++ b/frontend/tests/unit/new_dashboard/page.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it, vi } from "vitest"; import NewDashboardPage from "@/domains/new_dashboard/NewDashboardPage"; @@ -141,6 +141,7 @@ describe("NewDashboardPage", () => { keywords: ["React"], searchLocation: "Brasil", remoteOnly: false, + jobTypes: [], emailNotifications: true, careerChecklist: [], }, @@ -212,6 +213,63 @@ describe("NewDashboardPage", () => { expect(screen.getByRole("button", { name: /buscar vagas/i })).toBeInTheDocument(); }); + it("usa apenas vagas remotas como padrão quando a preferência está ativa", async () => { + mockUseUserDashboardData.mockReturnValue({ + ...mockUseUserDashboardData(), + searchPreferences: { + keywords: ["React"], + searchLocation: "Brasil", + remoteOnly: true, + jobTypes: ["Remoto"], + emailNotifications: true, + careerChecklist: [], + }, + }); + dashboardJobsState.recommendedJobs = [ + { + id: "remote-job", + jobTitle: "Remote Node", + company: "Globex", + location: "Global", + salary: "A combinar", + type: "Remoto", + level: "Sênior", + matchScore: 91, + tags: ["Node.js"], + posted: "Hoje", + status: "saved", + jobLink: "https://example.com/remote", + source: "Gupy", + notes: "", + }, + { + id: "onsite-job", + jobTitle: "Onsite Node", + company: "ACME", + location: "São Paulo", + salary: "A combinar", + type: "Presencial", + level: "Sênior", + matchScore: 88, + tags: ["Node.js"], + posted: "Hoje", + status: "saved", + jobLink: "https://example.com/onsite", + source: "LinkedIn", + notes: "", + }, + ]; + + renderPage("/vagas"); + + await waitFor(() => { + expect(screen.getByDisplayValue("Somente remotas")).toBeInTheDocument(); + }); + + expect(screen.getByText("Remote Node")).toBeInTheDocument(); + expect(screen.queryByText("Onsite Node")).not.toBeInTheDocument(); + }); + it("aciona a busca e paginação de vagas", () => { renderPage("/vagas");