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/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
-