diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 311fff3..304e78c 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -1,5 +1,6 @@ Abaixo abertas +abordagem aborta abra abre @@ -32,6 +33,7 @@ adaptado adaptador Adaptadores adequa +adicionada adicionais Adicionamos Administrativas @@ -40,6 +42,9 @@ administrativos adzuna Adzuna afetada +áfrica +África +agendar agoda agregadas agregado @@ -58,6 +63,8 @@ Ajustes AKVGSK aleatório além +Alemanha +alemania alerta alertas Alertas @@ -81,10 +88,12 @@ alvo ambas ambiente ambientes +América amigáveis amigável amostra amplas +Análise analítico andamento andybalholm @@ -96,6 +105,7 @@ anteriores antigas antigo antigos +Anunciar apaga apareça aparece @@ -115,6 +125,8 @@ apontando aponte Apresentação apresentável +apresentou +APROVADA aproveitar aproximação aquela @@ -124,6 +136,7 @@ armazenadas Arquitetura artefatos asar +Ásia Assinaturas assíncrona assíncrono @@ -146,11 +159,14 @@ ator atrás atuais atualização +atualizações atualizada atualizadas atualmente aumentamos Aumentei +austrália +Austrália autentica autenticada autenticados @@ -160,6 +176,7 @@ automático automatizadas autorização autorizadas +auxiliares auxlib avaliação avaliado @@ -168,6 +185,8 @@ Avançadas avançado avisa baixa +baixos +Baixos barrar barras baseado @@ -194,6 +213,8 @@ bostonconsultinggroup branco brex BRPOP +Bruna +brunasilva bruta bruto brutos @@ -214,11 +235,16 @@ caixa calculável Camada camadas +canadá +Canadá Cancela cancelado cancelamento Cand +CANDIDATADAS +Candidatar candidatura +Candidaturas canva capacidade captura @@ -263,6 +289,7 @@ Ciclos Círculo cirúrgica clampeia +clicar clientes cmdmeta cobre @@ -279,9 +306,12 @@ coletar coletores colidem coloca +colômbia +Colômbia colunas comandos combinações +combinar começa comentados commitar @@ -293,6 +323,7 @@ compilador complementar completa completamente +Completar complexa complexas Componentes @@ -329,6 +360,7 @@ conhece conhecer conhecidas conjunto +Conseguir considere consistência consolidadas @@ -358,6 +390,8 @@ Controle Controles conversão Converte +convites +coreia coroutinelib corpo corporativo @@ -390,6 +424,7 @@ cruzada cuidado cujas cujo +currículo curta customuser daqui @@ -461,11 +496,13 @@ devolver devolvido diacríticos diária +Dica diferencial diferenciar diferentes difflib dificuldade +digitar digitou dimensões dinamicamente @@ -495,6 +532,7 @@ documentado Documentar dois doordash +dotlottie drena duas duplica @@ -505,8 +543,12 @@ duplicata duplicatas duração ecossistema +edita +editadas +editados efetivamente eficiência +egito elas Elementos eles @@ -522,8 +564,10 @@ encontrada encontradas encontrados encontram +Encontramos encontrar encontre +encontrou enfileira enfileirada enfileiradas @@ -541,6 +585,9 @@ entrada entrar Entregar Entregas +Entrevista +ENTREVISTANDO +entrevistas envia enviado envie @@ -559,11 +606,15 @@ escolhe Esconder escopo escreve +escritorio Escuro esgotada esgotadas espaço espaços +españa +espanha +Espanha especiais específica especificação @@ -581,6 +632,8 @@ esperado esperar esqueci essa +estados +Estados estágio estar Estas @@ -606,6 +659,7 @@ estritamente Estrutura estruturados Estudantes +Etapas evento eventos evitar @@ -650,6 +704,7 @@ expirou explicitamente explícito explodir +Explorar exporta exportacao exportadas @@ -665,6 +720,7 @@ externos extrai fala falam +Falar falhar falhas falhe @@ -690,6 +746,7 @@ flutuação fluxo Fluxos flyio +focado fonte foram formata @@ -701,6 +758,8 @@ fornecidas fornecido fornecidos fpconv +frança +França frequentemente fulano Fulano @@ -728,6 +787,7 @@ Gestão glassdoor Glassdoor globais +Globex Globo goautoneg golangci @@ -742,14 +802,17 @@ greehouse groot guia Gupy +Habilidades habilitado habilitar hashedpassword +híbrid híbrido Hierarquia hintrc Histórico hoje +holanda hotjar hscan HSTS @@ -757,6 +820,7 @@ huggingface hyperloglog içado ícone +ideais idênticas idênticos identidade @@ -804,6 +868,7 @@ indexadas Indexados indexam indexar +Índia indica índice índices @@ -822,10 +887,12 @@ infojobs informada informado informativa +Informe Infraestrutura ingestão Inicia iniciado +Iniciados Inicializa inicializado inicializador @@ -883,6 +950,7 @@ invertido invertidos invés iolib +irlanda isola isoladamente isoladas @@ -897,6 +965,7 @@ jobsglobalscraper jobstore jooble Jooble +julho KHTML klarna Krsj @@ -926,6 +995,7 @@ linhas linit listagem loadlib +localidade localiza Localização localizado @@ -955,7 +1025,9 @@ Mapear mapeia marca marcado +marcar margem +marrocos mastigada mathlib matriz @@ -964,6 +1036,7 @@ maxtimeout Mczn mecanismo média +médio meia melhorar melhores @@ -976,6 +1049,8 @@ memória menos mensagem mensagens +mensuráveis +mentores mescladas mesma mesmas @@ -986,9 +1061,12 @@ método métodos MÉTRICA métricas +méxico +México microsserviços Migrações milissegundos +Minhas mínima mínimas mínimo @@ -1007,12 +1085,16 @@ modernos momento momentos mondelezinternational +monitora +Monitoradas +monitorado Monitorar montá montar Mostrando Mostrar motivo +movimente muda mudam mude @@ -1028,8 +1110,10 @@ Nenhum nerdwallet nessa nesse +nesta NGJH ninguém +Nível nocmp nodetype noemail @@ -1042,7 +1126,9 @@ normalizada normalizado normalizados normalizar +Notas Notificações +notificou novamente novos novousuario @@ -1075,6 +1161,7 @@ operacional operacionalmente operações Operador +oportunidade oportunidades optar ordem @@ -1089,6 +1176,7 @@ orquestração osbuilders oslib osscluster +otimização otimizado outra pacote @@ -1103,11 +1191,13 @@ palavras Paletas paloaltonetworks papéis +papo paralelas paralelo parar parceiras Parcial +Parece parseado parseia parserc @@ -1131,6 +1221,7 @@ pelos peloton pendentes percentunit +perdeu perfeitamente perfis performático @@ -1177,6 +1268,7 @@ porém porque portanto portas +portfólio posicionamento posições positivos @@ -1188,6 +1280,7 @@ possuir pouco prática práticas +prático práticos precisa precisam @@ -1210,6 +1303,7 @@ presente presentes preserva preservada +preservando Pricebook Primário primeira @@ -1236,6 +1330,7 @@ progresso proibidos projetado Projetado +projetos promauto prometheuscommunity promhttp @@ -1262,6 +1357,7 @@ Publique Puerkito pura puro +Quadro qualidade quantas Quantidade @@ -1283,9 +1379,11 @@ recebe recebendo receber recente +recentemente recoloca Recomenda Recomendações +recomendadas recomendado recomendados reconecta @@ -1312,6 +1410,8 @@ reindexação reiniciada reiniciar Reiniciar +reino +Reino rejeição rejeita rejeitar @@ -1329,6 +1429,7 @@ removendo removida removidas removido +renderização repetidas repetitivos repositório @@ -1375,6 +1476,8 @@ reutilizada reutilizaveis reutilizáveis reutilizável +revisão +Revisar riotgames risco robinhood @@ -1412,6 +1515,8 @@ seguras seguro seguros sejam +SELECIONADA +seletivos seletor Selo semáforos @@ -1451,6 +1556,9 @@ simultaneamente simultâneos sinal sincronização +Sincronizado +Sincronizar +Singapura singleflight SISMEMBER SMEMBERS @@ -1503,8 +1611,12 @@ talentos talvez tarefas técnica +Técnicas +técnico +Tecnologias Tela telas +teletrabalho tema temos temporária @@ -1534,6 +1646,7 @@ tiver tocar tolerância topo +tornou tráfego Tráfego transação @@ -1544,6 +1657,7 @@ tratamento travar traz trazer +três Triagem triar trilhas @@ -1566,6 +1680,10 @@ unicas únicas único únicos +unido +Unido +unidos +Unidos unitários unitytechnologies unpic @@ -1618,6 +1736,8 @@ vetorial vezes viagem vierem +vinda +vindas vindo vira vírgula @@ -1638,5 +1758,7 @@ xpack xxhash yamlh yamlprivateh +zelândia +Zelândia zerados ZLDS diff --git a/backend/bruno/Auth/Register.bru b/backend/bruno/Auth/Register.bru index 9061229..392d77f 100644 --- a/backend/bruno/Auth/Register.bru +++ b/backend/bruno/Auth/Register.bru @@ -12,10 +12,13 @@ post { body:json { { - "email":"hudson60@gmail.com", - "password": "tavares10", - "name": "Hudson Tavares", - "role":"banana" + "email": "teste.criptografia.001@example.com", + "password": "SenhaForte123", + "name": "Teste Criptografia", + "phone": "+5534999999999", + "cpf": "12345678901", + "technologies": ["TypeScript", "Node.js", "React"], + "level": "pleno" } } diff --git a/backend/bruno/Jobs/SearchJobs.bru b/backend/bruno/Jobs/SearchJobs.bru index 9f28754..29ef13e 100644 --- a/backend/bruno/Jobs/SearchJobs.bru +++ b/backend/bruno/Jobs/SearchJobs.bru @@ -5,12 +5,13 @@ meta { } get { - url: {{base_url}}/jobs/search?keywords=node.js + url: {{base_url}}/jobs/search?location=&keywords=node.js body: none auth: inherit } params:query { + location: keywords: node.js } 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/front_admin/src/app/layouts/MainLayout/Sidebar/Sidebar.tsx b/front_admin/src/app/layouts/MainLayout/Sidebar/Sidebar.tsx index 989f9f1..b034090 100644 --- a/front_admin/src/app/layouts/MainLayout/Sidebar/Sidebar.tsx +++ b/front_admin/src/app/layouts/MainLayout/Sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router-dom"; +import { CandidateLogo } from "../../../../lib/ui/CandidateLogo"; import { useAuth } from "../../../../modules/auth/hooks/useAuth"; -import { CandidateLogo } from "../components/CandidateLogo"; import { SidebarFooter } from "./components/SidebarFooter"; import { SidebarItem } from "./components/SidebarItem"; import { NAV_ITEMS } from "./sidebar.config"; diff --git a/front_admin/src/app/layouts/MainLayout/components/CandidateLogo.tsx b/front_admin/src/lib/ui/CandidateLogo.tsx similarity index 59% rename from front_admin/src/app/layouts/MainLayout/components/CandidateLogo.tsx rename to front_admin/src/lib/ui/CandidateLogo.tsx index 1b93d74..b2757df 100644 --- a/front_admin/src/app/layouts/MainLayout/components/CandidateLogo.tsx +++ b/front_admin/src/lib/ui/CandidateLogo.tsx @@ -1,24 +1,3 @@ -// interface CandidateLogoProps { -// size?: "sm" | "md"; -// } - -// const SIZE_STYLES = { -// sm: "text-xl", -// md: "text-2xl", -// }; - -// export function CandidateLogo({ size = "md" }: CandidateLogoProps) { -// return ( -// -// < -// Cand!Date -// !> -// -// ); -// } interface CandidateLogoProps { size?: "sm" | "md"; } diff --git a/front_admin/src/modules/auth/LoginPage.tsx b/front_admin/src/modules/auth/LoginPage.tsx index d43a4fb..f1781f1 100644 --- a/front_admin/src/modules/auth/LoginPage.tsx +++ b/front_admin/src/modules/auth/LoginPage.tsx @@ -1,7 +1,7 @@ import { Lock } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { CandidateLogo } from "../../app/layouts/MainLayout/components/CandidateLogo"; import { ErrorMessage } from "../../components/common/ErrorMessage"; +import { CandidateLogo } from "../../lib/ui/CandidateLogo"; import { BrandPanel } from "./components/BrandPanel"; import { LoginForm } from "./components/LoginForm"; import { LoginHeader } from "./components/LoginHeader"; diff --git a/front_admin/tsconfig.app.json b/front_admin/tsconfig.app.json index 6830b6f..9f77597 100644 --- a/front_admin/tsconfig.app.json +++ b/front_admin/tsconfig.app.json @@ -22,5 +22,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "../shared/CandidateLogo.tsx"] } diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 7c6d00a..8fe04bc 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,6 +19,7 @@ export default defineConfig([ ecmaVersion: 2020, globals: globals.browser, parserOptions: { + tsconfigRootDir: import.meta.dirname, ecmaVersion: 'latest', ecmaFeatures: { jsx: true }, sourceType: 'module', diff --git a/frontend/src/app/AppRoutes.tsx b/frontend/src/app/AppRoutes.tsx index 75120f5..f165331 100644 --- a/frontend/src/app/AppRoutes.tsx +++ b/frontend/src/app/AppRoutes.tsx @@ -1,11 +1,11 @@ +import NotFound from "@/app/NotFound"; import { useAuth } from "@/domains/auth/application/AuthContext"; -import { AuthenticatedLayout } from "@/app/AuthenticatedLayout"; import AuthCallbackPage from "@/domains/auth/presentation/pages/AuthCallbackPage"; import LoginPage from "@/domains/auth/presentation/pages/LoginPage"; import RegisterPage from "@/domains/auth/presentation/pages/RegisterPage"; -import JobsPage from "@/domains/jobs/presentation/pages/JobsPage"; import LandingPage from "@/domains/marketing/presentation/pages/LandingPage"; -import NotFound from "@/app/NotFound"; +import NewDashboardPage from "@/domains/new_dashboard/NewDashboardPage"; +import NewDashboardLayout from "@/domains/new_dashboard/layout"; import Loading from "@/shared/ui/Loading"; import { Navigate, Route, Routes } from "react-router-dom"; @@ -24,21 +24,23 @@ function PublicRoute({ children }: { children: React.ReactNode }) { } export function AppRoutes() { + const dashboardElement = ( + + + + + + ); + return ( } /> - - - - } - > - } /> - } /> - } /> - } /> - + + + + + + ("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} + +
+ + + + + + +
+ + + +
+ + +
+ +