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
-
onMonthsChange(Number(e.target.value))} style={{ ...T.input, cursor:"pointer" }}>
- Último mês
- Últimos 2 meses
- Últimos 3 meses
- Últimos 6 meses
- Último ano
- Tudo
-
-
- {projects.length > 0 && (
-
-
Projeto ChatGPT
-
onProjectChange(e.target.value)} style={{ ...T.input, cursor:"pointer" }}>
- Todos os projetos
- {projects.map(p=>Projeto ({p.count} chats) )}
-
-
- )}
-
-
- {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 (
-
- setSelected(s=>({...s,[conv.id]:e.target.checked}))} style={{ width:15, height:15, accentColor:"var(--acc)", cursor:"pointer", flexShrink:0 }}/>
-
-
{conv.title||"Sem título"}
-
-
- {isRecruiter && recrutamento }
- {date}
-
-
- );
- })}
-
-
- { const s={}; filtered.forEach(c=>s[c.id]=true); setSelected(s); }} style={{ fontSize:11, color:"var(--acc-text)", background:"none", border:"none", cursor:"pointer" }}>Selecionar tudo
- setSelected({})} style={{ fontSize:11, color:"var(--t3)", background:"none", border:"none", cursor:"pointer" }}>Desmarcar tudo
-
-
- )}
-
- {/* 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=>(
-
- setApproved(a=>({...a,[r.id]:e.target.checked}))} style={{ width:16, height:16, accentColor:"var(--acc)", cursor:"pointer", flexShrink:0, marginTop:2 }}/>
-
-
- {r.company||"Empresa?"}
- ·
- {r.role||"Cargo?"}
-
-
-
{r.convTitle}
- {r.notes &&
{r.notes}
}
- {r.tags?.length > 0 && (
-
- {r.tags.map(t=>{t} )}
-
- )}
-
-
- ))}
-
- )}
-
- {/* 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 => (
- setTab(t.id)} style={{ flex:1, padding:"8px", borderRadius:8, border:"none", background:tab===t.id?"var(--bg-r)":"transparent", color:tab===t.id?"var(--t1)":"var(--t3)", cursor:"pointer", fontSize:13, fontFamily:"'Outfit',sans-serif", fontWeight:tab===t.id?600:400, transition:"all 0.15s", boxShadow:tab===t.id?"0 1px 3px rgba(0,0,0,0.15)":"none" }}>
- {t.label}
-
- ))}
-
-
- {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
-
- )}
-
- {/* ── Filter (ChatGPT) ── */}
- {step==="filter" && (
-
-
-
-
Período
-
setMonths(Number(e.target.value))} style={{ ...T.input, cursor:"pointer" }}>
- Último mês
- Últimos 2 meses
- Últimos 3 meses
- Últimos 6 meses
- Último ano
- Tudo
-
-
-
-
- {selectedConvCount} de {filteredConvs.length} conversas selecionadas
-
-
- {filteredConvs.map(conv => {
- const date = new Date((conv.update_time||conv.create_time||0)*1000).toLocaleDateString("pt-BR",{day:"2-digit",month:"short"});
- const isRec = looksLikeRecruitment(conv);
- return (
-
- setSelectedConvs(s=>({...s,[conv.id]:e.target.checked}))} style={{ width:15, height:15, accentColor:"var(--acc)", cursor:"pointer", flexShrink:0 }}/>
- {conv.title||"Sem título"}
-
- {isRec && recrutamento }
- {date}
-
-
- );
- })}
-
-
- { const s={}; filteredConvs.forEach(c=>s[c.id]=true); setSelectedConvs(s); }} style={{ fontSize:11, color:"var(--acc-text)", background:"none", border:"none", cursor:"pointer" }}>Selecionar tudo
- setSelectedConvs({})} style={{ fontSize:11, color:"var(--t3)", background:"none", border:"none", cursor:"pointer" }}>Nenhum
-
-
- )}
-
- {/* ── Processing ── */}
- {step==="processing" && (
-
-
- {[0,1,2].map(i=> )}
-
-
-
Analisando com IA...
-
{processing.label}
- {processing.total > 1 &&
{processing.current} de {processing.total}
}
-
- {processing.total > 1 && (
-
- )}
-
- )}
-
- {/* ── Review ── */}
- {step==="review" && (
-
- {extracted.length === 0 ? (
-
-
🔍
-
Nenhum processo detectado
-
Tente com outro arquivo ou um texto mais detalhado.
-
- ) : extracted.map(r => (
-
- setApproved(a=>({...a,[r.id]:e.target.checked}))} style={{ width:16, height:16, accentColor:"var(--acc)", cursor:"pointer", flexShrink:0, marginTop:2 }}/>
-
-
- {r.company}
- ·
- {r.role}
-
-
- {r.convTitle &&
{r.convTitle}
}
- {r.notes &&
{r.notes}
}
- {r.tags?.length > 0 && (
-
- {r.tags.map(t => {t} )}
-
- )}
-
-
- ))}
-
- )}
-
- {/* ── Done ── */}
- {step==="done" && (
-
-
-
-
-
Importação concluída!
-
{approvedCount} processo{approvedCount!==1?"s foram":" foi"} adicionado{approvedCount!==1?"s":""} ao pipeline.
-
Ver pipeline
-
- )}
-
-
- {/* Footer */}
- {(step==="filter" || step==="review") && (
-
- step==="review"?setStep(sourceType==="chatgpt"?"filter":"upload"):setStep("upload")} size="sm">
-
-
- {step==="filter" && (
-
- Analisar {selectedConvCount} conversa{selectedConvCount!==1?"s":""}
-
- )}
- {step==="review" && (
-
- {isDemo ? "Indisponível no modo demo" : `Importar ${approvedCount} processo${approvedCount!==1?"s":""}`}
-
- )}
-
- )}
-
-
- );
-}
-
-export default ImportModal;
diff --git a/src/components/modals/NewProcessModal.jsx b/src/components/modals/NewProcessModal.jsx
deleted file mode 100644
index 819fba8..0000000
--- a/src/components/modals/NewProcessModal.jsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useState } from "react";
-import { STAGE, ACTIVE_STAGES } from "../../utils/constants.js";
-import { CONTACT_CHANNELS, T } from "../../constants/index.js";
-import Ic from "../ui/Ic.jsx";
-import Btn from "../ui/Btn.jsx";
-
-export function NewProcessModal({ onClose, onSave, isMobile }) {
- const [form, setForm] = useState({ company:"", role:"", stage:"contacted", location:"", salary:"", recruiter:"", recruiterEmail:"", jobUrl:"", nextStepNote:"", nextStepDate:"", tags:"", notes:"", origin:"inbound", channel:"" });
- const [saving, setSaving] = useState(false);
- const F = (k,v) => setForm(f=>({...f,[k]:v}));
-
- const save = async () => {
- if (!form.company||!form.role||saving) return;
- setSaving(true);
- await onSave({
- ...form,
- id: crypto.randomUUID(),
- contactedDate: new Date().toISOString().split("T")[0],
- tags: form.tags.split(",").map(t=>t.trim()).filter(Boolean),
- steps: [{ date:new Date().toISOString().split("T")[0], type:form.stage, note:form.origin==="inbound"?"Recrutador entrou em contato":"Aplicação enviada" }],
- aiContext: "",
- starred: false,
- });
- onClose();
- };
-
- const fields = [["Empresa *","company"],["Cargo *","role"],["Localização","location"],["Salário / range","salary"],["Recrutador(a)","recruiter"],["E-mail / contato","recruiterEmail"],["Link da vaga","jobUrl"],["Próximo passo","nextStepNote"]];
-
- return (
-
-
- {isMobile &&
}
-
-
Novo Processo
-
-
-
-
Como surgiu esta oportunidade?
-
- {[
- { value:"inbound", label:"Fui contactado", sub:"recrutador veio até mim", icon:"msg", activeBg:"var(--cyan-d)", activeBorder:"var(--cyan-b)", activeColor:"var(--cyan)" },
- { value:"outbound", label:"Me candidatei", sub:"apliquei na vaga", icon:"send", activeBg:"var(--acc-d)", activeBorder:"var(--acc-b)", activeColor:"var(--acc)" },
- ].map((opt,i)=>(
- F("origin",opt.value)} style={{ flex:1, padding:"13px 10px", border:"none", cursor:"pointer", background:form.origin===opt.value?opt.activeBg:"var(--bg-o)", borderRight:i===0?"1px solid var(--border)":"none", transition:"background 0.15s", display:"flex", flexDirection:"column", alignItems:"center", gap:4 }}>
-
- {opt.label}
- {opt.sub}
-
- ))}
-
-
- {form.origin==="inbound" && (
-
-
Canal de contato
-
- {CONTACT_CHANNELS.map(ch=>{
- const on = form.channel===ch.value;
- return (
- F("channel",on?"":ch.value)} style={{ display:"flex", alignItems:"center", gap:5, padding:"6px 12px", borderRadius:999, cursor:"pointer", border:`1px solid ${on?"var(--acc-b)":"var(--border)"}`, background:on?"var(--acc-d)":"transparent", color:on?"var(--acc)":"var(--t3)", fontSize:12, fontWeight:on?600:400, fontFamily:"'Outfit',sans-serif", transition:"all 0.15s" }}>
-
- {ch.label}
-
- );
- })}
-
-
- )}
-
- {fields.map(([label,field])=>(
-
- {label}
- F(field,e.target.value)} style={{ ...T.input, boxSizing:"border-box" }}/>
-
- ))}
-
- Data próxima etapa
- F("nextStepDate",e.target.value)} style={{ ...T.input, boxSizing:"border-box" }}/>
-
-
- Estágio atual
- F("stage",e.target.value)} style={{ width:"100%", padding:"10px 14px", borderRadius:10, border:"1.5px solid var(--border)", background:"var(--bg-o)", color:"var(--t1)", fontSize:14, outline:"none", fontFamily:"'Outfit',sans-serif" }}>
- {Object.entries(STAGE).filter(([k])=>!["rejected","archived"].includes(k)).map(([k,v])=>{v.label} )}
-
-
-
- Tags (separadas por vírgula)
- F("tags",e.target.value)} placeholder="React, Remoto, FinTech" style={{ ...T.input, boxSizing:"border-box" }}/>
-
-
- Notas iniciais
-
-
-
-
- {saving ? "Salvando..." : "Adicionar Processo"}
-
- Cancelar
-
-
-
- );
-}
-
-export default NewProcessModal;
diff --git a/src/components/modals/RecruiterMessageModal.jsx b/src/components/modals/RecruiterMessageModal.jsx
deleted file mode 100644
index 28afe53..0000000
--- a/src/components/modals/RecruiterMessageModal.jsx
+++ /dev/null
@@ -1,317 +0,0 @@
-import { useState } from "react";
-import { callAI } from "../../lib/ai.js";
-import { supabase } from "../../supabase.js";
-import { T } from "../../constants/index.js";
-import Ic from "../ui/Ic.jsx";
-import Btn from "../ui/Btn.jsx";
-
-const EXTRACTION_SYSTEM = `Você é um assistente especializado em analisar mensagens de recrutadores de tecnologia.
-Extraia as informações estruturadas da mensagem e retorne EXATAMENTE este JSON (sem markdown, sem explicações):
-{
- "recruiter": "nome do recrutador ou vazio",
- "recruiterRole": "cargo do recrutador ou vazio",
- "company": "nome da empresa ou vazio",
- "role": "título do cargo da vaga ou vazio",
- "stack": "tecnologias mencionadas, separadas por vírgula, ou vazio",
- "regime": "remoto/híbrido/presencial ou vazio",
- "salary": "faixa salarial mencionada ou vazio",
- "nextStep": "próximo passo sugerido pelo recrutador ou vazio",
- "location": "cidade/estado ou vazio"
-}`;
-
-const DRAFT_SYSTEM = `Você é Fernando, Senior Full-Stack Engineer / Front-End Tech Lead com 10+ anos de experiência (React, Next.js, Node.js, TypeScript, Supabase). Você foi contactado por um recrutador no LinkedIn.
-Escreva UMA resposta inicial na primeira pessoa, como se fosse você mesmo digitando agora.
-Regras:
-- Confirme o interesse de forma direta (não exagerada)
-- Faça UMA pergunta estratégica sobre a vaga (stack, modelo de trabalho ou próximo passo)
-- Máximo 3 parágrafos curtos, tom profissional mas humano
-- Sem saudações genéricas ("Espero que esteja bem"), sem "Atenciosamente"
-- Não mencione IA
-- Responda SEMPRE no mesmo idioma da mensagem do recrutador
-Responda SOMENTE com o texto da mensagem, sem introdução, sem aspas, sem explicações.`;
-
-export function RecruiterMessageModal({ onClose, onProcessCreated, initialMsg = "" }) {
- const [step, setStep] = useState("paste"); // paste | working | result
- const [msg, setMsg] = useState(initialMsg);
- const [extracted, setExtracted] = useState(null);
- const [draft, setDraft] = useState("");
- const [draftLoading, setDraftLoading] = useState(false);
- const [copied, setCopied] = useState(false);
- const [error, setError] = useState("");
- const [saved, setSaved] = useState(false);
-
- const run = async () => {
- if (!msg.trim()) return;
- setStep("working");
- setError("");
-
- let token;
- try {
- const { data } = await supabase.auth.getSession();
- token = data?.session?.access_token;
- } catch {
- setError("Erro de autenticação. Recarregue a página.");
- setStep("paste");
- return;
- }
-
- // Extract structured info from the message
- let ext;
- try {
- const raw = await callAI(
- [{ role: "user", content: msg }],
- EXTRACTION_SYSTEM,
- token
- );
- const parsed = JSON.parse(raw.replace(/```json|```/g, "").trim());
- ext = {
- recruiter: parsed.recruiter || "",
- recruiterRole: parsed.recruiterRole || "",
- company: parsed.company || "",
- role: parsed.role || "",
- stack: parsed.stack || "",
- regime: parsed.regime || "",
- salary: parsed.salary || "",
- nextStep: parsed.nextStep || "",
- location: parsed.location || "",
- };
- setExtracted(ext);
- } catch {
- setError("Não foi possível extrair as informações. Verifique a mensagem e tente novamente.");
- setStep("paste");
- return;
- }
-
- // Show result immediately, generate draft in parallel
- setStep("result");
- setDraftLoading(true);
- try {
- const userMsg = `Recrutador: ${ext.recruiter || "recrutador"}${ext.recruiterRole ? ` (${ext.recruiterRole})` : ""}
-Empresa: ${ext.company || "empresa"} | Cargo: ${ext.role || "cargo"} | Regime: ${ext.regime || "—"} | Salário: ${ext.salary || "—"}
-Mensagem do recrutador:
-"${msg}"`;
- const draftText = await callAI(
- [{ role: "user", content: userMsg }],
- DRAFT_SYSTEM,
- token
- );
- setDraft(draftText.trim());
- } catch {
- setDraft("");
- } finally {
- setDraftLoading(false);
- }
- };
-
- const handleKeyDown = (e) => {
- if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && msg.trim()) run();
- };
-
- const save = () => {
- if (saved) { onClose(); return; }
- const today = new Date().toISOString().slice(0, 10);
- const tags = extracted?.stack
- ? extracted.stack.split(",").map(s => s.trim()).filter(Boolean)
- : [];
- const process = {
- id: crypto.randomUUID(),
- company: extracted?.company || "Empresa?",
- role: extracted?.role || "Cargo?",
- stage: "contacted",
- origin: "inbound",
- channel: "linkedin",
- location: extracted?.location || "",
- salary: extracted?.salary || "",
- recruiter: extracted?.recruiter || "",
- recruiterEmail: "",
- contactedDate: today,
- nextStepDate: null,
- nextStepNote: extracted?.nextStep || "",
- jobUrl: "",
- tags,
- notes: `Mensagem original:\n${msg}`,
- steps: [{ date: today, type: "contacted", note: "Contactado via LinkedIn" }],
- aiContext: "",
- starred: false,
- };
- onProcessCreated(process);
- setSaved(true);
- };
-
- const copy = async () => {
- if (!draft) return;
- await navigator.clipboard.writeText(draft);
- setCopied(true);
- setTimeout(() => setCopied(false), 2500);
- };
-
- const overlay = {
- position: "fixed", inset: 0, background: "rgba(0,0,0,0.6)",
- display: "flex", alignItems: "center", justifyContent: "center",
- zIndex: 1000, padding: 16,
- };
- const modal = {
- background: "var(--bg-r)", borderRadius: 16,
- border: "1px solid var(--border-md)", width: "100%", maxWidth: 520,
- maxHeight: "92vh", overflow: "hidden", display: "flex", flexDirection: "column",
- boxShadow: "0 24px 60px rgba(0,0,0,0.35)",
- };
- const hdr = {
- display: "flex", alignItems: "center", justifyContent: "space-between",
- padding: "16px 20px", borderBottom: "1px solid var(--border)", flexShrink: 0,
- };
- const body = {
- flex: 1, overflowY: "auto", padding: "20px", display: "flex", flexDirection: "column", gap: 14,
- };
- const ftr = {
- padding: "14px 20px", borderTop: "1px solid var(--border)",
- display: "flex", gap: 8, justifyContent: "space-between", alignItems: "center", flexShrink: 0,
- };
-
- return (
-
e.target === e.currentTarget && onClose()}>
-
- {/* Header */}
-
-
-
-
-
-
- {step === "paste" && "Mensagem do LinkedIn"}
- {step === "working" && "Analisando…"}
- {step === "result" && "Pronto para responder"}
-
-
-
-
-
-
-
- {/* ── STEP: paste ────────────────────────────────────────── */}
- {step === "paste" && (
- <>
-
-
- Cole a mensagem recebida no LinkedIn. A IA extrai empresa, cargo e stack — e gera uma resposta pronta para copiar.
-
-
-
-
- {msg.trim() ? `${msg.trim().split(/\s+/).length} palavras` : "Ctrl+Enter para analisar"}
-
-
- Analisar mensagem
-
-
- >
- )}
-
- {/* ── STEP: working ──────────────────────────────────────── */}
- {step === "working" && (
-
-
-
-
Extraindo informações…
-
-
-
- )}
-
- {/* ── STEP: result ───────────────────────────────────────── */}
- {step === "result" && extracted && (
- <>
-
- {/* 4 key fields — 2×2 grid */}
-
- {[
- { key: "company", label: "Empresa" },
- { key: "role", label: "Cargo" },
- { key: "salary", label: "Salário" },
- { key: "regime", label: "Regime" },
- ].map(({ key, label }) => (
-
- {label}
- setExtracted(prev => ({ ...prev, [key]: e.target.value }))}
- placeholder={`${label}…`}
- style={{ ...T.input, fontSize: 13 }}
- data-testid={`field-${key}`}
- />
-
- ))}
-
-
- {/* Draft */}
-
-
-
Resposta para o LinkedIn
- {draftLoading && (
-
- )}
-
- {draftLoading ? (
-
- Gerando resposta…
-
- ) : draft ? (
-
- ) : (
-
- Não foi possível gerar a resposta. Use a aba Mensagens no processo.
-
- )}
-
-
-
-
-
{ setStep("paste"); setDraft(""); setExtracted(null); setSaved(false); }}
- style={{ background: "none", border: "none", cursor: "pointer", color: "var(--t3)", fontSize: 12, fontFamily: "'Outfit',sans-serif", display: "flex", alignItems: "center", gap: 5, padding: "6px 8px", borderRadius: 8 }}
- >
- Voltar
-
-
- {draft && !draftLoading && (
-
-
- {copied ? "Copiado!" : "Copiar"}
-
- )}
-
-
- {saved ? "Abrir processo" : "Salvar"}
-
-
-
- >
- )}
-
-
- );
-}
diff --git a/src/components/process/InlineTags.jsx b/src/components/process/InlineTags.jsx
index 011cca6..3be6df8 100644
--- a/src/components/process/InlineTags.jsx
+++ b/src/components/process/InlineTags.jsx
@@ -26,7 +26,7 @@ export function InlineTags({ process, onUpdate }) {
onKeyDown={e=>{ if(e.key==="Enter"){e.preventDefault();addTag();} }}
onBlur={addTag}
placeholder="+ tag"
- style={{ padding:"3px 10px", borderRadius:6, border:"1px dashed var(--border-md)", background:"transparent", color:"var(--t2)", fontSize:12, fontFamily:"'JetBrains Mono',monospace", outline:"none", width:64, textAlign:"center" }}
+ style={{ padding:"3px 8px", borderRadius:6, border:"1px dashed var(--border-md)", background:"transparent", color:"var(--t2)", fontSize:11, fontFamily:"'JetBrains Mono',monospace", outline:"none", width:60, textAlign:"center", minWidth:0, boxSizing:"border-box" }}
/>
);
diff --git a/src/components/tabs/VagaTab.jsx b/src/components/tabs/VagaTab.jsx
index 430adfb..c277d96 100644
--- a/src/components/tabs/VagaTab.jsx
+++ b/src/components/tabs/VagaTab.jsx
@@ -66,7 +66,38 @@ function DatePicker({ value, onChange, diff, urgent, soon }) {
);
}
+function Chevron({ open }) {
+ return (
+
+
+
+ );
+}
+
+function AccordionCard({ icon, title, badge, open, onToggle, children }) {
+ return (
+
+
+
+ {title}
+ {badge}
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+ );
+}
+
export function VagaTab({ process, onUpdate, onDelete, isMobile }) {
+ const [open, setOpen] = useState({ next: true, vaga: true, notes: true });
const [editingField, setEditingField] = useState(null);
const [drafts, setDrafts] = useState({});
const [meetingType, setMeetingType] = useState(() => {
@@ -85,6 +116,8 @@ export function VagaTab({ process, onUpdate, onDelete, isMobile }) {
const urgent = diff !== null && diff >= 0 && diff <= 2;
const soon = diff !== null && diff >= 0 && diff <= 7 && !urgent;
+ const toggle = (key) => setOpen(prev => ({ ...prev, [key]: !prev[key] }));
+
const saveField = (field, value) => {
setEditingField(null);
setDrafts(prev => { const n = {...prev}; delete n[field]; return n; });
@@ -144,136 +177,112 @@ export function VagaTab({ process, onUpdate, onDelete, isMobile }) {
const col2 = isMobile ? "1fr" : "1fr 1fr";
const col3 = isMobile ? "1fr 1fr" : "1fr 1fr 1fr";
- const sectionStyle = {
- borderRadius:12, border:"1px solid var(--border)", background:"var(--bg-o)",
- };
- const sectionHeader = {
- padding:"10px 14px", borderBottom:"1px solid var(--border)",
- display:"flex", alignItems:"center", gap:6,
- };
- const sectionBody = {
- padding: isMobile ? "12px 12px" : "14px 16px",
- display:"flex", flexDirection:"column", gap:12,
- };
+ const nextBadge = urgent ? (
+
URGENTE
+ ) : soon ? (
+
EM BREVE
+ ) : process.nextStepDate ? (
+
{fmtDate(process.nextStepDate)}
+ ) : null;
return (
-
+
{/* ── Próxima etapa ─────────────────────────────────────── */}
-
-
-
-
Próxima etapa
- {urgent &&
URGENTE }
- {soon &&
EM BREVE }
+
toggle("next")}>
+ onUpdate({...process,nextStepDate:val||null})} diff={diff} urgent={urgent} soon={soon} />
+
+
Tipo de etapa
+
+ {MEETING_TYPES.map(mt => {
+ const on = meetingType === mt.id;
+ return (
+ handleMeetingType(mt.id)} style={{ padding:"5px 12px", borderRadius:20, border:`1px solid ${on?"var(--acc-b)":"var(--border)"}`, background:on?"var(--acc-d)":"transparent", color:on?"var(--acc-text)":"var(--t3)", fontSize:12, cursor:"pointer", fontFamily:"'Outfit',sans-serif", fontWeight:on?600:400, transition:"all 0.15s" }}>
+ {mt.label}
+
+ );
+ })}
+
+
+
+
Descrição / instrução
+
onUpdate({...process,nextStepNote:e.target.value})}
+ placeholder="Ex: Entrevista técnica com o time de plataforma" style={{ ...T.input }} />
+
+
+
+ {/* ── Dados da vaga ─────────────────────────────────────── */}
+
toggle("vaga")}>
+
+
+
+
+
+
+
+
+
Origem
+
+ {[{value:"inbound",label:"Inbound"},{value:"outbound",label:"Outbound"}].map(opt => (
+ onUpdate({...process,origin:opt.value})}
+ style={{ padding:"4px 10px", borderRadius:20, border:`1px solid ${(process.origin||"inbound")===opt.value?"var(--acc-b)":"var(--border)"}`, background:(process.origin||"inbound")===opt.value?"var(--acc-d)":"transparent", color:(process.origin||"inbound")===opt.value?"var(--acc-text)":"var(--t3)", fontSize:11, cursor:"pointer", fontFamily:"'Outfit',sans-serif", transition:"all 0.15s" }}>
+ {opt.label}
+
+ ))}
+
+
-
-
onUpdate({...process,nextStepDate:val||null})} diff={diff} urgent={urgent} soon={soon} />
+ {(process.origin||"inbound")==="inbound" && (
-
Tipo de etapa
+
Canal
- {MEETING_TYPES.map(mt => {
- const on = meetingType === mt.id;
+ {CONTACT_CHANNELS.map(ch => {
+ const on = (process.channel||"")===ch.value;
return (
- handleMeetingType(mt.id)} style={{ padding:"5px 12px", borderRadius:20, border:`1px solid ${on?"var(--acc-b)":"var(--border)"}`, background:on?"var(--acc-d)":"transparent", color:on?"var(--acc-text)":"var(--t3)", fontSize:12, cursor:"pointer", fontFamily:"'Outfit',sans-serif", fontWeight:on?600:400, transition:"all 0.15s" }}>
- {mt.label}
+ onUpdate({...process,channel:on?"":ch.value})}
+ style={{ display:"flex", alignItems:"center", gap:4, padding:"4px 10px", borderRadius:20, border:`1px solid ${on?"var(--acc-b)":"var(--border)"}`, background:on?"var(--acc-d)":"transparent", color:on?"var(--acc-text)":"var(--t3)", fontSize:11, cursor:"pointer", fontFamily:"'Outfit',sans-serif", transition:"all 0.15s" }}>
+ {ch.label}
);
})}
-
-
Descrição / instrução
-
onUpdate({...process,nextStepNote:e.target.value})}
- placeholder="Ex: Entrevista técnica com o time de plataforma" style={{ ...T.input }} />
-
-
-
-
- {/* ── Dados da vaga ─────────────────────────────────────── */}
-
-
-
- Dados da vaga
-
-
+ )}
+
-
-
-
-
-
-
-
-
Origem
-
- {[{value:"inbound",label:"Inbound"},{value:"outbound",label:"Outbound"}].map(opt => (
- onUpdate({...process,origin:opt.value})}
- style={{ padding:"4px 10px", borderRadius:20, border:`1px solid ${(process.origin||"inbound")===opt.value?"var(--acc-b)":"var(--border)"}`, background:(process.origin||"inbound")===opt.value?"var(--acc-d)":"transparent", color:(process.origin||"inbound")===opt.value?"var(--acc-text)":"var(--t3)", fontSize:11, cursor:"pointer", fontFamily:"'Outfit',sans-serif", transition:"all 0.15s" }}>
- {opt.label}
-
- ))}
-
-
+
+
- {(process.origin||"inbound")==="inbound" && (
-
-
Canal
-
- {CONTACT_CHANNELS.map(ch => {
- const on = (process.channel||"")===ch.value;
- return (
- onUpdate({...process,channel:on?"":ch.value})}
- style={{ display:"flex", alignItems:"center", gap:4, padding:"4px 10px", borderRadius:20, border:`1px solid ${on?"var(--acc-b)":"var(--border)"}`, background:on?"var(--acc-d)":"transparent", color:on?"var(--acc-text)":"var(--t3)", fontSize:11, cursor:"pointer", fontFamily:"'Outfit',sans-serif", transition:"all 0.15s" }}>
- {ch.label}
-
- );
- })}
-
-
+
+ {process.jobUrl && /^https?:\/\//i.test(process.jobUrl) && (
+
+ Abrir vaga →
+
)}
-
-
-
-
-
-
- {process.jobUrl && /^https?:\/\//i.test(process.jobUrl) && (
-
- Abrir vaga →
-
- )}
-
-
-
+
+
{/* ── Anotações ──────────────────────────────────────────── */}
-
-
-
- Anotações
-
-
- {contextMsg && (
-
-
Contexto inicial
-
- {contextMsg.slice(0,200)}{contextMsg.length>200?"…":""}
-
+
toggle("notes")}>
+ {contextMsg && (
+
+
Contexto inicial
+
+ {contextMsg.slice(0,200)}{contextMsg.length>200?"…":""}
- )}
-
-
+
+ )}
+