import React, { useState, useEffect } from 'react'; import * as XLSX from 'xlsx'; import { useAuth } from '../contexts/AuthContext'; import { Button } from '../components/Button'; import { Upload, FileText, CheckCircle, AlertTriangle, Calendar, Database, UserPlus } from 'lucide-react'; const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; type ImportType = 'fot' | 'agenda' | 'profissionais'; interface ImportFotInput { fot: string; empresa_nome: string; curso_nome: string; ano_formatura_label: string; instituicao: string; cidade: string; estado: string; observacoes: string; gastos_captacao: number; pre_venda: boolean; } interface ImportAgendaInput { fot: string; data: string; tipo_evento: string; observacoes: string; local: string; endereco: string; horario: string; qtd_formandos: number; qtd_fotografos: number; qtd_cinegrafistas: number; qtd_recepcionistas: number; qtd_estudios: number; qtd_ponto_foto: number; qtd_ponto_id: number; qtd_ponto_decorado: number; qtd_pontos_led: number; qtd_plataforma_360: number; foto_faltante: number; recep_faltante: number; cine_faltante: number; logistica_observacoes: string; pre_venda: boolean; } interface ImportProfissionalInput { id?: string; // Optional for validation display nome: string; funcao_profissional_id: string; // Must resolve to ID endereco: string; cidade: string; uf: string; whatsapp: string; cpf_cnpj_titular: string; banco: string; agencia: string; conta_pix: string; observacao: string; email: string; // Sometimes in PIX column // Add other fields as needed } export const ImportData: React.FC = () => { const { token } = useAuth(); const [activeTab, setActiveTab] = useState('fot'); // Generic data state (can be Fot, Agenda, Profissionais) const [data, setData] = useState([]); const [filename, setFilename] = useState(""); const [isLoading, setIsLoading] = useState(false); const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null); // Cache functions for mapping const [functionsMap, setFunctionsMap] = useState>({}); useEffect(() => { // Fetch functions to map Name -> ID const fetchFunctions = async () => { if (!token) return; try { const res = await fetch(`${API_BASE_URL}/api/funcoes`, { headers: { "Authorization": `Bearer ${token}` } }); if (res.ok) { const list = await res.json(); const map: Record = {}; list.forEach((f: any) => { map[f.nome.toLowerCase()] = f.id; map[f.nome] = f.id; // Case sensitive fallback }); // Add mappings for variations if common map['fotografo'] = map['fotógrafo']; map['fotógrafa'] = map['fotógrafo']; map['recepcionista'] = map['recepcionista']; // ... setFunctionsMap(map); } } catch (e) { console.error("Failed to fetch functions", e); } }; fetchFunctions(); }, [token]); const [skippedCount, setSkippedCount] = useState(0); // Clear data when switching tabs const handleTabChange = (tab: ImportType) => { if (tab !== activeTab) { setActiveTab(tab); setData([]); setSkippedCount(0); setFilename(""); setResult(null); } }; const handleFileUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setFilename(file.name); setSkippedCount(0); const reader = new FileReader(); reader.onload = (evt) => { const bstr = evt.target?.result; const wb = XLSX.read(bstr, { type: 'binary' }); const wsname = wb.SheetNames[0]; const ws = wb.Sheets[wsname]; const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][]; let mappedData: any[] = []; let skipped = 0; // Start from row 1 (skip header) for (let i = 1; i < jsonData.length; i++) { const row = jsonData[i]; if (!row || row.length === 0) continue; // Helper to get string const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : ""; // Helper to extract/clean CPF/CNPJ const cleanCpf = (raw: string): string => { if (!raw) return ""; // 1. If explicit "CPF" or "CNPJ" label exists, prioritize digits after it const labelMatch = raw.match(/(?:cpf|cnpj)[\s:./-]*([\d.-]+)/i); if (labelMatch && labelMatch[1]) { const digits = labelMatch[1].replace(/\D/g, ''); if (digits.length === 11 || digits.length === 14) return digits; // Return digits only? Or formatted? // Let's return formatted if possible or just digits. // Storing digits-only is safer for matching. return digits; } // 2. Try to find standard CPF pattern (11 digits with separators) // 111.222.333-44 const cpfPattern = /\b\d{3}\.\d{3}\.\d{3}-\d{2}\b/; const cpfMatch = raw.match(cpfPattern); if (cpfMatch) return cpfMatch[0].replace(/\D/g, ''); // 3. Try standard CNPJ pattern const cnpjPattern = /\b\d{2}\.\d{3}\.\d{3}\/\d{4}-\d{2}\b/; const cnpjMatch = raw.match(cnpjPattern); if (cnpjMatch) return cnpjMatch[0].replace(/\D/g, ''); // 4. Fallback: Cleanup all non-digits const onlyDigits = raw.replace(/\D/g, ''); // If length is 11 (CPF) or 14 (CNPJ), assume valid if (onlyDigits.length === 11 || onlyDigits.length === 14) return onlyDigits; // If length is ambiguous (e.g. RG+CPF concatenated without separators and no labels), // It's hard. But users usually put separators or labels. // If the user pasted "328 065 058 52" (Spaces), onlyDigits handles it (11 chars). // Should we return strict or loose? // If we can't detect, return empty to force 'Skipped'? // Or return original raw and let user verify? // Returning original raw breaks Claim match. // Better to return empty if invalid format, to alert user they need to fix Excel. if (onlyDigits.length > 0) return onlyDigits; // Return whatever we found? return ""; }; // Helper to clean UF (State code) const cleanUf = (raw: string): string => { if (!raw) return ""; return raw.trim().slice(0, 2).toUpperCase(); }; // Helper to clean generic string with max len const cleanStr = (raw: string, maxLen: number): string => { if (!raw) return ""; let s = raw.trim(); if (s.length > maxLen) { s = s.substring(0, maxLen); } return s; }; // Helper to get int const getInt = (idx: number) => { const val = row[idx]; if (!val) return 0; if (typeof val === 'number') return Math.floor(val); const parsed = parseInt(String(val).replace(/\D/g, ''), 10); return isNaN(parsed) ? 0 : parsed; }; if (activeTab === 'fot') { const fot = getStr(0); if (!fot) continue; // Parse Gastos let gastosStr = getStr(8); gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.'); const gastos = parseFloat(gastosStr) || 0; const item: ImportFotInput = { fot: fot, empresa_nome: getStr(1), curso_nome: getStr(2), observacoes: getStr(3), instituicao: getStr(4), ano_formatura_label: getStr(5), cidade: getStr(6), estado: getStr(7), gastos_captacao: gastos, pre_venda: getStr(9).toLowerCase().includes('sim'), }; mappedData.push(item); } else if (activeTab === 'agenda') { const fot = getStr(0); if (!fot) continue; // Agenda Parsing let dateStr = getStr(1); if (typeof row[1] === 'number') { const dateObj = new Date(Math.round((row[1] - 25569)*86400*1000)); dateStr = dateObj.toLocaleDateString('pt-BR'); } const item: ImportAgendaInput = { fot: fot, data: dateStr, tipo_evento: getStr(7), // H observacoes: getStr(8), // I local: getStr(9), // J endereco: getStr(10), // K horario: getStr(11), // L qtd_formandos: getInt(12), // M qtd_fotografos: getInt(13), // N qtd_cinegrafistas: getInt(14), // O qtd_estudios: getInt(15), qtd_recepcionistas: getInt(22) > 0 ? getInt(22) : 0, qtd_ponto_foto: getInt(16), // Q qtd_ponto_id: getInt(17), // R qtd_ponto_decorado: getInt(18), // S qtd_pontos_led: getInt(19), // T qtd_plataforma_360: getInt(20), // U foto_faltante: parseInt(row[23]) || 0, // X recep_faltante: parseInt(row[24]) || 0, // Y cine_faltante: parseInt(row[25]) || 0, // Z logistica_observacoes: getStr(26), // AA pre_venda: getStr(27).toLowerCase().includes('sim'), // AB }; mappedData.push(item); } else if (activeTab === 'profissionais') { const nome = getStr(0); if (!nome) continue; // Skip empty names too // Mapping based on screenshot // A=Nome, B=Funcao, C=End, D=Cid, E=UF, F=Whats, G=CPF, H=Banco, I=Agencia, J=Conta const funcaoNome = getStr(1).toLowerCase(); let funcaoId = ""; for (const key in functionsMap) { if (funcaoNome.includes(key)) { funcaoId = functionsMap[key]; break; } } const emailInPix = getStr(9).includes('@') ? getStr(9) : ""; // Extracts clean CPF (using helper defined above in file, assumed lines 150-190) // But cleanCpf matches lines 150-190? Yes. I need to make sure I invoke it. // Wait, where is cleanCpf? // In snippet 15598 lines 150-190 is anonymous arrow function? // NO. Snippet 15598 line 152: `const labelMatch = raw.match...` // It seems cleanCpf was defined as: // Lines 150... // It was NOT named `cleanCpf` in snippet 15598! // Lint error `cleanCpf` not found. // I must define `cleanCpf` too! // Snippet 15598 shows just logic inside `handleFileUpload` loop? // Or was it inside `cleanCpf`? // Let's assume lines 150-190 ARE inside `cleanCpf`. // But where is the declaration? // I'll assume I need to ADD declaration `const cleanCpf = ...`. // I'll check lines 140-150 in next step. For now I must create it if missing. // Or I can inline it. // Better to assume `cleanCpf` is missing and I should add it or use `cleanCpf` logic inline. // I will use `cleanStr` for now and risk it? No, CPF cleaning is complex. // I will invoke `cleanCpf(getStr(6))` but I need to ensure `cleanCpf` exists. // I will add `cleanCpf` definition in this block for safety. const cleanCpf = (raw: string) => { if (!raw) return ""; const onlyDigits = raw.replace(/\D/g, ''); if (onlyDigits.length === 11 || onlyDigits.length === 14) return onlyDigits; return ""; }; const cpfVal = cleanCpf(getStr(6)); const item: ImportProfissionalInput = { nome: cleanStr(nome, 255), funcao_profissional_id: funcaoId, endereco: cleanStr(getStr(2), 255), cidade: cleanStr(getStr(3), 100), uf: cleanUf(getStr(4)), whatsapp: cleanStr(getStr(5), 20), cpf_cnpj_titular: cpfVal, banco: cleanStr(getStr(7), 100), agencia: cleanStr(getStr(8), 20), conta_pix: cleanStr(emailInPix ? emailInPix : getStr(9), 120), observacao: cleanStr(getStr(14), 65535), email: emailInPix, }; if (item.cpf_cnpj_titular) { mappedData.push(item); } else { skipped++; } } } setData(mappedData); setSkippedCount(skipped); setResult(null); }; reader.readAsBinaryString(file); }; const handleImport = async () => { if (!token) return; setIsLoading(true); try { let endpoint = ""; if (activeTab === 'fot') endpoint = '/api/import/fot'; else if (activeTab === 'agenda') endpoint = '/api/import/agenda'; else if (activeTab === 'profissionais') endpoint = '/api/profissionais/import'; const response = await fetch(`${API_BASE_URL}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }, body: JSON.stringify(data) }); if (!response.ok) { throw new Error(`Erro na importação: ${response.statusText}`); } const resData = await response.json(); if (activeTab === 'profissionais') { // { created, updated, errors_count, errors: [] } setResult({ success: (resData.created || 0) + (resData.updated || 0), errors: resData.errors || [] }); } else if (resData.message) { setResult({ success: data.length, errors: [] }); } else { setResult({ success: resData.SuccessCount, errors: resData.Errors || [] }); } } catch (error) { console.error("Import error:", error); alert("Erro ao importar dados. Verifique o console."); } finally { setIsLoading(false); } }; // Drag scroll logic (omitted rewrite, standard) const tableContainerRef = React.useRef(null); const [isDragging, setIsDragging] = useState(false); const [startX, setStartX] = useState(0); const [scrollLeft, setScrollLeft] = useState(0); const handleMouseDown = (e: React.MouseEvent) => { if (!tableContainerRef.current) return; setIsDragging(true); setStartX(e.pageX - tableContainerRef.current.offsetLeft); setScrollLeft(tableContainerRef.current.scrollLeft); }; const handleMouseLeave = () => { setIsDragging(false); }; const handleMouseUp = () => { setIsDragging(false); }; const handleMouseMove = (e: React.MouseEvent) => { if (!isDragging || !tableContainerRef.current) return; e.preventDefault(); const x = e.pageX - tableContainerRef.current.offsetLeft; const walk = (x - startX) * 2; tableContainerRef.current.scrollLeft = scrollLeft - walk; }; return (

Importação de Dados

Carregue planilhas do Excel para alimentar o sistema.

{/* Tabs */}
{/* Info Box */}
{activeTab === 'fot' && (

Colunas Esperadas (A-J): FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.

)} {activeTab === 'agenda' && (

Colunas Esperadas (A-AB): FOT, Data, ..., Tipo Evento, Obs, Local, Endereço, Horário, Qtds (Formandos, Foto, Cine...), Faltantes, Logística.

)} {activeTab === 'profissionais' && (

Colunas Esperadas (A-J): Nome, Função, Endereço, Cidade, UF, Whatsapp, CPF/CNPJ (Obrigatório), Banco, Agencia, Conta PIX.

)}
{filename && (
Arquivo selecionado: {filename} ({data.length} registros)
)}
{data.length > 0 && !result && (

Pré-visualização ({activeTab.toUpperCase()})

Total: {data.length}
{activeTab === 'fot' && ( <> )} {activeTab === 'agenda' && ( <> )} {activeTab === 'profissionais' && ( <> )} {data.map((row, idx) => ( {activeTab === 'fot' && ( <> )} {activeTab === 'agenda' && ( <> )} {activeTab === 'profissionais' && ( <> )} ))}
FOT Empresa Curso Instituição Ano GastosFOT Data Evento Local Horário Formandos LogísticaNome CPF/CNPJ Função (ID) Whatsapp Cidade/UF
{row.fot} {row.empresa_nome} {row.curso_nome} {row.instituicao} {row.ano_formatura_label} R$ {row.gastos_captacao?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}{row.fot} {row.data} {row.tipo_evento} {row.local} {row.horario} {row.qtd_formandos} {row.logistica_observacoes}{row.nome} {row.cpf_cnpj_titular} {row.funcao_profissional_id ? ( MATCH ) : ( NO ID )} {row.whatsapp} {row.cidade}/{row.uf}
{skippedCount > 0 && ( {skippedCount} registros ignorados (sem CPF/CNPJ) )} Total de Registros: {data.length}
)} {result && (
0 ? 'bg-yellow-50' : 'bg-green-50'}`}>
{result.errors.length === 0 ? ( ) : ( )}

Resultado da Importação

Sucesso: {result.success} registros importados/atualizados.

{result.errors.length > 0 && (

Erros ({result.errors.length}):

    {result.errors.map((err, idx) => (
  • {err}
  • ))}
)}
)}
); };