Skip to content

Commit e0ead59

Browse files
authored
feat(PAV-88): implementa preferência inicial de modalidade de vagas (#187)
- Substitui o checkbox de vagas remotas por um select de modalidade inicial no perfil. - Persiste combinações de modalidade em jobTypes. - Aplica a preferência automaticamente como filtro inicial na tela de vagas. - Mantém compatibilidade com remoteOnly para dados antigos.
2 parents 0d06d1c + 4c2a9e7 commit e0ead59

15 files changed

Lines changed: 275 additions & 45 deletions

File tree

backend/bruno/Users/Patch Preferences.bru

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ body:json {
2020
"searchLocation": "São Paulo, Brasil",
2121
"searchLanguage": "pt",
2222
"remoteOnly": true,
23-
"jobTypes": ["full-time", "contract"],
23+
"jobTypes": ["Remoto", "Híbrido"],
2424
"emailNotifications": false
2525
}
2626
}

backend/bruno/Users/Post Preferences.bru

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ body:json {
2020
"searchLocation": "São Paulo, Brasil",
2121
"searchLanguage": "pt",
2222
"remoteOnly": true,
23-
"jobTypes": ["full-time", "contract"],
23+
"jobTypes": ["Remoto", "Híbrido"],
2424
"emailNotifications": false
2525
}
2626
}

backend/src/modules/users/schemas/user.schemas.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ export const updateProfileSchema = z
3838

3939
// ── Preferences ───────────────────────────────────────────────────────────────
4040

41+
const jobTypePreferenceSchema = z.enum(["Remoto", "Híbrido", "Presencial"]);
42+
4143
export const updatePreferencesSchema = z
4244
.object({
4345
keywords: z.array(z.string().min(1)).max(20),
4446
searchLocation: z.string().min(1).max(100).nullable(),
4547
searchLanguage: z.string().length(2).nullable(),
4648
remoteOnly: z.boolean(),
47-
jobTypes: z.array(z.string().min(1)).max(10),
49+
jobTypes: z.array(jobTypePreferenceSchema).max(3),
4850
emailNotifications: z.boolean(),
4951
careerChecklist: z
5052
.array(

frontend/src/domains/new_dashboard/NewDashboardPage.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useUserDashboardData } from "./hooks/useUserDashboardData";
1818
import type {
1919
CareerChecklist,
2020
Job,
21+
JobModelFilter,
2122
JobStatus,
2223
MatchSort,
2324
NewJob,
@@ -29,6 +30,11 @@ import {
2930
type ContinentFilter,
3031
type CountryFilter,
3132
} from "./utils/locationFilters";
33+
import {
34+
getModelFilterFromJobTypes,
35+
modelFilterMatchesJob,
36+
modelFilterToApiFilter,
37+
} from "./utils/jobModelFilters";
3238
import { parseSearchKeywords } from "./utils/searchKeywords";
3339

3440
function getSection(pathname: string) {
@@ -149,7 +155,7 @@ export default function NewDashboardPage() {
149155
const navigate = useNavigate();
150156

151157
const [searchQuery, setSearchQuery] = useState("");
152-
const [filterType, setFilterType] = useState("Todos");
158+
const [filterType, setFilterType] = useState<JobModelFilter>("Todos");
153159
const [filterLevel, setFilterLevel] = useState("Todos");
154160
const [continentFilter, setContinentFilter] =
155161
useState<ContinentFilter>("Todos");
@@ -159,12 +165,14 @@ export default function NewDashboardPage() {
159165
const [isAddJobOpen, setIsAddJobOpen] = useState(false);
160166
const [toast, setToast] = useState("");
161167
const checklistSaveTimeout = useRef<number | null>(null);
168+
const hasUserSelectedModelFilter = useRef(false);
162169
const showToast = useCallback((message: string) => setToast(message), []);
163170
const {
164171
userProfile,
165172
setUserProfile,
166173
searchPreferences,
167174
setSearchPreferences,
175+
isLoadingUserData,
168176
isSavingProfile,
169177
isSavingPreferences,
170178
saveUserProfile,
@@ -196,6 +204,13 @@ export default function NewDashboardPage() {
196204
),
197205
[recommendedJobs, userProfile.technologyExperiences],
198206
);
207+
const displayedRecommendedJobs = useMemo(
208+
() =>
209+
matchedRecommendedJobs.filter((job) =>
210+
modelFilterMatchesJob(job, filterType),
211+
),
212+
[filterType, matchedRecommendedJobs],
213+
);
199214
const selectedJob =
200215
[...matchedTrackedJobs, ...matchedRecommendedJobs].find(
201216
(job) => job.id === selectedJobId,
@@ -230,12 +245,19 @@ export default function NewDashboardPage() {
230245
const handleSavePreferences = async (preferences: SearchPreferences) => {
231246
try {
232247
await saveSearchPreferences(preferences);
248+
hasUserSelectedModelFilter.current = false;
249+
setFilterType(getModelFilterFromJobTypes(preferences.jobTypes));
233250
showToast("Preferências de busca atualizadas.");
234251
} catch {
235252
// O hook já notificou a falha preservando as preferências editadas.
236253
}
237254
};
238255

256+
const handleFilterTypeChange = useCallback((value: JobModelFilter) => {
257+
hasUserSelectedModelFilter.current = true;
258+
setFilterType(value);
259+
}, []);
260+
239261
const handleCareerChecklistChange = useCallback(
240262
(careerChecklist: CareerChecklist[]) => {
241263
const nextPreferences = {
@@ -313,9 +335,7 @@ export default function NewDashboardPage() {
313335
const typedKeywords = parseSearchKeywords(searchQuery);
314336
const filters = {
315337
...(filterLevel !== "Todos" ? { level: filterLevel } : {}),
316-
...(filterType !== "Todos"
317-
? { type: filterType, model: filterType }
318-
: {}),
338+
...modelFilterToApiFilter(filterType),
319339
...(continentFilter !== "Todos" ? { continent: continentFilter } : {}),
320340
...(countryFilter !== "Todos"
321341
? { country: countryFilter, location: countryFilter }
@@ -347,6 +367,12 @@ export default function NewDashboardPage() {
347367
searchQuery,
348368
]);
349369

370+
useEffect(() => {
371+
if (isLoadingUserData || hasUserSelectedModelFilter.current) return;
372+
373+
setFilterType(getModelFilterFromJobTypes(searchPreferences.jobTypes));
374+
}, [isLoadingUserData, searchPreferences.jobTypes]);
375+
350376
// Busca automática: dispara ao digitar (com debounce) ou ao trocar
351377
// qualquer filtro, sem precisar clicar em "Buscar vagas".
352378
const isFirstSearchRun = useRef(true);
@@ -398,11 +424,11 @@ export default function NewDashboardPage() {
398424
case "vagas":
399425
return (
400426
<JobTab
401-
jobs={matchedRecommendedJobs}
427+
jobs={displayedRecommendedJobs}
402428
searchQuery={searchQuery}
403429
setSearchQuery={setSearchQuery}
404430
filterType={filterType}
405-
setFilterType={setFilterType}
431+
setFilterType={handleFilterTypeChange}
406432
filterLevel={filterLevel}
407433
setFilterLevel={setFilterLevel}
408434
continentFilter={continentFilter}

frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Search } from "lucide-react";
2-
import { jobLevels, jobTypes } from "../../constants";
3-
import type { MatchSort } from "../../types";
2+
import { jobLevels } from "../../constants";
3+
import type { JobModelFilter, MatchSort } from "../../types";
4+
import { jobModelFilterOptions } from "../../utils/jobModelFilters";
45
import {
56
continentOptions,
67
countryOptions,
@@ -11,8 +12,8 @@ import {
1112
interface JobFilterProps {
1213
searchQuery: string;
1314
setSearchQuery: (value: string) => void;
14-
filterType: string;
15-
setFilterType: (value: string) => void;
15+
filterType: JobModelFilter;
16+
setFilterType: (value: JobModelFilter) => void;
1617
filterLevel: string;
1718
setFilterLevel: (value: string) => void;
1819
continentFilter: ContinentFilter;
@@ -51,13 +52,19 @@ export function JobFilter({
5152

5253
<select
5354
value={filterType}
54-
onChange={(event) => setFilterType(event.target.value)}
55+
onChange={(event) =>
56+
setFilterType(event.target.value as JobModelFilter)
57+
}
5558
className="h-11 rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring"
5659
>
5760
<option value="Todos">Modelo (Todos)</option>
58-
{jobTypes.map((type) => (
59-
<option key={type}>{type}</option>
60-
))}
61+
{jobModelFilterOptions
62+
.filter((option) => option.value !== "Todos")
63+
.map((option) => (
64+
<option key={option.value} value={option.value}>
65+
{option.label}
66+
</option>
67+
))}
6168
</select>
6269

6370
<select

frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
import { Search } from "lucide-react";
2-
import type { Job, JobStatus, MatchSort, SearchPreferences } from "../../types";
2+
import type {
3+
Job,
4+
JobModelFilter,
5+
JobStatus,
6+
MatchSort,
7+
SearchPreferences,
8+
} from "../../types";
39
import {
410
type ContinentFilter,
511
type CountryFilter,
612
} from "../../utils/locationFilters";
13+
import {
14+
getModelFilterFromJobTypes,
15+
jobModelFilterOptions,
16+
} from "../../utils/jobModelFilters";
717
import { JobFilter } from "./JobFilter";
818
import { JobTable } from "./JobTable";
919

1020
interface JobTabProps {
1121
jobs: Job[];
1222
searchQuery: string;
1323
setSearchQuery: (value: string) => void;
14-
filterType: string;
15-
setFilterType: (value: string) => void;
24+
filterType: JobModelFilter;
25+
setFilterType: (value: JobModelFilter) => void;
1626
filterLevel: string;
1727
setFilterLevel: (value: string) => void;
1828
continentFilter: ContinentFilter;
@@ -59,6 +69,13 @@ export function JobTab({
5969
onOpenJob,
6070
onStatusChange,
6171
}: JobTabProps) {
72+
const preferredModelFilter = searchPreferences
73+
? getModelFilterFromJobTypes(searchPreferences.jobTypes)
74+
: "Todos";
75+
const preferredModelLabel = jobModelFilterOptions.find(
76+
(option) => option.value === preferredModelFilter,
77+
)?.label;
78+
6279
return (
6380
<div className="mx-auto flex w-full max-w-[1680px] flex-col gap-6 px-6 py-8 lg:px-8">
6481
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
@@ -97,10 +114,10 @@ export function JobTab({
97114
setMatchSort={setMatchSort}
98115
/>
99116

100-
{searchPreferences?.remoteOnly && (
117+
{preferredModelFilter !== "Todos" && preferredModelLabel && (
101118
<p className="text-sm font-semibold text-emerald-600">
102-
Filtro de busca ativo: Apenas oportunidades remotas habilitado nas
103-
preferências.
119+
Preferência ativa: {preferredModelLabel.toLowerCase()} como padrão
120+
nesta busca.
104121
</p>
105122
)}
106123

frontend/src/domains/new_dashboard/components/profile/PreferencesForm.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { SearchPreferences } from "../../types";
1+
import type { JobModelFilter, SearchPreferences } from "../../types";
2+
import {
3+
getJobTypesFromModelFilter,
4+
getModelFilterFromJobTypes,
5+
jobModelFilterOptions,
6+
} from "../../utils/jobModelFilters";
27

38
interface PreferencesFormProps {
49
searchPreferences: SearchPreferences;
@@ -13,6 +18,10 @@ export function PreferencesForm({
1318
isSaving = false,
1419
onSave,
1520
}: PreferencesFormProps) {
21+
const preferredModelFilter = getModelFilterFromJobTypes(
22+
searchPreferences.jobTypes,
23+
);
24+
1625
return (
1726
<section className="rounded-2xl border border-border bg-card p-6 shadow-sm">
1827
<h2 className="text-[18px] font-bold">Preferências de Carreira</h2>
@@ -43,24 +52,34 @@ export function PreferencesForm({
4352
/>
4453
</label>
4554

46-
<div className="flex flex-col justify-center gap-3">
47-
<label className="flex cursor-pointer select-none items-start gap-3 text-xs font-bold text-muted-foreground">
48-
<input
49-
type="checkbox"
50-
checked={searchPreferences.remoteOnly}
51-
onChange={(event) =>
55+
<div className="flex flex-col justify-center gap-4">
56+
<label>
57+
<span className="mb-2 block text-xs font-bold uppercase text-muted-foreground">
58+
Vagas Exibidas Inicialmente
59+
</span>
60+
<select
61+
value={preferredModelFilter}
62+
onChange={(event) => {
63+
const jobTypes = getJobTypesFromModelFilter(
64+
event.target.value as JobModelFilter,
65+
);
5266
setSearchPreferences((current) => ({
5367
...current,
54-
remoteOnly: event.target.checked,
55-
}))
56-
}
57-
className="mt-0.5 h-4 w-4 rounded accent-primary"
58-
/>
59-
<span>
60-
<span className="block text-foreground">Apenas Oportunidades Remotas</span>
61-
<span className="mt-1 block font-normal">
62-
Esconde vagas híbridas ou presenciais da listagem principal.
63-
</span>
68+
jobTypes,
69+
remoteOnly:
70+
jobTypes.length === 1 && jobTypes[0] === "Remoto",
71+
}));
72+
}}
73+
className="h-10 w-full rounded-lg border border-input bg-card px-4 text-sm outline-none focus:border-ring"
74+
>
75+
{jobModelFilterOptions.map((option) => (
76+
<option key={option.value} value={option.value}>
77+
{option.label}
78+
</option>
79+
))}
80+
</select>
81+
<span className="mt-2 block text-xs text-muted-foreground">
82+
Define o filtro padrão aplicado automaticamente na tela de vagas.
6483
</span>
6584
</label>
6685

frontend/src/domains/new_dashboard/constants/initialData.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export const initialPreferences: SearchPreferences = {
210210
keywords: ["React", "Frontend", "Fullstack"],
211211
searchLocation: "São Paulo, SP",
212212
remoteOnly: true,
213+
jobTypes: ["Remoto"],
213214
emailNotifications: true,
214215
careerChecklist: [],
215216
};

0 commit comments

Comments
 (0)