From fc7d156f08f988e6d054578baea25f578a578efd Mon Sep 17 00:00:00 2001 From: hltav Date: Thu, 16 Jul 2026 11:05:39 -0300 Subject: [PATCH 1/2] feat(new-dashboard): cria new dashboard e amplia cobertura de testes --- backend/src/routes/jobs.routes.ts | 120 ++++- backend/tests/unit/app.test.ts | 128 ++++++ .../new_dashboard/NewDashboardPage.tsx | 334 ++++++++++++++ .../components/dashboard/DashboardTab.tsx | 133 ++++++ .../components/dashboard/KanbanBoard.tsx | 45 ++ .../components/dashboard/KanbanColumn.tsx | 127 ++++++ .../new_dashboard/components/help/HelpTab.tsx | 40 ++ .../components/home/CareerChecklist.tsx | 301 +++++++++++++ .../new_dashboard/components/home/HomeTab.tsx | 77 ++++ .../components/home/StatusConters.tsx | 1 + .../components/home/StatusCounters.tsx | 41 ++ .../components/home/WelcomeBanner.tsx | 48 ++ .../components/jobs/AddJobModal.tsx | 181 ++++++++ .../components/jobs/JobDetailModal.tsx | 204 +++++++++ .../components/jobs/JobFilter.tsx | 98 +++++ .../components/jobs/JobFilters.tsx | 2 + .../new_dashboard/components/jobs/JobRow.tsx | 81 ++++ .../new_dashboard/components/jobs/JobTab.tsx | 148 +++++++ .../components/jobs/JobTable.tsx | 179 ++++++++ .../components/layout/Header.tsx | 254 +++++++++++ .../components/layout/Sidebar.tsx | 88 ++++ .../components/layout/ThemeToggle.tsx | 21 + .../components/mentoring/MentorCard.tsx | 88 ++++ .../mentoring/MentorDetailModal.tsx | 80 ++++ .../components/mentoring/MentoringTab.tsx | 28 ++ .../components/profile/PreferencesForm.tsx | 102 +++++ .../components/profile/ProfileForm.tsx | 196 +++++++++ .../components/profile/ProfileTab.tsx | 42 ++ .../components/shared/CandidateLogo.tsx | 41 ++ .../new_dashboard/components/shared/Icons.tsx | 39 ++ .../new_dashboard/components/shared/Modal.tsx | 43 ++ .../new_dashboard/components/shared/Toast.tsx | 16 + .../src/domains/new_dashboard/constants.ts | 1 + .../domains/new_dashboard/constants/colors.ts | 36 ++ .../domains/new_dashboard/constants/index.ts | 4 + .../new_dashboard/constants/initialData.ts | 200 +++++++++ .../domains/new_dashboard/constants/tokens.ts | 25 ++ .../new_dashboard/context/JobsContext.tsx | 55 +++ .../new_dashboard/context/ThemeContext.tsx | 29 ++ .../new_dashboard/context/ToastContext.tsx | 35 ++ .../new_dashboard/hooks/useDashboardJobs.ts | 320 ++++++++++++++ .../domains/new_dashboard/hooks/useJobs.ts | 2 + .../domains/new_dashboard/hooks/useTheme.ts | 2 + .../domains/new_dashboard/hooks/useToast.ts | 2 + .../hooks/useUserDashboardData.ts | 163 +++++++ .../infrastructure/dashboardJobsApi.ts | 300 +++++++++++++ .../infrastructure/userDashboardApi.ts | 140 ++++++ frontend/src/domains/new_dashboard/layout.tsx | 16 + frontend/src/domains/new_dashboard/page.tsx | 2 + frontend/src/domains/new_dashboard/types.ts | 1 + .../src/domains/new_dashboard/types/index.ts | 3 + .../src/domains/new_dashboard/types/job.ts | 49 +++ .../src/domains/new_dashboard/types/mentor.ts | 18 + .../src/domains/new_dashboard/types/user.ts | 39 ++ .../domains/new_dashboard/utils/helpers.ts | 40 ++ .../new_dashboard/utils/locationFilters.ts | 161 +++++++ .../new_dashboard/utils/searchKeywords.ts | 9 + frontend/tests/unit/new_dashboard/api.test.ts | 416 ++++++++++++++++++ .../new_dashboard/branch-coverage.test.tsx | 169 +++++++ .../tests/unit/new_dashboard/context.test.tsx | 72 +++ .../new_dashboard/dashboard.layout.test.tsx | 157 +++++++ .../unit/new_dashboard/home.profile.test.tsx | 242 ++++++++++ .../tests/unit/new_dashboard/hooks.test.tsx | 311 +++++++++++++ .../tests/unit/new_dashboard/jobs.test.tsx | 341 ++++++++++++++ .../tests/unit/new_dashboard/page.test.tsx | 263 +++++++++++ .../unit/new_dashboard/theme.toast.test.tsx | 105 +++++ .../new_dashboard/userDashboardData.test.tsx | 211 +++++++++ .../tests/unit/new_dashboard/utils.test.ts | 88 ++++ frontend/tests/unit/shared/apiClient.test.ts | 116 +++++ 69 files changed, 7467 insertions(+), 2 deletions(-) create mode 100644 frontend/src/domains/new_dashboard/NewDashboardPage.tsx create mode 100644 frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx create mode 100644 frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx create mode 100644 frontend/src/domains/new_dashboard/components/help/HelpTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx create mode 100644 frontend/src/domains/new_dashboard/components/home/HomeTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/home/StatusConters.tsx create mode 100644 frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx create mode 100644 frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobDetailModal.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobFilter.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobFilters.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobRow.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/jobs/JobTable.tsx create mode 100644 frontend/src/domains/new_dashboard/components/layout/Header.tsx create mode 100644 frontend/src/domains/new_dashboard/components/layout/Sidebar.tsx create mode 100644 frontend/src/domains/new_dashboard/components/layout/ThemeToggle.tsx create mode 100644 frontend/src/domains/new_dashboard/components/mentoring/MentorCard.tsx create mode 100644 frontend/src/domains/new_dashboard/components/mentoring/MentorDetailModal.tsx create mode 100644 frontend/src/domains/new_dashboard/components/mentoring/MentoringTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/profile/PreferencesForm.tsx create mode 100644 frontend/src/domains/new_dashboard/components/profile/ProfileForm.tsx create mode 100644 frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx create mode 100644 frontend/src/domains/new_dashboard/components/shared/CandidateLogo.tsx create mode 100644 frontend/src/domains/new_dashboard/components/shared/Icons.tsx create mode 100644 frontend/src/domains/new_dashboard/components/shared/Modal.tsx create mode 100644 frontend/src/domains/new_dashboard/components/shared/Toast.tsx create mode 100644 frontend/src/domains/new_dashboard/constants.ts create mode 100644 frontend/src/domains/new_dashboard/constants/colors.ts create mode 100644 frontend/src/domains/new_dashboard/constants/index.ts create mode 100644 frontend/src/domains/new_dashboard/constants/initialData.ts create mode 100644 frontend/src/domains/new_dashboard/constants/tokens.ts create mode 100644 frontend/src/domains/new_dashboard/context/JobsContext.tsx create mode 100644 frontend/src/domains/new_dashboard/context/ThemeContext.tsx create mode 100644 frontend/src/domains/new_dashboard/context/ToastContext.tsx create mode 100644 frontend/src/domains/new_dashboard/hooks/useDashboardJobs.ts create mode 100644 frontend/src/domains/new_dashboard/hooks/useJobs.ts create mode 100644 frontend/src/domains/new_dashboard/hooks/useTheme.ts create mode 100644 frontend/src/domains/new_dashboard/hooks/useToast.ts create mode 100644 frontend/src/domains/new_dashboard/hooks/useUserDashboardData.ts create mode 100644 frontend/src/domains/new_dashboard/infrastructure/dashboardJobsApi.ts create mode 100644 frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts create mode 100644 frontend/src/domains/new_dashboard/layout.tsx create mode 100644 frontend/src/domains/new_dashboard/page.tsx create mode 100644 frontend/src/domains/new_dashboard/types.ts create mode 100644 frontend/src/domains/new_dashboard/types/index.ts create mode 100644 frontend/src/domains/new_dashboard/types/job.ts create mode 100644 frontend/src/domains/new_dashboard/types/mentor.ts create mode 100644 frontend/src/domains/new_dashboard/types/user.ts create mode 100644 frontend/src/domains/new_dashboard/utils/helpers.ts create mode 100644 frontend/src/domains/new_dashboard/utils/locationFilters.ts create mode 100644 frontend/src/domains/new_dashboard/utils/searchKeywords.ts create mode 100644 frontend/tests/unit/new_dashboard/api.test.ts create mode 100644 frontend/tests/unit/new_dashboard/branch-coverage.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/context.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/dashboard.layout.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/home.profile.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/hooks.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/jobs.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/page.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/theme.toast.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/userDashboardData.test.tsx create mode 100644 frontend/tests/unit/new_dashboard/utils.test.ts create mode 100644 frontend/tests/unit/shared/apiClient.test.ts diff --git a/backend/src/routes/jobs.routes.ts b/backend/src/routes/jobs.routes.ts index 7bd23cf..4989050 100644 --- a/backend/src/routes/jobs.routes.ts +++ b/backend/src/routes/jobs.routes.ts @@ -9,6 +9,105 @@ import { logWarn } from "../logger"; export const jobsRoutes = Router(); +type SearchJob = { + title?: string | null; + location?: string | null; + modality?: string | null; + description?: string | null; +}; + +function firstQueryValue(value: unknown): string { + if (Array.isArray(value)) return firstQueryValue(value[0]); + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeComparable(value: string): string { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); +} + +function inferJobLevel(title: string): string { + const normalized = normalizeComparable(title); + if (normalized.includes("senior") || normalized.includes("sr")) { + return "senior"; + } + if (normalized.includes("junior") || normalized.includes("jr")) { + return "junior"; + } + return "pleno"; +} + +function inferJobType(job: SearchJob): string { + const normalized = normalizeComparable( + [ + job.title, + job.location, + job.modality, + job.description, + ] + .filter(Boolean) + .join(" "), + ); + + if (normalized.includes("hibrid") || normalized.includes("hybrid")) { + return "hibrido"; + } + if ( + normalized.includes("remot") || + normalized.includes("home office") || + normalized.includes("teletrabalho") || + normalized.includes("anywhere") || + normalized.includes("worldwide") + ) { + return "remoto"; + } + if ( + normalized.includes("presencial") || + normalized.includes("onsite") || + normalized.includes("on site") || + normalized.includes("on-site") || + normalized.includes("in office") || + normalized.includes("escritorio") + ) { + return "presencial"; + } + + return "presencial"; +} + +function filterJobs(jobs: unknown[], query: Request["query"]): unknown[] { + const level = normalizeComparable(firstQueryValue(query.level)); + const location = normalizeComparable(firstQueryValue(query.location)); + const type = normalizeComparable(firstQueryValue(query.type)); + + if (!level && !location && !type) return jobs; + + return jobs.filter((job) => { + const candidate = job as SearchJob; + const title = candidate.title ?? ""; + const jobLocation = candidate.location ?? ""; + const normalizedLocation = normalizeComparable(jobLocation); + + const matchesLevel = !level || inferJobLevel(title) === level; + const matchesLocation = + !location || normalizedLocation.includes(location); + const matchesType = !type || inferJobType(candidate) === type; + + return matchesLevel && matchesLocation && matchesType; + }); +} + +function hasPostFetchFilters(query: Request["query"]): boolean { + return Boolean( + firstQueryValue(query.level) || + firstQueryValue(query.location) || + firstQueryValue(query.type), + ); +} + /** * @swagger * /api/jobs/search: @@ -42,8 +141,25 @@ jobsRoutes.get("/search", async (req: Request, res: Response) => { ids = await cacheAbsoluteSMembers("scraper:jobs:index"); } - const { data: pageIds, pagination: meta } = paginate(ids, pagination); - const jobs = await cacheGetJobsByIds(pageIds); + if (!hasPostFetchFilters(req.query)) { + const { data: pageIds, pagination: meta } = paginate(ids, pagination); + const jobs = await cacheGetJobsByIds(pageIds); + + return res.json({ + total: meta.total, + page: meta.page, + limit: meta.limit, + totalPages: meta.totalPages, + hasNext: meta.hasNext, + hasPrev: meta.hasPrev, + jobs, + source, + }); + } + + const allJobs = await cacheGetJobsByIds(ids); + const filteredJobs = filterJobs(allJobs, req.query); + const { data: jobs, pagination: meta } = paginate(filteredJobs, pagination); return res.json({ total: meta.total, diff --git a/backend/tests/unit/app.test.ts b/backend/tests/unit/app.test.ts index 75b9057..7f58d13 100644 --- a/backend/tests/unit/app.test.ts +++ b/backend/tests/unit/app.test.ts @@ -235,6 +235,69 @@ describe("jobsApiApp", () => { expect(res.body.source).toBe("valkey_global_index"); }); + it("GET /jobs/search aplica filtros de level, location e type", async () => { + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-1", + title: "Desenvolvedor Node.js Júnior", + company: "ACME", + location: "Brasil - Remoto", + }, + { + id: "id-2", + title: "Desenvolvedor Node.js Sênior", + company: "Globo", + location: "Estados Unidos - Remoto", + }, + { + id: "id-3", + title: "Desenvolvedor Node.js Pleno", + company: "Globex", + location: "Brasil - Hybrid Remote", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ + keywords: "Node.js", + level: "Júnior", + location: "Brasil", + type: "Remoto", + }) + .expect(200); + + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-1" })]); + expect(res.body.total).toBe(1); + }); + + it("GET /jobs/search diferencia modelo híbrido de remoto", async () => { + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-1", + title: "Desenvolvedor Node.js", + company: "ACME", + location: "Brasil - Remote", + }, + { + id: "id-2", + title: "Desenvolvedor Node.js", + company: "Globex", + location: "Brasil - Hybrid Remote", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ keywords: "Node.js", type: "Híbrido" }) + .expect(200); + + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-2" })]); + expect(res.body.total).toBe(1); + }); + it("GET /jobs/search retorna paginação correta", async () => { mocks.parsePagination.mockReturnValue({ page: 2, limit: 10 }); mocks.paginate.mockReturnValue({ @@ -288,6 +351,71 @@ describe("jobsApiApp", () => { expect(res.body.message).toBe("Erro ao recuperar vagas em memória."); }); + it("GET /jobs/search trata filtros em array e aplica o primeiro valor", async () => { + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-array", + title: "Desenvolvedor Node.js Júnior", + company: "ACME", + location: "Brasil - Remoto", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ + keywords: "Node.js", + level: ["Júnior", "Sênior"], + location: ["Brasil", "Portugal"], + type: ["Remoto", "Híbrido"], + }) + .expect(200); + + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-array" })]); + expect(res.body.total).toBe(1); + }); + + it("GET /jobs/search identifica vaga sênior presencial", async () => { + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-senior", + title: "Tech Lead Sênior", + company: "ACME", + location: "São Paulo - Onsite", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ keywords: "Tech Lead", type: "Presencial" }) + .expect(200); + + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-senior" })]); + expect(res.body.total).toBe(1); + }); + + it("GET /jobs/search usa o fallback de tipo presencial quando não há pistas de remoto", async () => { + mocks.cacheGetJobsByIds.mockResolvedValue([ + { + id: "id-fallback", + title: "Backend Developer", + company: "ACME", + location: "Brasil", + }, + ]); + + const app = createJobsApiApp(); + const res = await request(app) + .get("/jobs/search") + .query({ keywords: "Backend", type: "Presencial" }) + .expect(200); + + expect(res.body.jobs).toEqual([expect.objectContaining({ id: "id-fallback" })]); + expect(res.body.total).toBe(1); + }); + // ── keywords ────────────────────────────────────────────────────────── it("GET /keywords retorna keywords do banco", async () => { diff --git a/frontend/src/domains/new_dashboard/NewDashboardPage.tsx b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx new file mode 100644 index 0000000..d4ec297 --- /dev/null +++ b/frontend/src/domains/new_dashboard/NewDashboardPage.tsx @@ -0,0 +1,334 @@ +import { useAuth } from "@/domains/auth/application/AuthContext"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { DashboardTab } from "./components/dashboard/DashboardTab"; +import { HelpTab } from "./components/help/HelpTab"; +import { HomeTab } from "./components/home/HomeTab"; +import { AddJobModal } from "./components/jobs/AddJobModal"; +import { JobDetailModal } from "./components/jobs/JobDetailModal"; +import { JobTab } from "./components/jobs/JobTab"; +import { Header } from "./components/layout/Header"; +import { Sidebar } from "./components/layout/Sidebar"; +import { MentoringTab } from "./components/mentoring/MentoringTab"; +import { ProfileTab } from "./components/profile/ProfileTab"; +import { Toast } from "./components/shared/Toast"; +import { jobStatuses } from "./constants"; +import { useDashboardJobs } from "./hooks/useDashboardJobs"; +import { useUserDashboardData } from "./hooks/useUserDashboardData"; +import type { + JobStatus, + NewJob, + SearchPreferences, + UserProfile, +} from "./types"; +import { + type ContinentFilter, + type CountryFilter, +} from "./utils/locationFilters"; +import { parseSearchKeywords } from "./utils/searchKeywords"; + +function getSection(pathname: string) { + if (pathname.startsWith("/dashboard")) return "dashboard"; + if (pathname.startsWith("/vagas")) return "vagas"; + if (pathname.startsWith("/mentoria")) return "mentoria"; + if (pathname.startsWith("/perfil")) return "perfil"; + if (pathname.startsWith("/ajuda")) return "ajuda"; + return "home"; +} + +export default function NewDashboardPage() { + const { user, refreshUser } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + const [searchQuery, setSearchQuery] = useState(""); + const [filterType, setFilterType] = useState("Todos"); + const [filterLevel, setFilterLevel] = useState("Todos"); + const [continentFilter, setContinentFilter] = + useState("Todos"); + const [countryFilter, setCountryFilter] = useState("Todos"); + const [selectedJobId, setSelectedJobId] = useState(null); + const [isAddJobOpen, setIsAddJobOpen] = useState(false); + const [toast, setToast] = useState(""); + const showToast = useCallback((message: string) => setToast(message), []); + const { + userProfile, + setUserProfile, + searchPreferences, + setSearchPreferences, + isSavingProfile, + isSavingPreferences, + saveUserProfile, + saveSearchPreferences, + } = useUserDashboardData(user, { onError: showToast }); + const { + trackedJobs, + recommendedJobs, + recommendedPagination, + isRefreshingJobs, + refreshRecommendations, + changeRecommendationsPage, + addTrackedJob, + changeJobStatus, + changeJobNotesLocally, + saveJobNotes, + } = useDashboardJobs(user, { onError: showToast }); + + const selectedJob = + [...trackedJobs, ...recommendedJobs].find( + (job) => job.id === selectedJobId, + ) ?? null; + const section = getSection(location.pathname); + useEffect(() => { + if (!toast) return; + + const timeout = window.setTimeout(() => setToast(""), 3000); + return () => window.clearTimeout(timeout); + }, [toast]); + + const handleSaveProfile = async (profile: UserProfile) => { + try { + await saveUserProfile(profile); + await refreshUser(); + showToast("Perfil atualizado com sucesso."); + } catch { + // O hook já notificou a falha preservando os dados editados no formulário. + } + }; + + const handleSavePreferences = async (preferences: SearchPreferences) => { + try { + await saveSearchPreferences(preferences); + showToast("Preferências de busca atualizadas."); + } catch { + // O hook já notificou a falha preservando as preferências editadas. + } + }; + + const handleStatusChange = async (jobId: string, status: JobStatus) => { + try { + const updatedJob = await changeJobStatus(jobId, status); + if (updatedJob && selectedJobId === jobId) { + setSelectedJobId(updatedJob.id); + } + showToast(`Vaga atualizada para: ${jobStatuses[status]}`); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + }; + + const handleNotesChange = (jobId: string, notes: string) => { + changeJobNotesLocally(jobId, notes); + }; + + const handleAddJob = async (newJob: NewJob) => { + try { + await addTrackedJob(newJob); + showToast("Vaga adicionada às suas oportunidades."); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + }; + + const handleCloseJob = async () => { + const jobToPersist = selectedJob; + setSelectedJobId(null); + if (!jobToPersist) return; + + try { + await saveJobNotes(jobToPersist); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + }; + + // const buildRecommendationSearch = () => { + // const typedKeywords = parseSearchKeywords(searchQuery); + // const filters = { + // ...(filterLevel !== "Todos" ? { level: filterLevel } : {}), + // ...(filterType !== "Todos" ? { type: filterType } : {}), + // ...(countryFilter !== "Todos" ? { location: countryFilter } : {}), + // }; + + // return { + // keywords: + // typedKeywords.length > 0 ? typedKeywords : searchPreferences.keywords, + // filters, + // }; + // }; + + // const handleSearchJobs = async () => { + // try { + // const { keywords, filters } = buildRecommendationSearch(); + + // await refreshRecommendations(keywords, filters, 1); + // showToast("Vagas recomendadas atualizadas."); + // } catch { + // // A camada de dados já apresentou o erro retornado pela API. + // } + // }; + + const handleRecommendationPageChange = async (page: number) => { + try { + await changeRecommendationsPage(page); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + }; + + const buildRecommendationSearch = () => { + const typedKeywords = parseSearchKeywords(searchQuery); + const filters = { + ...(filterLevel !== "Todos" ? { level: filterLevel } : {}), + ...(filterType !== "Todos" ? { type: filterType } : {}), + ...(countryFilter !== "Todos" ? { location: countryFilter } : {}), + }; + + return { + keywords: + typedKeywords.length > 0 ? typedKeywords : searchPreferences.keywords, + filters, + }; + }; + + const handleSearchJobs = useCallback(async () => { + try { + const { keywords, filters } = buildRecommendationSearch(); + await refreshRecommendations(keywords, filters, 1); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + countryFilter, + filterLevel, + filterType, + refreshRecommendations, + searchQuery, + ]); + + // Busca automática: dispara ao digitar (com debounce) ou ao trocar + // qualquer filtro, sem precisar clicar em "Buscar vagas". + const isFirstSearchRun = useRef(true); + useEffect(() => { + if (section !== "vagas") return; + + if (isFirstSearchRun.current) { + isFirstSearchRun.current = false; + return; + } + + const timeoutId = window.setTimeout(() => { + void handleSearchJobs(); + }, 500); + + return () => window.clearTimeout(timeoutId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, filterType, filterLevel, countryFilter, section]); + + const handleRecommendationPageSizeChange = async (limit: number) => { + try { + const { keywords, filters } = buildRecommendationSearch(); + await refreshRecommendations(keywords, filters, 1, limit); + } catch { + // A camada de dados já apresentou o erro retornado pela API. + } + }; + + const renderContent = () => { + switch (section) { + case "dashboard": + return ( + setSelectedJobId(job.id)} + onStatusChange={handleStatusChange} + onAddJob={() => setIsAddJobOpen(true)} + /> + ); + case "vagas": + return ( + setSelectedJobId(job.id)} + onStatusChange={handleStatusChange} + /> + ); + case "mentoria": + return ; + case "perfil": + return ( + + ); + case "ajuda": + return ; + case "home": + default: + return ( + navigate("/vagas")} + /> + ); + } + }; + + return ( +
+ + +
+
+ +
+ {renderContent()} +
+
+ + {selectedJob ? ( + + ) : null} + + {isAddJobOpen ? ( + setIsAddJobOpen(false)} + onAddJob={handleAddJob} + /> + ) : null} + + +
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx new file mode 100644 index 0000000..f47cc24 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/dashboard/DashboardTab.tsx @@ -0,0 +1,133 @@ +import { BriefcaseBusiness, Plus } from "lucide-react"; +import type { Job, JobStatus } from "../../types"; +import { KanbanBoard } from "./KanbanBoard"; + +interface DashboardTabProps { + jobs: Job[]; + technologies: string[]; + onOpenJob: (job: Job) => void; + onStatusChange: (jobId: string, status: JobStatus) => void; + onAddJob?: () => void; +} + +export function DashboardTab({ + jobs, + technologies, + onOpenJob, + onStatusChange, + onAddJob, +}: DashboardTabProps) { + const totalJobs = Math.max(jobs.length, 1); + const startedJobs = jobs.filter((job) => job.status !== "saved").length; + const interviewingJobs = jobs.filter((job) => job.status === "interviewing").length; + + const funnelItems = [ + { + label: "Vagas Monitoradas", + count: jobs.length, + percent: jobs.length > 0 ? 100 : 0, + color: "bg-primary", + }, + { + label: "Processos Iniciados", + count: startedJobs, + percent: Math.round((startedJobs / totalJobs) * 100), + color: "bg-violet-500", + }, + { + label: "Etapas de Entrevista", + count: interviewingJobs, + percent: Math.round((interviewingJobs / totalJobs) * 100), + color: "bg-amber-500", + }, + ]; + + return ( +
+
+
+

Gerenciar Vagas

+

+ Acompanhe seu fluxo de contratação arrastando suas oportunidades de forma visual. +

+
+ +
+ +
+
+
+

Quadro Kanban de Candidaturas

+

+ Acompanhe e movimente o progresso de seus processos seletivos. +

+
+ + Sincronizado + +
+ +
+ +
+
+

Análise de Candidaturas

+
+ {funnelItems.map((item) => ( +
+
+ + {item.label} ({item.count}) + + {item.percent}% +
+
+
+
+
+ ))} +
+
+ +
+

Minhas Habilidades Técnicas

+
+ {technologies.slice(0, 5).map((tech, index) => { + const percent = 95 - index * 10; + + return ( +
+ {tech} +
+
+
+ {percent}% +
+ ); + })} +
+
+
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx b/frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx new file mode 100644 index 0000000..e1e42ef --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/dashboard/KanbanBoard.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import type { Job, JobStatus } from "../../types"; +import { KanbanColumn } from "./KanbanColumn"; + +interface KanbanBoardProps { + jobs: Job[]; + onOpenJob: (job: Job) => void; + onStatusChange: (jobId: string, status: JobStatus) => void; +} + +const columns = ["saved", "applied", "interviewing", "rejected", "accepted"] as const; + +export function KanbanBoard({ + jobs, + onOpenJob, + onStatusChange, +}: KanbanBoardProps) { + const [draggedJobId, setDraggedJobId] = useState(null); + + const draggedJob = draggedJobId + ? jobs.find((job) => job.id === draggedJobId) + : null; + + function handleDrop(status: JobStatus) { + if (!draggedJob || draggedJob.status === status) return; + onStatusChange(draggedJob.id, status); + } + + return ( +
+ {columns.map((status) => ( + job.status === status)} + onOpenJob={onOpenJob} + draggedJobId={draggedJobId} + onDragStart={setDraggedJobId} + onDragEnd={() => setDraggedJobId(null)} + onDropJob={handleDrop} + /> + ))} +
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx b/frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx new file mode 100644 index 0000000..0c36b25 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/dashboard/KanbanColumn.tsx @@ -0,0 +1,127 @@ +import type { Job, JobStatus } from "../../types"; + +interface KanbanColumnProps { + status: JobStatus; + jobs: Job[]; + onOpenJob: (job: Job) => void; + draggedJobId: string | null; + onDragStart: (jobId: string) => void; + onDragEnd: () => void; + onDropJob: (status: JobStatus) => void; +} + +export function KanbanColumn({ + status, + jobs, + onOpenJob, + draggedJobId, + onDragStart, + onDragEnd, + onDropJob, +}: KanbanColumnProps) { + const config = { + saved: { + title: "SALVAS", + tint: "bg-slate-50 dark:bg-slate-900/30", + empty: "Nenhuma vaga aqui", + scrollColor: "#64748b", + }, + applied: { + title: "CANDIDATADAS", + tint: "bg-slate-50 dark:bg-slate-900/30", + empty: "Nenhuma vaga aqui", + scrollColor: "#0ea5e9", + }, + interviewing: { + title: "ENTREVISTANDO", + tint: "bg-amber-50/40 dark:bg-amber-950/10", + empty: "Nenhuma vaga aqui", + scrollColor: "#f59e0b", + }, + rejected: { + title: "NÃO SELECIONADA", + tint: "bg-rose-50/35 dark:bg-rose-950/10", + empty: "Nenhuma vaga aqui", + scrollColor: "#f43f5e", + }, + accepted: { + title: "APROVADA!", + tint: "bg-emerald-50/40 dark:bg-emerald-950/10", + empty: "Nenhuma vaga aqui", + scrollColor: "#10b981", + }, + } satisfies Record< + JobStatus, + { title: string; tint: string; empty: string; scrollColor: string } + >; + + return ( +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + onDropJob(status); + }} + className={`flex h-[468px] min-w-[138px] flex-col rounded-2xl border border-border px-4 py-4 transition-colors ${config[status].tint} ${ + draggedJobId ? "ring-1 ring-primary/20" : "" + }`} + > +
+ {config[status].title} + + {jobs.length} + +
+
+ {jobs.length > 0 ? ( + jobs.map((job) => ( +
onOpenJob(job)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpenJob(job); + } + }} + onDragStart={(event) => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", job.id); + onDragStart(job.id); + }} + onDragEnd={onDragEnd} + className={`flex h-[116px] w-full cursor-grab flex-col rounded-lg border border-border bg-card p-3 text-left shadow-sm transition-colors hover:bg-muted active:cursor-grabbing ${ + draggedJobId === job.id ? "opacity-50 ring-2 ring-primary/40" : "" + }`} + > + + {job.jobTitle} + + + {job.company} + +
+ + {job.posted} + + + {job.matchScore}% Match + +
+
+ )) + ) : ( +
+ {config[status].empty} +
+ )} +
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/help/HelpTab.tsx b/frontend/src/domains/new_dashboard/components/help/HelpTab.tsx new file mode 100644 index 0000000..a1bb57e --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/help/HelpTab.tsx @@ -0,0 +1,40 @@ +export function HelpTab() { + const faqs = [ + { + question: "Como o Match Score das vagas é calculado?", + answer: + "Nosso sistema compara as tecnologias do seu perfil com os requisitos técnicos descritos por cada empresa para gerar uma porcentagem de compatibilidade.", + }, + { + question: "O que é a agenda disponível dos mentores?", + answer: + "Os horários disponíveis são sincronizados com a agenda interna de cada mentor. Você pode marcar um bate-papo técnico sem custo de forma simulada.", + }, + { + question: "Como posso mudar o meu status de candidatura?", + answer: + "Você pode clicar na aba 'Dashboard' e mover as vagas para acompanhar em tempo real se foi contratado ou selecionado para entrevistas.", + }, + ]; + + return ( +
+
+

Perguntas Frequentes & Central de Ajuda

+

+ Encontre dicas rápidas sobre como alavancar o uso do seu painel profissional. +

+
+ {faqs.map((faq) => ( +
+

{faq.question}

+

+ {faq.answer} +

+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx b/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx new file mode 100644 index 0000000..6c4a13e --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/home/CareerChecklist.tsx @@ -0,0 +1,301 @@ +import { ListChecks, Plus, Trash2 } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +type ChecklistItem = { + id: string; + label: string; + checked: boolean; +}; + +type ChecklistList = { + id: string; + title: string; + month: string; + items: ChecklistItem[]; +}; + +// TODO: mover para persistência por usuário no backend (preferências ou módulo próprio de checklists). +const STORAGE_KEY = "new-dashboard-career-checklists"; + +function createId() { + return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; +} + +function currentMonth() { + return new Date().toISOString().slice(0, 7); +} + +function formatMonth(month: string) { + const [year, monthNumber] = month.split("-").map(Number); + if (!year || !monthNumber) return month; + + return new Intl.DateTimeFormat("pt-BR", { + month: "long", + year: "numeric", + }).format(new Date(year, monthNumber - 1, 1)); +} + +function loadLists(): ChecklistList[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function CareerChecklist() { + const [lists, setLists] = useState(() => loadLists()); + const [selectedListId, setSelectedListId] = useState( + () => loadLists()[0]?.id ?? null, + ); + const [newListTitle, setNewListTitle] = useState(""); + const [newListMonth, setNewListMonth] = useState(currentMonth); + const [newItemLabel, setNewItemLabel] = useState(""); + + const selectedList = useMemo( + () => lists.find((list) => list.id === selectedListId) ?? lists[0], + [lists, selectedListId], + ); + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(lists)); + }, [lists]); + + const completedItems = + selectedList?.items.filter((item) => item.checked).length ?? 0; + const totalItems = selectedList?.items.length ?? 0; + + function addList() { + const month = newListMonth || currentMonth(); + const title = newListTitle.trim() || `Checklist de ${formatMonth(month)}`; + const list = { + id: createId(), + title, + month, + items: [], + }; + + setLists((current) => [list, ...current]); + setSelectedListId(list.id); + setNewListTitle(""); + setNewListMonth(currentMonth()); + } + + function deleteSelectedList() { + if (!selectedList) return; + + setLists((current) => current.filter((list) => list.id !== selectedList.id)); + setSelectedListId(null); + } + + function addItem() { + const label = newItemLabel.trim(); + if (!selectedList || !label) return; + + setLists((current) => + current.map((list) => + list.id === selectedList.id + ? { + ...list, + items: [ + ...list.items, + { id: createId(), label, checked: false }, + ], + } + : list, + ), + ); + setNewItemLabel(""); + } + + function toggleItem(itemId: string) { + if (!selectedList) return; + + setLists((current) => + current.map((list) => + list.id === selectedList.id + ? { + ...list, + items: list.items.map((item) => + item.id === itemId + ? { ...item, checked: !item.checked } + : item, + ), + } + : list, + ), + ); + } + + function deleteItem(itemId: string) { + if (!selectedList) return; + + setLists((current) => + current.map((list) => + list.id === selectedList.id + ? { + ...list, + items: list.items.filter((item) => item.id !== itemId), + } + : list, + ), + ); + } + + return ( +
+
+
+

+ Checklist de Carreira +

+

+ Organize listas mensais de acompanhamento. +

+
+ + {selectedList ? ( + + ) : null} +
+ +
+ setNewListTitle(event.target.value)} + placeholder="Nome da nova lista" + className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring" + /> + setNewListMonth(event.target.value)} + className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring" + /> + +
+ + {lists.length > 0 ? ( +
+ {lists.map((list) => ( + + ))} +
+ ) : null} + + {selectedList ? ( +
+
+
+

+ {selectedList.title} +

+

+ {formatMonth(selectedList.month)} · {completedItems}/{totalItems} itens +

+
+
+ +
+ setNewItemLabel(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") addItem(); + }} + placeholder="Novo item do checklist" + className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring" + /> + +
+ + {selectedList.items.length > 0 ? ( +
    + {selectedList.items.map((item) => ( +
  • + toggleItem(item.id)} + className="h-5 w-5 rounded border-border accent-emerald-600" + /> + + {item.label} + + +
  • + ))} +
+ ) : ( +
+ Nenhum item nesta lista. +
+ )} +
+ ) : ( +
+ +

+ Crie uma lista mensal para começar seu acompanhamento de carreira. +

+
+ )} +
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx new file mode 100644 index 0000000..01ec646 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/home/HomeTab.tsx @@ -0,0 +1,77 @@ +import { ClipboardList } from "lucide-react"; +import { jobStatuses } from "../../constants"; +import type { Job, UserProfile } from "../../types"; +import { CareerChecklist } from "./CareerChecklist"; +import { StatusCounters } from "./StatusCounters"; +import { WelcomeBanner } from "./WelcomeBanner"; + +interface HomeTabProps { + userProfile: UserProfile; + jobs: Job[]; + onExploreJobs: () => void; +} + +export function HomeTab({ userProfile, jobs, onExploreJobs }: HomeTabProps) { + const recentJobs = jobs.slice(0, 5); + + return ( +
+ + +
+ +
+
+ +
+

+ Candidaturas recentes +

+ + {recentJobs.length > 0 ? ( +
    + {recentJobs.map((job) => ( +
  • +
    +

    + {job.jobTitle} +

    +

    + {job.company} · {job.location} +

    +
    + + {jobStatuses[job.status]} + +
  • + ))} +
+ ) : ( +
+

+ Nenhuma vaga salva ainda. Busque oportunidades e salve as + candidaturas que quiser acompanhar aqui. +

+ +
+ )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/home/StatusConters.tsx b/frontend/src/domains/new_dashboard/components/home/StatusConters.tsx new file mode 100644 index 0000000..19c5eb2 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/home/StatusConters.tsx @@ -0,0 +1 @@ +export { StatusCounters } from "./StatusCounters"; diff --git a/frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx b/frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx new file mode 100644 index 0000000..956906a --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/home/StatusCounters.tsx @@ -0,0 +1,41 @@ +import type { Job } from "../../types"; + +interface StatusCountersProps { + jobs: Job[]; +} + +export function StatusCounters({ jobs }: StatusCountersProps) { + const saved = jobs.filter((job) => job.status === "saved").length; + const active = jobs.filter((job) => ["applied", "interviewing"].includes(job.status)).length; + const interviews = jobs.filter((job) => job.status === "interviewing").length; + const accepted = jobs.filter((job) => job.status === "accepted").length; + + return ( +
+ + + + +
+ ); +} + +function CounterCard({ + label, + value, + helper, +}: { + label: string; + value: number; + helper: string; +}) { + return ( +
+ + {label} + +

{value}

+

{helper}

+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx b/frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx new file mode 100644 index 0000000..31c6100 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/home/WelcomeBanner.tsx @@ -0,0 +1,48 @@ +import { BriefcaseBusiness, Sparkles } from "lucide-react"; +import type { UserProfile } from "../../types"; + +interface WelcomeBannerProps { + userProfile: UserProfile; + jobsCount: number; + onExploreJobs: () => void; +} + +export function WelcomeBanner({ + userProfile, + jobsCount, + onExploreJobs, +}: WelcomeBannerProps) { + return ( +
+ + +
+
+ + Seu Dashboard Profissional está pronto +
+ +

+ Que bom te ver de volta, {userProfile.displayName}! +

+ +

+ Sua conta de nível{" "} + {userProfile.level} monitora + atualmente {jobsCount}{" "} + {jobsCount === 1 ? "vaga" : "vagas"}. +

+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx b/frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx new file mode 100644 index 0000000..c899b30 --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/jobs/AddJobModal.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { jobLevels, jobTypes } from "../../constants"; +import type { NewJob } from "../../types"; +import { Modal } from "../shared/Modal"; + +const initialForm: NewJob = { + jobTitle: "", + company: "", + location: "", + salary: "", + type: "Remoto", + level: "Pleno", + tags: "", + source: "Manual", + jobLink: "", + notes: "", +}; + +interface AddJobModalProps { + onClose: () => void; + onAddJob: (job: NewJob) => void; +} + +export function AddJobModal({ onClose, onAddJob }: AddJobModalProps) { + const [form, setForm] = useState(initialForm); + const [error, setError] = useState(""); + + const updateField = (field: T, value: NewJob[T]) => { + setForm((current) => ({ ...current, [field]: value })); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!form.jobTitle.trim() || !form.company.trim()) { + setError("Informe pelo menos o cargo e a empresa."); + return; + } + + onAddJob(form); + onClose(); + }; + + return ( + + + + + } + > +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+ + + + + + +
+ + + +
+ + +
+ +