diff --git a/ROADMAP.md b/ROADMAP.md index edc8b12..887ad0c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ Estado atual, próximas fases e visão de longo prazo do projeto. | **Hamburger menu (mobile)** — header limpo com alerta urgência + bottom sheet de ações | ✅ | | **Mobile Pipeline compacto** — busca + sort inline, chips menores, mais espaço para cards | ✅ | | **Desktop hover actions** — arquivar/deletar aparecem ao hover no card sem precisar clicar+segurar | ✅ | -| Suite de testes — 255 testes (unit + component + integration), CI bloqueia build em falha | ✅ | +| Suite de testes — 187 testes (unit + component + integration), CI bloqueia build em falha | ✅ | | `.env.example` com todas as variáveis documentadas | ✅ | ### Infraestrutura @@ -272,3 +272,7 @@ Em produção Componentizado IA Avançada Plataforma | `VagaTab` — dados da vaga + próxima etapa com auto-stage + notas, substituindo OverviewTab | ✅ | | Cards compactos sem preview de nota | ✅ | | Arquivos obsoletos removidos (TimelineTab, AITab, OverviewTab, MessagesTab) | ✅ | +| **VagaTab accordion** — cards Próxima etapa / Dados da vaga / Anotações com padrão visual uniforme e abre/fecha | ✅ | +| **InlineTags compact** — input "+ tag" compacto e centralizado, sem corte no mobile | ✅ | +| **Desktop hover actions** — botão arquivar/deletar aparece no hover do card, sem sobreposição no Badge | ✅ | +| **Remoção de código morto** — NewProcessModal, ImportModal, ImportChatGPTModal, RecruiterMessageModal, importHelpers, isUrgent (-1847 linhas) | ✅ | diff --git a/TESTING.md b/TESTING.md index 8edbdd2..64c9f7e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,14 +4,14 @@ ## 1. Estado atual -**255 testes passando. Zero falhas.** +**187 testes passando. Zero falhas.** | Camada | Arquivos | Testes | |---|---|---| -| Unit | 9 arquivos | ~197 | -| Component | 6 arquivos | ~58 | -| Integration | 1 arquivo | — | -| **Total** | **17 arquivos** | **255** | +| Unit | 7 arquivos | ~155 | +| Component | 7 arquivos | ~22 | +| Integration | 1 arquivo | ~10 | +| **Total** | **15 arquivos** | **187** | **CI:** `npm run test:run` executa antes de `npm run build` em todo PR e push para `main`. Build só acontece se todos os testes passarem. @@ -34,29 +34,31 @@ ``` src/__tests__/ ├── unit/ -│ ├── buildPrompt.test.js # buildCVPrompt — montagem de prompt de adaptação de CV -│ ├── channel.test.js # CONTACT_CHANNELS — valores e ícones -│ ├── constants.test.js # STAGE, ACTIVE_STAGES, CHANNELS, SCENARIOS -│ ├── dateUtils.test.js # fmtDate, daysDiff — formatação e diff de datas -│ ├── edgeFunction.test.js # anthropic-proxy — rate limit, CORS, validação de payload +│ ├── buildPrompt.test.js # buildCVPrompt — montagem de prompt de adaptação de CV +│ ├── channel.test.js # CONTACT_CHANNELS — valores e ícones +│ ├── constants.test.js # STAGE, ACTIVE_STAGES, CHANNELS, SCENARIOS +│ ├── dateUtils.test.js # fmtDate, daysDiff — formatação e diff de datas +│ ├── edgeFunction.test.js # anthropic-proxy — rate limit, CORS, validação de payload │ ├── extractTextFromPdf.test.js # extractTextFromPdf — extração de texto de PDFs -│ ├── filterProcesses.test.js # filterProcesses — busca e filtro por stage -│ ├── importHelpers.test.js # parseCSV, normalizeProcess, detectFormat -│ ├── sort.test.js # sortProcesses — urgência, empresa, stage, recente -│ └── supabase.test.js # rowToProcess, processToRow — mapeadores snake_case ↔ camelCase +│ ├── filterProcesses.test.js # filterProcesses — busca e filtro por stage +│ ├── sort.test.js # sortProcesses — urgência, empresa, stage, recente +│ └── supabase.test.js # rowToProcess, processToRow — mapeadores snake_case ↔ camelCase │ ├── components/ │ ├── CVTab.test.jsx # CVTab — fluxo 4 etapas (input → analyzing → review → result) │ ├── InlineTags.test.jsx # InlineTags — adicionar, remover, tecla Enter/Escape │ ├── ProcessCard.test.jsx # ProcessCard — render, urgência, swipe mobile, ações │ ├── ProfileSetupModal.test.jsx # ProfileSetupModal — abas, salvar, upload PDF -│ ├── RecruiterMessageModal.test.jsx # RecruiterMessageModal — fluxo 3 etapas │ └── ResumesModal.test.jsx # ResumesModal — listagem, criar, editar, excluir │ └── integration/ └── resumes.test.js # useResumes hook — CRUD completo com Supabase mockado (MSW) ``` +**Removidos nesta sessão:** +- `importHelpers.test.js` — removido junto com `importHelpers.js` (código morto) +- `RecruiterMessageModal.test.jsx` — removido junto com `RecruiterMessageModal.jsx` (código morto) + --- ## 4. Regra: testes com toda nova funcionalidade @@ -164,5 +166,6 @@ Todo push para `main` e todo PR disparam esse pipeline. O deploy na Vercel só a | Data | Testes | Falhas | Observação | |---|---|---|---| +| 2026-06-07 | 187 | 0 | Remoção de código morto: ImportModal, ImportChatGPTModal, NewProcessModal, RecruiterMessageModal, importHelpers, isUrgent; VagaTab accordion; InlineTags compact | | 2026-05-30 | 253 | 0 | Correção: constants.test (ACTIVE_STAGES 4 itens), ProfileSetupModal (mock extractTextFromPdf, label aba "CV") | | Sessão anterior | 252 | 8 | ACTIVE_STAGES esperava 5 itens; ProfileSetupModal mockava pdfjs diretamente (não funcionava com lazy import) | diff --git a/public/screenshot.png b/public/screenshot.png new file mode 100644 index 0000000..61d0111 Binary files /dev/null and b/public/screenshot.png differ diff --git a/src/__tests__/components/RecruiterMessageModal.test.jsx b/src/__tests__/components/RecruiterMessageModal.test.jsx deleted file mode 100644 index 4655726..0000000 --- a/src/__tests__/components/RecruiterMessageModal.test.jsx +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { RecruiterMessageModal } from "../../components/modals/RecruiterMessageModal.jsx"; - -// ─── Mocks ──────────────────────────────────────────────────────────────────── -vi.mock("../../supabase.js", () => ({ - supabase: { - auth: { - getSession: vi.fn().mockResolvedValue({ - data: { session: { access_token: "mock-token" } }, - }), - }, - }, -})); - -const mockCallAI = vi.fn(); -vi.mock("../../lib/ai.js", () => ({ - callAI: (...args) => mockCallAI(...args), -})); - -// ─── Fixtures ───────────────────────────────────────────────────────────────── -const mockExtracted = JSON.stringify({ - recruiter: "Ana Lima", - recruiterRole: "Tech Recruiter", - company: "Nubank", - role: "Senior Frontend Engineer", - stack: "React, TypeScript, GraphQL", - regime: "remoto", - salary: "R$ 25k-30k", - nextStep: "Call de 30min", - location: "São Paulo, SP", -}); - -const mockDraftText = "Olá Ana, obrigado pelo contato! Tenho interesse em conhecer melhor a oportunidade na Nubank."; - -const defaultProps = { - onClose: vi.fn(), - onProcessCreated: vi.fn(), -}; - -beforeEach(() => { - vi.clearAllMocks(); - Object.assign(navigator, { - clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, - }); -}); - -// ─── Helpers ───────────────────────────────────────────────────────────────── -const setupToResult = async () => { - mockCallAI - .mockResolvedValueOnce(mockExtracted) // extraction call - .mockResolvedValueOnce(mockDraftText); // draft call (plain text) - - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Mensagem de recrutador da Nubank"); - await userEvent.click(screen.getByTestId("btn-analyze")); - - // Wait for result step to appear (extraction done) - await waitFor(() => screen.getByDisplayValue("Nubank")); - // Wait for draft to finish loading - await waitFor(() => screen.getByTestId("draft-output")); -}; - -// ─── Tests ─────────────────────────────────────────────────────────────────── -describe("RecruiterMessageModal — step paste", () => { - it("renderiza textarea para colar mensagem", () => { - render(); - expect(screen.getByTestId("msg-input")).toBeDefined(); - expect(screen.getByPlaceholderText(/Cole a mensagem aqui/i)).toBeDefined(); - }); - - it("botão Analisar desabilitado quando mensagem vazia", () => { - render(); - expect(screen.getByTestId("btn-analyze").disabled).toBe(true); - }); - - it("botão Analisar habilitado após digitar mensagem", async () => { - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Olá Fernando"); - expect(screen.getByTestId("btn-analyze").disabled).toBe(false); - }); - - it("initialMsg pré-preenche o textarea", () => { - render(); - expect(screen.getByDisplayValue("Mensagem pré-preenchida")).toBeDefined(); - }); - - it("botão Analisar habilitado quando initialMsg presente", () => { - render(); - expect(screen.getByTestId("btn-analyze").disabled).toBe(false); - }); - - it("clicar no X chama onClose", async () => { - render(); - await userEvent.click(screen.getByTestId("btn-close")); - expect(defaultProps.onClose).toHaveBeenCalledOnce(); - }); -}); - -describe("RecruiterMessageModal — extração via IA", () => { - it("exibe spinner no step working", async () => { - // Mock that never resolves so we can check the working state - mockCallAI.mockImplementation(() => new Promise(() => {})); - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Mensagem"); - await userEvent.click(screen.getByTestId("btn-analyze")); - await waitFor(() => { - expect(screen.getByText(/Extraindo informações/i)).toBeDefined(); - }); - }); - - it("após extração exibe campos company e role preenchidos", async () => { - mockCallAI - .mockResolvedValueOnce(mockExtracted) - .mockResolvedValue(mockDraftText); - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Mensagem"); - await userEvent.click(screen.getByTestId("btn-analyze")); - await waitFor(() => screen.getByDisplayValue("Nubank")); - expect(screen.getByDisplayValue("Senior Frontend Engineer")).toBeDefined(); - }); - - it("exibe erro quando extração falha e volta ao paste", async () => { - mockCallAI.mockRejectedValue(new Error("API error")); - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Mensagem"); - await userEvent.click(screen.getByTestId("btn-analyze")); - await waitFor(() => { - expect(screen.getByText(/Não foi possível extrair/i)).toBeDefined(); - }); - // Should be back on paste step - expect(screen.getByTestId("msg-input")).toBeDefined(); - }); -}); - -describe("RecruiterMessageModal — step result", () => { - it("campos extraídos são editáveis", async () => { - await setupToResult(); - const companyInput = screen.getByTestId("field-company"); - await userEvent.clear(companyInput); - await userEvent.type(companyInput, "Stone"); - expect(screen.getByDisplayValue("Stone")).toBeDefined(); - }); - - it("exibe rascunho de resposta em plain text", async () => { - await setupToResult(); - expect(screen.getByDisplayValue(mockDraftText)).toBeDefined(); - }); - - it("botão Copiar chama clipboard com texto do draft", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-copy")); - await waitFor(() => { - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockDraftText); - }); - }); - - it("botão Salvar chama onProcessCreated com dados corretos", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-save")); - expect(defaultProps.onProcessCreated).toHaveBeenCalledOnce(); - const created = defaultProps.onProcessCreated.mock.calls[0][0]; - expect(created.company).toBe("Nubank"); - expect(created.stage).toBe("contacted"); - expect(created.origin).toBe("inbound"); - expect(created.channel).toBe("linkedin"); - }); - - it("tags criadas a partir da stack extraída", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-save")); - const created = defaultProps.onProcessCreated.mock.calls[0][0]; - expect(created.tags).toContain("React"); - expect(created.tags).toContain("TypeScript"); - }); - - it("notas incluem mensagem original", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-save")); - const created = defaultProps.onProcessCreated.mock.calls[0][0]; - expect(created.notes).toContain("Mensagem de recrutador da Nubank"); - }); - - it("após salvar botão muda para 'Abrir processo' e chama onClose ao clicar", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-save")); - await waitFor(() => { - expect(screen.getByText(/Abrir processo/i)).toBeDefined(); - }); - await userEvent.click(screen.getByTestId("btn-save")); - expect(defaultProps.onClose).toHaveBeenCalledOnce(); - }); - - it("botão Voltar retorna ao step paste", async () => { - await setupToResult(); - await userEvent.click(screen.getByTestId("btn-back")); - expect(screen.getByTestId("msg-input")).toBeDefined(); - }); -}); - -describe("RecruiterMessageModal — sem draft", () => { - it("exibe mensagem de fallback quando draft falha", async () => { - mockCallAI - .mockResolvedValueOnce(mockExtracted) // extraction ok - .mockRejectedValueOnce(new Error("draft error")); // draft fails - - render(); - await userEvent.type(screen.getByTestId("msg-input"), "Mensagem"); - await userEvent.click(screen.getByTestId("btn-analyze")); - await waitFor(() => screen.getByDisplayValue("Nubank")); - await waitFor(() => { - expect(screen.getByText(/Não foi possível gerar a resposta/i)).toBeDefined(); - }); - // Save button still appears - expect(screen.getByTestId("btn-save")).toBeDefined(); - }); -}); diff --git a/src/__tests__/unit/dateUtils.test.js b/src/__tests__/unit/dateUtils.test.js index 3efab28..e11077d 100644 --- a/src/__tests__/unit/dateUtils.test.js +++ b/src/__tests__/unit/dateUtils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { fmtDate, daysDiff, isUrgent } from "../../utils/dateUtils.js"; +import { fmtDate, daysDiff } from "../../utils/dateUtils.js"; describe("fmtDate", () => { it("formata data válida em pt-BR", () => { @@ -68,21 +68,3 @@ describe("daysDiff", () => { expect(daysDiff("2026-06-20")).toBeGreaterThan(0); }); }); - -describe("isUrgent", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-05-20T12:00:00")); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it("null não é urgente", () => expect(isUrgent(null)).toBe(false)); - it("hoje (diff=0) é urgente", () => expect(isUrgent("2026-05-20")).toBe(true)); - it("amanhã (diff=1) é urgente", () => expect(isUrgent("2026-05-21")).toBe(true)); - it("depois de amanhã (diff=2) é urgente", () => expect(isUrgent("2026-05-22")).toBe(true)); - it("3 dias (diff=3) não é urgente", () => expect(isUrgent("2026-05-23")).toBe(false)); - it("ontem (diff=-1) não é urgente", () => expect(isUrgent("2026-05-19")).toBe(false)); -}); diff --git a/src/__tests__/unit/importHelpers.test.js b/src/__tests__/unit/importHelpers.test.js deleted file mode 100644 index 1ad5bb9..0000000 --- a/src/__tests__/unit/importHelpers.test.js +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - isChatGPTFormat, - isICCFormat, - looksLikeRecruitment, - parseCSV, - normalizeProcess, -} from "../../utils/importHelpers.js"; - -// ── isChatGPTFormat ────────────────────────────────────────────────────────── - -describe("isChatGPTFormat", () => { - it("retorna true para array com campo mapping", () => { - expect(isChatGPTFormat([{ mapping: {}, title: "Conversa" }])).toBe(true); - }); - - it("retorna false para array vazio", () => { - expect(isChatGPTFormat([])).toBe(false); - }); - - it("retorna false para array sem mapping", () => { - expect(isChatGPTFormat([{ company: "Nubank", role: "Dev" }])).toBe(false); - }); - - it("retorna false para não-array", () => { - expect(isChatGPTFormat(null)).toBe(false); - expect(isChatGPTFormat("string")).toBe(false); - expect(isChatGPTFormat({ mapping: {} })).toBe(false); - }); - - it("retorna false para objeto isolado com mapping (não array)", () => { - expect(isChatGPTFormat({ mapping: {} })).toBe(false); - }); -}); - -// ── isICCFormat ────────────────────────────────────────────────────────────── - -describe("isICCFormat", () => { - it("retorna true para array com campo company", () => { - expect(isICCFormat([{ company: "Nubank", role: "Dev", stage: "contacted" }])).toBe(true); - }); - - it("retorna false para array vazio", () => { - expect(isICCFormat([])).toBe(false); - }); - - it("retorna false para array sem company", () => { - expect(isICCFormat([{ mapping: {} }])).toBe(false); - }); - - it("retorna false para não-array", () => { - expect(isICCFormat(null)).toBe(false); - expect(isICCFormat("string")).toBe(false); - }); -}); - -// ── looksLikeRecruitment ───────────────────────────────────────────────────── - -describe("looksLikeRecruitment", () => { - it("detecta 'recrutador' no título", () => { - expect(looksLikeRecruitment({ title: "Conversa com recrutador" })).toBe(true); - }); - - it("detecta 'job' (inglês)", () => { - expect(looksLikeRecruitment({ title: "Senior job offer" })).toBe(true); - }); - - it("detecta 'vaga' (português)", () => { - expect(looksLikeRecruitment({ title: "Vaga de Dev React" })).toBe(true); - }); - - it("detecta 'interview'", () => { - expect(looksLikeRecruitment({ title: "Technical interview at Nubank" })).toBe(true); - }); - - it("detecta 'desenvolvedor'", () => { - expect(looksLikeRecruitment({ title: "Oportunidade para desenvolvedor" })).toBe(true); - }); - - it("retorna false para conversa genérica", () => { - expect(looksLikeRecruitment({ title: "Como fazer pizza margherita" })).toBe(false); - }); - - it("é case-insensitive", () => { - expect(looksLikeRecruitment({ title: "RECRUTADOR SENIOR" })).toBe(true); - }); - - it("retorna false para título vazio", () => { - expect(looksLikeRecruitment({ title: "" })).toBe(false); - }); - - it("retorna false quando título é undefined", () => { - expect(looksLikeRecruitment({})).toBe(false); - }); -}); - -// ── parseCSV ───────────────────────────────────────────────────────────────── - -describe("parseCSV", () => { - it("parseia CSV simples com cabeçalho", () => { - const csv = `company,role,stage\nNubank,Senior FE,interview`; - const result = parseCSV(csv); - expect(result).toHaveLength(1); - expect(result[0].company).toBe("Nubank"); - expect(result[0].role).toBe("Senior FE"); - expect(result[0].stage).toBe("interview"); - }); - - it("parseia múltiplas linhas", () => { - const csv = `company,role\nNubank,Dev\nSpotify,SWE\nStone,Tech Lead`; - expect(parseCSV(csv)).toHaveLength(3); - }); - - it("filtra linhas sem company", () => { - const csv = `company,role\nNubank,Dev\n,SemEmpresa`; - expect(parseCSV(csv)).toHaveLength(1); - }); - - it("aceita campo 'empresa' (português)", () => { - const csv = `empresa,role\nNubank,Dev`; - const result = parseCSV(csv); - expect(result).toHaveLength(1); - expect(result[0].empresa).toBe("Nubank"); - }); - - it("retorna array vazio para CSV sem dados (só cabeçalho)", () => { - expect(parseCSV("company,role")).toHaveLength(0); - }); - - it("retorna array vazio para texto vazio", () => { - expect(parseCSV("")).toHaveLength(0); - }); - - it("retorna array vazio para uma única linha sem cabeçalho", () => { - expect(parseCSV("somente uma linha")).toHaveLength(0); - }); - - it("remove aspas duplas dos valores", () => { - const csv = `company,role\n"Nubank","Senior FE"`; - const result = parseCSV(csv); - expect(result[0].company).toBe("Nubank"); - expect(result[0].role).toBe("Senior FE"); - }); - - it("converte cabeçalhos para minúsculas", () => { - const csv = `Company,Role\nNubank,Dev`; - const result = parseCSV(csv); - expect(result[0]).toHaveProperty("company"); - expect(result[0]).toHaveProperty("role"); - }); - - it("suporta quebra de linha Windows (\\r\\n)", () => { - const csv = "company,role\r\nNubank,Dev\r\nSpotify,SWE"; - expect(parseCSV(csv)).toHaveLength(2); - }); - - it("campos ausentes ficam como string vazia", () => { - const csv = `company,role,stage\nNubank,Dev`; - const result = parseCSV(csv); - expect(result[0].stage).toBe(""); - }); -}); - -// ── normalizeProcess ───────────────────────────────────────────────────────── - -describe("normalizeProcess", () => { - it("preserva campos válidos do processo ICC", () => { - const input = { - id: "test-id", - company: "Nubank", - role: "Senior FE", - stage: "interview", - origin: "inbound", - location: "Remoto", - salary: "R$ 25k", - recruiter: "Ana", - recruiterEmail: "ana@nu.com", - contactedDate: "2026-05-01", - notes: "Ótima vaga", - tags: ["react", "typescript"], - }; - const result = normalizeProcess(input); - expect(result.id).toBe("test-id"); - expect(result.company).toBe("Nubank"); - expect(result.role).toBe("Senior FE"); - expect(result.stage).toBe("interview"); - expect(result.origin).toBe("inbound"); - expect(result.tags).toEqual(["react", "typescript"]); - }); - - it("stage inválido cai para 'contacted'", () => { - expect(normalizeProcess({ company: "X", stage: "nao_existe" }).stage).toBe("contacted"); - }); - - it("origin 'outbound' preservado", () => { - expect(normalizeProcess({ company: "X", origin: "outbound" }).origin).toBe("outbound"); - }); - - it("origin desconhecido → 'inbound'", () => { - expect(normalizeProcess({ company: "X", origin: "qualquer" }).origin).toBe("inbound"); - }); - - it("fallback de company para 'Empresa?'", () => { - expect(normalizeProcess({}).company).toBe("Empresa?"); - }); - - it("fallback de role para 'Cargo?'", () => { - expect(normalizeProcess({}).role).toBe("Cargo?"); - }); - - it("aceita alias português 'empresa'", () => { - expect(normalizeProcess({ empresa: "Stone" }).company).toBe("Stone"); - }); - - it("aceita alias português 'cargo'", () => { - expect(normalizeProcess({ cargo: "Tech Lead" }).role).toBe("Tech Lead"); - }); - - it("tags como string separada por ; vira array", () => { - const result = normalizeProcess({ company: "X", tags: "react;node;typescript" }); - expect(result.tags).toEqual(["react", "node", "typescript"]); - }); - - it("tags array mantido como array", () => { - const result = normalizeProcess({ company: "X", tags: ["react", "node"] }); - expect(result.tags).toEqual(["react", "node"]); - }); - - it("sem tags → array vazio", () => { - expect(normalizeProcess({ company: "X" }).tags).toEqual([]); - }); - - it("gera id quando ausente", () => { - const result = normalizeProcess({ company: "X" }); - expect(typeof result.id).toBe("string"); - expect(result.id.length).toBeGreaterThan(0); - }); - - it("recruiterEmail aceita alias 'email'", () => { - const result = normalizeProcess({ company: "X", email: "teste@email.com" }); - expect(result.recruiterEmail).toBe("teste@email.com"); - }); - - it("convTitle null quando ausente", () => { - expect(normalizeProcess({ company: "X" }).convTitle).toBeNull(); - }); - - it("convTitle preservado quando presente", () => { - expect(normalizeProcess({ company: "X", convTitle: "Conversa ChatGPT" }).convTitle).toBe("Conversa ChatGPT"); - }); -}); diff --git a/src/components/auth/LoginScreen.jsx b/src/components/auth/LoginScreen.jsx index a6633ff..d28c392 100644 --- a/src/components/auth/LoginScreen.jsx +++ b/src/components/auth/LoginScreen.jsx @@ -4,24 +4,87 @@ import { T } from "../../constants/index.js"; import Ic from "../ui/Ic.jsx"; import Btn from "../ui/Btn.jsx"; +const STRINGS = { + en: { + tagline: "Command Center", + signIn: "Sign in", + signInSub: "Enter your email and password to access.", + emailLabel: "Email", + emailPlaceholder: "you@example.com", + passwordLabel: "Password", + forgotLink: "Forgot password?", + signingIn: "Signing in…", + signInBtn: "Sign in", + magicLink: "Sign in without password", + magicTitle: "Magic link", + magicSub: "We'll send a sign-in link to your email.", + sendLink: "Send link", + sending: "Sending…", + backToPassword: "Back to password login", + forgotTitle: "Reset password", + forgotSub: "We'll send you a link to create a new password.", + sendReset: "Send reset email", + sentMagicTitle: "Link sent!", + sentMagicSub: "Check your inbox at", + sentForgotTitle: "Email sent!", + sentForgotSub: "Check the recovery instructions at", + backToLogin: "Back to login", + demo: "Try a demo without signing up →", + invalidCreds: "Invalid email or password.", + langToggle: "Português", + }, + pt: { + tagline: "Command Center", + signIn: "Entrar", + signInSub: "Use seu e-mail e senha para acessar.", + emailLabel: "E-mail", + emailPlaceholder: "seu@email.com", + passwordLabel: "Senha", + forgotLink: "Esqueci minha senha", + signingIn: "Entrando…", + signInBtn: "Entrar", + magicLink: "Entrar sem senha (link mágico)", + magicTitle: "Link mágico", + magicSub: "Receba um link de acesso no seu e-mail.", + sendLink: "Enviar link", + sending: "Enviando…", + backToPassword: "Voltar ao login com senha", + forgotTitle: "Recuperar senha", + forgotSub: "Enviaremos um link para você criar uma nova senha.", + sendReset: "Enviar e-mail de recuperação", + sentMagicTitle: "Link enviado!", + sentMagicSub: "Verifique seu e-mail em", + sentForgotTitle: "E-mail enviado!", + sentForgotSub: "Verifique as instruções de recuperação em", + backToLogin: "Voltar ao login", + demo: "Ver demonstração sem cadastro →", + invalidCreds: "E-mail ou senha incorretos.", + langToggle: "English", + }, +}; + export function LoginScreen({ onDemo }) { - const [mode, setMode] = useState("password"); // "password" | "magic" | "forgot" - const [email, setEmail] = useState(""); + const [lang, setLang] = useState("en"); + const [mode, setMode] = useState("password"); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [sent, setSent] = useState(false); + const [sent, setSent] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(null); + + const s = STRINGS[lang]; + const switchMode = m => { setMode(m); setError(null); setSent(false); }; + const toggleLang = () => { setLang(l => l === "en" ? "pt" : "en"); setError(null); }; const inputFocus = e => { e.target.style.borderColor="var(--acc)"; e.target.style.boxShadow="0 0 0 3px var(--acc-d)"; }; const inputBlur = e => { e.target.style.borderColor="var(--border)"; e.target.style.boxShadow="none"; }; - const switchMode = m => { setMode(m); setError(null); setSent(false); }; async function handlePassword(e) { e.preventDefault(); setLoading(true); setError(null); const { error: err } = await supabase.auth.signInWithPassword({ email: email.trim(), password }); setLoading(false); - if (err) setError(err.message === "Invalid login credentials" ? "E-mail ou senha incorretos." : err.message); + if (err) setError(err.message === "Invalid login credentials" ? s.invalidCreds : err.message); } async function handleMagicLink(e) { @@ -47,16 +110,6 @@ export function LoginScreen({ onDemo }) { setSent(true); } - const Logo = () => ( -
-
- -
-
Interview OS
-
Command Center
-
- ); - const ErrorBox = () => error ? (
{error}
) : null; @@ -67,45 +120,71 @@ export function LoginScreen({ onDemo }) {
{title}
-
{subtitle} {email}
- ); return ( -
+
+ + {/* Language toggle — top right */} + +
- -
- {sent && mode === "magic" && } - {sent && mode === "forgot" && } + + {/* Logo */} +
+
+ +
+
Interview OS
+
{s.tagline}
+
+ + {/* Card */} +
+ + {sent && mode === "magic" && } + {sent && mode === "forgot" && } {!sent && mode === "password" && (
-
Entrar
-
Use seu e-mail e senha para acessar.
+
{s.signIn}
+
{s.signInSub}
- - setEmail(e.target.value)} placeholder="seu@email.com" style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/> + + setEmail(e.target.value)} placeholder={s.emailPlaceholder} style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/>
- + setPassword(e.target.value)} placeholder="••••••••" style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/>
-
- {loading ? "Entrando…" : "Entrar"} + {loading ? s.signingIn : s.signInBtn}
-
@@ -113,19 +192,19 @@ export function LoginScreen({ onDemo }) { {!sent && mode === "magic" && (
-
Link mágico
-
Receba um link de acesso no seu e-mail.
+
{s.magicTitle}
+
{s.magicSub}
- - setEmail(e.target.value)} placeholder="seu@email.com" style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/> + + setEmail(e.target.value)} placeholder={s.emailPlaceholder} style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/>
- {loading ? "Enviando…" : <>Enviar link} + {loading ? s.sending : <>{s.sendLink}}
-
@@ -133,30 +212,33 @@ export function LoginScreen({ onDemo }) { {!sent && mode === "forgot" && (
-
Recuperar senha
-
Enviaremos um link para você criar uma nova senha.
+
{s.forgotTitle}
+
{s.forgotSub}
- - setEmail(e.target.value)} placeholder="seu@email.com" style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/> + + setEmail(e.target.value)} placeholder={s.emailPlaceholder} style={{ ...T.input, fontSize:14 }} onFocus={inputFocus} onBlur={inputBlur}/>
- {loading ? "Enviando…" : <>Enviar e-mail de recuperação} + {loading ? s.sending : <>{s.sendReset}}
-
)}
-
diff --git a/src/components/modals/ImportChatGPTModal.jsx b/src/components/modals/ImportChatGPTModal.jsx deleted file mode 100644 index 9196c8b..0000000 --- a/src/components/modals/ImportChatGPTModal.jsx +++ /dev/null @@ -1,409 +0,0 @@ -import { useState, useRef } from "react"; -import JSZip from "jszip"; -import { STAGE } from "../../utils/constants.js"; -import { T } from "../../constants/index.js"; -import { callAI } from "../../lib/ai.js"; -import { supabase } from "../../supabase.js"; -import Ic from "../ui/Ic.jsx"; -import Btn from "../ui/Btn.jsx"; -import Badge from "../ui/Badge.jsx"; - -// ─── ChatGPT Import helpers ────────────────────────────────────────────────── -function extractMessagesFromConversation(conv) { - const mapping = conv.mapping || {}; - const msgs = []; - Object.values(mapping).forEach(node => { - const msg = node?.message; - if (!msg) return; - const role = msg.author?.role; - if (role !== "user" && role !== "assistant") return; - const parts = msg.content?.parts || []; - const text = parts.filter(p => typeof p === "string").join("\n").trim(); - if (text) msgs.push({ role, content: text, time: msg.create_time || 0 }); - }); - return msgs.sort((a, b) => a.time - b.time); -} - -function conversationText(conv) { - return extractMessagesFromConversation(conv) - .map(m => `[${m.role === "user" ? "Eu" : "ChatGPT"}]: ${m.content}`) - .join("\n\n"); -} - -function parseConversationsJson(raw) { - try { - const data = JSON.parse(raw); - return Array.isArray(data) ? data : []; - } catch { return []; } -} - -async function loadConversationsFromFile(file) { - const name = file.name.toLowerCase(); - if (name.endsWith(".json")) { - return parseConversationsJson(await file.text()); - } - if (name.endsWith(".zip")) { - const zip = await JSZip.loadAsync(file); - const jsonFile = zip.file("conversations.json"); - if (!jsonFile) throw new Error("conversations.json não encontrado no ZIP."); - return parseConversationsJson(await jsonFile.async("string")); - } - throw new Error("Formato não suportado. Use o arquivo .zip ou conversations.json."); -} - -function filterRecentConversations(convs, months = 2) { - const cutoff = Date.now() / 1000 - months * 30 * 24 * 3600; - return convs.filter(c => (c.update_time || c.create_time || 0) >= cutoff); -} - -function filterByProject(convs, projectId) { - if (!projectId) return convs; - return convs.filter(c => c.project_id === projectId); -} - -function getProjects(convs) { - const map = {}; - convs.forEach(c => { - if (c.project_id) map[c.project_id] = (map[c.project_id] || 0) + 1; - }); - return Object.entries(map).map(([id, count]) => ({ id, count })); -} - -function looksLikeRecruiterChat(conv) { - const title = (conv.title || "").toLowerCase(); - const keywords = ["recrutador","recruiter","vaga","job","oportunidade","opportunity","entrevista","interview","empresa","company","contratação","hiring","processo seletivo","selection process","tech lead","engineer","developer","desenvolvedor","linkedin","headhunter"]; - return keywords.some(k => title.includes(k)); -} - -export function ImportChatGPTModal({ onClose, onImport, isMobile, isDemo }) { - const [step, setStep] = useState("upload"); // upload | filter | processing | review | done - const [allConvs, setAllConvs] = useState([]); - const [projects, setProjects] = useState([]); - const [selectedProject, setSelectedProject] = useState("__all__"); - const [months, setMonths] = useState(2); - const [filtered, setFiltered] = useState([]); - const [selected, setSelected] = useState({}); - const [processing, setProcessing] = useState({ current: 0, total: 0, label: "" }); - const [extracted, setExtracted] = useState([]); - const [approved, setApproved] = useState({}); - const [error, setError] = useState(""); - const fileRef = useRef(); - - const handleFile = async (file) => { - setError(""); - try { - const convs = await loadConversationsFromFile(file); - if (!convs.length) throw new Error("Nenhuma conversa encontrada no arquivo."); - setAllConvs(convs); - setProjects(getProjects(convs)); - applyFilter(convs, "__all__", months); - setStep("filter"); - } catch(e) { setError(e.message); } - }; - - const applyFilter = (convs, projectId, m) => { - let result = filterRecentConversations(convs, m); - if (projectId && projectId !== "__all__") result = filterByProject(result, projectId); - const sel = {}; - result.forEach(c => { sel[c.id] = looksLikeRecruiterChat(c); }); - setFiltered(result); - setSelected(sel); - }; - - const onProjectChange = (pid) => { - setSelectedProject(pid); - applyFilter(allConvs, pid, months); - }; - - const onMonthsChange = (m) => { - setMonths(m); - applyFilter(allConvs, selectedProject, m); - }; - - const selectedCount = Object.values(selected).filter(Boolean).length; - - const processSelected = async () => { - const toProcess = filtered.filter(c => selected[c.id]); - if (!toProcess.length) return; - setStep("processing"); - setProcessing({ current: 0, total: toProcess.length, label: "" }); - - const results = []; - const { data: { session: s } } = await supabase.auth.getSession(); - const sys = `Você é um especialista em análise de conversas de recrutamento. Analise a conversa e retorne APENAS JSON válido, sem markdown nem explicações.`; - - for (let i = 0; i < toProcess.length; i++) { - const conv = toProcess[i]; - setProcessing({ current: i + 1, total: toProcess.length, label: conv.title || `Conversa ${i+1}` }); - const text = conversationText(conv); - if (!text.trim()) continue; - - const prompt = `Analise esta conversa e determine se é sobre um processo seletivo/recrutamento. - -CONVERSA: -${text.slice(0, 4000)} - -Se NÃO for sobre recrutamento, retorne: {"is_recruitment": false} -Se FOR sobre recrutamento, retorne este JSON: -{ - "is_recruitment": true, - "company": "nome da empresa (ou null)", - "role": "cargo/vaga (ou null)", - "recruiter": "nome do recrutador (ou null)", - "recruiterEmail": "email do recrutador (ou null)", - "stage": "contacted|screening|interview|technical|offer|rejected|archived", - "origin": "inbound|outbound", - "location": "cidade/remoto (ou null)", - "salary": "faixa salarial mencionada (ou null)", - "contactedDate": "YYYY-MM-DD da data de contato (ou null)", - "notes": "resumo em 2-3 linhas do que aconteceu nesta conversa", - "tags": ["array de tags relevantes ex: react, remoto, fintech"] -}`; - - try { - const reply = await callAI([{role:"user",content:prompt}], sys, s?.access_token); - const clean = reply.replace(/```json\n?|\n?```/g,"").trim(); - const parsed = JSON.parse(clean); - if (parsed.is_recruitment) { - results.push({ - ...parsed, - id: crypto.randomUUID(), - convTitle: conv.title || `Conversa ${i+1}`, - convDate: new Date((conv.update_time || conv.create_time || 0) * 1000).toISOString().split("T")[0], - }); - } - } catch { /* skip malformed */ } - } - - setExtracted(results); - const approvedMap = {}; - results.forEach(r => { approvedMap[r.id] = true; }); - setApproved(approvedMap); - setStep("review"); - }; - - const doImport = async () => { - const toImport = extracted.filter(r => approved[r.id]); - const now = new Date().toISOString().split("T")[0]; - const newProcesses = toImport.map(r => ({ - id: r.id, - company: r.company || "Empresa não identificada", - role: r.role || "Cargo não identificado", - stage: Object.keys(STAGE).includes(r.stage) ? r.stage : "contacted", - location: r.location || "", - salary: r.salary || "", - recruiter: r.recruiter || "", - recruiterEmail: r.recruiterEmail || "", - origin: r.origin === "outbound" ? "outbound" : "inbound", - contactedDate: r.contactedDate || r.convDate || now, - nextStepDate: null, - nextStepNote: "", - jobUrl: "", - tags: Array.isArray(r.tags) ? r.tags : [], - notes: r.notes || "", - steps: r.contactedDate ? [{ date: r.contactedDate || r.convDate || now, type: "contacted", note: "Importado do ChatGPT" }] : [], - aiContext: "", - starred: false, - })); - await onImport(newProcesses); - setStep("done"); - }; - - const approvedCount = Object.values(approved).filter(Boolean).length; - - return ( -
-
- {/* Header */} -
- {isMobile &&
} -
- -
-
-
Importar do ChatGPT
-
- {step==="upload" && "Selecione o arquivo exportado do ChatGPT"} - {step==="filter" && `${allConvs.length} conversas encontradas`} - {step==="processing" && `Analisando ${processing.current} de ${processing.total}...`} - {step==="review" && `${extracted.length} processo${extracted.length!==1?"s":""} detectado${extracted.length!==1?"s":""}`} - {step==="done" && "Importação concluída"} -
-
- -
- - {/* Steps indicator */} -
- {["upload","filter","processing","review"].map((s,i)=>( -
i ? "var(--acc)" : "var(--border)", transition:"background 0.3s" }}/> - ))} -
- - {/* Body */} -
- - {/* Upload */} - {step==="upload" && ( -
-
fileRef.current?.click()} - onDragOver={e=>e.preventDefault()} - onDrop={e=>{ e.preventDefault(); const f=e.dataTransfer.files[0]; if(f) handleFile(f); }} - style={{ border:"2px dashed var(--border-md)", borderRadius:14, padding:"40px 24px", textAlign:"center", cursor:"pointer", transition:"all 0.15s", background:"var(--bg-o)" }} - onMouseEnter={e=>{e.currentTarget.style.borderColor="var(--acc-b)";e.currentTarget.style.background="var(--acc-d)"}} - onMouseLeave={e=>{e.currentTarget.style.borderColor="var(--border-md)";e.currentTarget.style.background="var(--bg-o)"}} - > -
📁
-
Arraste o arquivo aqui ou clique para selecionar
-
Aceita o arquivo .zip exportado do ChatGPT ou conversations.json extraído
- { if(e.target.files[0]) handleFile(e.target.files[0]); }}/> -
- {error &&
{error}
} -
-
Como exportar do ChatGPT
- {["1. Abra o ChatGPT → clique no seu avatar (canto superior direito)", "2. Vá em Settings → Data Controls", "3. Clique em Export data → Confirm export", "4. Aguarde o email com o link de download (pode levar alguns minutos)", "5. Baixe o .zip e faça o upload aqui"].map((t,i)=>( -
- {i+1}.{t.slice(3)} -
- ))} -
-
- )} - - {/* Filter */} - {step==="filter" && ( -
-
-
-
Período
- -
- {projects.length > 0 && ( -
-
Projeto ChatGPT
- -
- )} -
-
- {selectedCount} de {filtered.length} conversas selecionadas para análise - {selectedCount > 0 && · Conversas com aparência de recrutamento foram pré-selecionadas} -
-
- {filtered.length === 0 ? ( -
Nenhuma conversa no período selecionado
- ) : filtered.map(conv=>{ - const date = new Date((conv.update_time||conv.create_time||0)*1000).toLocaleDateString("pt-BR",{day:"2-digit",month:"short"}); - const isRecruiter = looksLikeRecruiterChat(conv); - return ( - - ); - })} -
-
- - -
-
- )} - - {/* Processing */} - {step==="processing" && ( -
-
{[0,1,2].map(i=>)}
-
-
Analisando com IA...
-
{processing.label}
-
{processing.current} de {processing.total} conversas
-
-
-
-
-
- )} - - {/* Review */} - {step==="review" && ( -
- {extracted.length === 0 ? ( -
-
🔍
-
Nenhum processo detectado
-
As conversas analisadas não pareceram ser sobre processos seletivos. Tente selecionar mais conversas.
-
- ) : extracted.map(r=>( - - ))} -
- )} - - {/* Done */} - {step==="done" && ( -
-
-
Importação concluída!
-
{approvedCount} processo{approvedCount!==1?"s foram":"foi"} adicionado{approvedCount!==1?"s":""} ao seu pipeline.
- Ver pipeline -
- )} -
- - {/* Footer */} - {(step==="filter" || step==="review") && ( -
- step==="review"?setStep("filter"):setStep("upload")} size="sm"> - {step==="filter" && ( - - Analisar {selectedCount} conversa{selectedCount!==1?"s":""} - - )} - {step==="review" && ( - - {isDemo ? "Indisponível no modo demo" : `Importar ${approvedCount} processo${approvedCount!==1?"s":""}`} - - )} -
- )} -
-
- ); -} - -export default ImportChatGPTModal; diff --git a/src/components/modals/ImportModal.jsx b/src/components/modals/ImportModal.jsx deleted file mode 100644 index 6f1d802..0000000 --- a/src/components/modals/ImportModal.jsx +++ /dev/null @@ -1,460 +0,0 @@ -import { useState, useRef } from "react"; -import JSZip from "jszip"; -import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.mjs?url"; -import { T } from "../../constants/index.js"; -import { callAI } from "../../lib/ai.js"; -import { supabase } from "../../supabase.js"; -import { isChatGPTFormat, isICCFormat, looksLikeRecruitment, parseCSV, normalizeProcess } from "../../utils/importHelpers.js"; -import Ic from "../ui/Ic.jsx"; -import Btn from "../ui/Btn.jsx"; -import Badge from "../ui/Badge.jsx"; - -// ── ChatGPT conversation text extractor ─────────────────────────────────────── -function extractConvText(conv) { - const msgs = []; - Object.values(conv.mapping || {}).forEach(node => { - const msg = node?.message; - if (!msg) return; - const role = msg.author?.role; - if (role !== "user" && role !== "assistant") return; - const text = (msg.content?.parts || []).filter(p => typeof p === "string").join("\n").trim(); - if (text) msgs.push({ role, content: text, time: msg.create_time || 0 }); - }); - return msgs.sort((a, b) => a.time - b.time) - .map(m => `[${m.role === "user" ? "Eu" : "IA"}]: ${m.content}`) - .join("\n\n"); -} - -async function extractPdfText(file) { - let pdfjsLib; - try { - pdfjsLib = await import("pdfjs-dist"); - pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrl; - } catch { throw new Error("Falha ao carregar leitor de PDF."); } - const buffer = await file.arrayBuffer(); - const pdf = await pdfjsLib.getDocument({ data: buffer }).promise; - let text = ""; - for (let i = 1; i <= Math.min(pdf.numPages, 10); i++) { - const page = await pdf.getPage(i); - const content = await page.getTextContent(); - text += content.items.map(item => item.str).join(" ") + "\n"; - } - return text; -} - -const AI_SYS = `Você analisa textos e extrai informações sobre processos seletivos. Retorne APENAS JSON válido, sem markdown.`; - -function buildExtractPrompt(text) { - return `Analise o texto e extraia TODOS os processos seletivos mencionados. Retorne array JSON: -[{ - "company": "nome da empresa", - "role": "cargo", - "stage": "contacted|screening|interview|technical|offer|rejected|archived", - "origin": "inbound|outbound", - "location": "cidade ou remoto", - "salary": "faixa salarial ou null", - "recruiter": "nome do recrutador ou null", - "recruiterEmail": "email ou null", - "contactedDate": "YYYY-MM-DD ou null", - "notes": "resumo em 2-3 linhas", - "tags": [] -}] -Se não houver processos, retorne []. - -TEXTO: -${text.slice(0, 6000)}`; -} - -// ── Component ────────────────────────────────────────────────────────────────── -export function ImportModal({ onClose, onImport, isMobile, isDemo }) { - const [tab, setTab] = useState("file"); - const [step, setStep] = useState("upload"); - const [pasteText, setPasteText] = useState(""); - const [months, setMonths] = useState(2); - const [allConvs, setAllConvs] = useState([]); - const [selectedConvs, setSelectedConvs] = useState({}); - const [processing, setProcessing] = useState({ current: 0, total: 0, label: "" }); - const [extracted, setExtracted] = useState([]); - const [approved, setApproved] = useState({}); - const [error, setError] = useState(""); - const [sourceType, setSourceType] = useState(null); - const fileRef = useRef(); - - const now = new Date().toISOString().split("T")[0]; - - const setReview = (results) => { - setExtracted(results); - const ap = {}; - results.forEach(r => { ap[r.id] = true; }); - setApproved(ap); - setStep("review"); - }; - - const handleFile = async (file) => { - setError(""); - try { - const name = file.name.toLowerCase(); - - if (name.endsWith(".zip")) { - const zip = await JSZip.loadAsync(file); - const jsonFile = zip.file("conversations.json"); - if (!jsonFile) throw new Error("conversations.json não encontrado no ZIP."); - loadChatGPT(JSON.parse(await jsonFile.async("string"))); - return; - } - - if (name.endsWith(".json")) { - const data = JSON.parse(await file.text()); - if (isChatGPTFormat(data)) { loadChatGPT(data); return; } - if (isICCFormat(data)) { - setSourceType("icc-json"); - setReview((Array.isArray(data) ? data : []).map(normalizeProcess)); - return; - } - throw new Error("JSON não reconhecido. Use o formato ICC (array de processos com campo 'company') ou export do ChatGPT."); - } - - if (name.endsWith(".csv")) { - const rows = parseCSV(await file.text()); - if (!rows.length) throw new Error("Nenhuma linha válida encontrada no CSV."); - setSourceType("csv"); - setReview(rows.map(r => normalizeProcess({ ...r, id: crypto.randomUUID() }))); - return; - } - - if (name.endsWith(".pdf")) { - const text = await extractPdfText(file); - if (!text.trim()) throw new Error("Não foi possível extrair texto do PDF."); - await extractFromText(text); - return; - } - - throw new Error("Formato não suportado. Use .json, .zip, .csv ou .pdf"); - } catch (e) { setError(e.message); } - }; - - const loadChatGPT = (convs) => { - setSourceType("chatgpt"); - setAllConvs(convs); - const cutoff = Date.now() / 1000 - months * 30 * 24 * 3600; - const recent = convs.filter(c => (c.update_time || c.create_time || 0) >= cutoff); - const sel = {}; - recent.forEach(c => { sel[c.id] = looksLikeRecruitment(c); }); - setSelectedConvs(sel); - setStep("filter"); - }; - - const extractFromText = async (text) => { - setSourceType("text"); - setStep("processing"); - setProcessing({ current: 0, total: 1, label: "Analisando com IA..." }); - try { - const { data: { session: s } } = await supabase.auth.getSession(); - const reply = await callAI([{ role: "user", content: buildExtractPrompt(text) }], AI_SYS, s?.access_token); - const parsed = JSON.parse(reply.replace(/```json\n?|\n?```/g, "").trim()); - setReview((Array.isArray(parsed) ? parsed : []).map(r => normalizeProcess({ ...r, id: crypto.randomUUID() }))); - } catch (e) { - setError("Erro ao analisar o texto. Tente novamente."); - setStep("upload"); - } - }; - - const processChatGPT = async () => { - const cutoff = Date.now() / 1000 - months * 30 * 24 * 3600; - const filtered = allConvs.filter(c => (c.update_time || c.create_time || 0) >= cutoff); - const toProcess = filtered.filter(c => selectedConvs[c.id]); - if (!toProcess.length) return; - setStep("processing"); - const results = []; - const { data: { session: s } } = await supabase.auth.getSession(); - const sys = `Você analisa conversas de recrutamento. Retorne APENAS JSON válido, sem markdown.`; - for (let i = 0; i < toProcess.length; i++) { - const conv = toProcess[i]; - setProcessing({ current: i + 1, total: toProcess.length, label: conv.title || `Conversa ${i + 1}` }); - const text = extractConvText(conv); - if (!text.trim()) continue; - try { - const prompt = `Se NÃO for recrutamento: {"is_recruitment": false} -Se FOR: {"is_recruitment": true, "company":"...","role":"...","recruiter":"...","recruiterEmail":"...","stage":"contacted|screening|interview|technical|offer|rejected|archived","origin":"inbound|outbound","location":"...","salary":"...","contactedDate":"YYYY-MM-DD","notes":"resumo 2-3 linhas","tags":[]} - -CONVERSA:\n${text.slice(0, 4000)}`; - const reply = await callAI([{ role: "user", content: prompt }], sys, s?.access_token); - const parsed = JSON.parse(reply.replace(/```json\n?|\n?```/g, "").trim()); - if (parsed.is_recruitment) { - results.push(normalizeProcess({ - ...parsed, - id: crypto.randomUUID(), - convTitle: conv.title, - contactedDate: parsed.contactedDate || new Date((conv.update_time || conv.create_time || 0) * 1000).toISOString().split("T")[0], - })); - } - } catch { /* skip */ } - } - setReview(results); - }; - - const doImport = async () => { - const toImport = extracted.filter(r => approved[r.id]); - const newProcesses = toImport.map(r => ({ - id: r.id, - company: r.company, - role: r.role, - stage: r.stage, - location: r.location, - salary: r.salary, - recruiter: r.recruiter, - recruiterEmail: r.recruiterEmail, - origin: r.origin, - contactedDate: r.contactedDate, - nextStepDate: null, - nextStepNote: "", - jobUrl: "", - tags: r.tags, - notes: r.notes, - steps: [{ date: r.contactedDate, type: "contacted", note: "Importado" }], - aiContext: "", - starred: false, - channel: "", - })); - await onImport(newProcesses); - setStep("done"); - }; - - const approvedCount = Object.values(approved).filter(Boolean).length; - const filteredConvs = allConvs.filter(c => (c.update_time || c.create_time || 0) >= Date.now() / 1000 - months * 30 * 24 * 3600); - const selectedConvCount = Object.values(selectedConvs).filter(Boolean).length; - - const stepIndex = ["upload", "filter", "processing", "review", "done"].indexOf(step); - - return ( -
-
- - {/* Header */} -
- {isMobile &&
} -
- -
-
-
Importar processos
-
- {step==="upload" && "JSON · CSV · PDF · Colar texto"} - {step==="filter" && `${allConvs.length} conversas encontradas`} - {step==="processing" && `Analisando ${processing.current} de ${processing.total}...`} - {step==="review" && `${extracted.length} processo${extracted.length!==1?"s":""} detectado${extracted.length!==1?"s":""}`} - {step==="done" && "Importação concluída"} -
-
- -
- - {/* Progress bar */} -
- {[0,1,2,3].map(i => ( -
i ? "var(--acc)" : "var(--border)", transition:"background 0.3s" }}/> - ))} -
- - {/* Body */} -
- - {/* ── Upload ── */} - {step==="upload" && ( -
- {/* Tabs */} -
- {[{id:"file",label:"Arquivo"},{id:"text",label:"Colar texto"}].map(t => ( - - ))} -
- - {tab==="file" && ( - <> -
fileRef.current?.click()} - onDragOver={e=>e.preventDefault()} - onDrop={e=>{ e.preventDefault(); const f=e.dataTransfer.files[0]; if(f) handleFile(f); }} - style={{ border:"2px dashed var(--border-md)", borderRadius:14, padding:"36px 24px", textAlign:"center", cursor:"pointer", background:"var(--bg-o)", transition:"all 0.15s" }} - onMouseEnter={e=>{e.currentTarget.style.borderColor="var(--acc-b)";e.currentTarget.style.background="var(--acc-d)"}} - onMouseLeave={e=>{e.currentTarget.style.borderColor="var(--border-md)";e.currentTarget.style.background="var(--bg-o)"}} - > -
📂
-
Arraste ou clique para selecionar
-
- JSON · CSV · PDF · ZIP -
- { if(e.target.files[0]) handleFile(e.target.files[0]); }}/> -
-
-
Formatos aceitos
- {[ - {fmt:"JSON",desc:'Array de processos com campo "company" (formato ICC) ou export do ChatGPT'}, - {fmt:"CSV",desc:"Colunas: company, role, stage, location, salary, recruiter, notes, tags"}, - {fmt:"PDF",desc:"Conversa ou proposta — IA extrai os dados automaticamente"}, - {fmt:"ZIP",desc:"Arquivo de export completo do ChatGPT (contém conversations.json)"}, - ].map(({fmt,desc})=>( -
- {fmt} - {desc} -
- ))} -
- - )} - - {tab==="text" && ( - <> -
Cole a conversa ou descreva o processo
-