photum/frontend/pages/ImportData.tsx
NANDO9322 a51401d9ba feat(finance): overhaul completo do financeiro (Import, Filtros, UI)
- Melhora Importação: ignora linhas vazias/inválidas automaticamente.
- Filtros Server-Side: busca em todas as páginas (FOT, Nome, etc.).
- Colunas Novas: adiciona Curso, Instituição, Ano e Empresa na tabela.
- UI/UX: Corrige ordenação (vazios no fim) e adiciona scrollbar no topo.
2026-02-02 19:16:37 -03:00

855 lines
40 KiB
TypeScript

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' | 'financeiro';
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
}
interface ImportFinanceInput {
fot: string;
data: string;
tipo_evento: string;
tipo_servico: string;
nome: string;
whatsapp: string;
cpf: string;
tabela_free: string;
valor_free: number;
valor_extra: number;
descricao_extra: string;
total_pagar: number;
data_pgto: string;
pgto_ok: boolean;
}
export const ImportData: React.FC = () => {
const { token } = useAuth();
// Read initial tab from URL
const query = new URLSearchParams(window.location.search);
const initialTab = (query.get('tab') as ImportType) || 'fot';
const [activeTab, setActiveTab] = useState<ImportType>(initialTab);
// Generic data state (can be Fot, Agenda, Profissionais)
const [data, setData] = useState<any[]>([]);
const [filename, setFilename] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
// Cache functions for mapping
const [functionsMap, setFunctionsMap] = useState<Record<string, string>>({});
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<string, string> = {};
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<HTMLInputElement>) => {
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[][];
const rows = jsonData as any[];
let mappedData: any[] = [];
let skipped = 0;
if (activeTab === 'financeiro') {
// Dynamic Header Mapping
// 1. Find header row (look for "FOT" and "Nome")
let headerIdx = -1;
const colMap: {[key: string]: number} = {};
for (let i = 0; i < 20 && i < rows.length; i++) {
const rowStrings = (rows[i] as any[]).map(c => String(c).toLowerCase().trim());
if (rowStrings.some(s => s === 'fot' || s === 'nome' || s === 'cpf')) {
headerIdx = i;
rowStrings.forEach((h, idx) => {
if (h === 'fot' || h.includes('contrato')) colMap['fot'] = idx;
else if (h === 'data' || h.includes('dt evento')) colMap['data'] = idx;
else if (h === 'evento' || h.includes('tp evento')) colMap['evento'] = idx;
else if (h === 'serviço' || h === 'servico' || h.includes('função') || h.includes('tp serv')) colMap['servico'] = idx;
else if (h === 'nome' || h === 'profissional') colMap['nome'] = idx;
else if (h === 'whatsapp' || h === 'whats' || h === 'tel' || h === 'cel') colMap['whatsapp'] = idx;
else if (h === 'cpf') colMap['cpf'] = idx;
else if (h.includes('tab') || h.includes('tabela')) colMap['tabela'] = idx;
else if (h.includes('v. free') || h.includes('valor free') || h === 'cache') colMap['vfree'] = idx;
else if (h.includes('v. extra') || h.includes('valor extra')) colMap['vextra'] = idx;
else if (h.includes('desc') || h.includes('obs extra')) colMap['descextra'] = idx;
else if (h === 'total' || h.includes('total')) colMap['total'] = idx;
else if (h.includes('dt pgto') || h.includes('data pag')) colMap['datapgto'] = idx;
else if (h === 'ok' || h.includes('pago') || h === 'status') colMap['pgtook'] = idx;
});
break;
}
}
if (headerIdx === -1) {
// Fallback to index based if header not found
headerIdx = 0;
colMap['fot'] = 0; colMap['data'] = 1; colMap['evento'] = 2; colMap['servico'] = 3;
colMap['nome'] = 4; colMap['whatsapp'] = 5; colMap['cpf'] = 6; colMap['tabela'] = 7;
colMap['vfree'] = 8; colMap['vextra'] = 9; colMap['descextra'] = 10; colMap['total'] = 11;
colMap['datapgto'] = 12; colMap['pgtook'] = 13;
}
// Iterate data rows
for (let i = headerIdx + 1; i < rows.length; i++) {
const row = rows[i] as any[];
if (!row || row.length === 0) continue;
const getCol = (key: string) => {
const idx = colMap[key];
if (idx === undefined || idx < 0) return "";
return row[idx] !== undefined ? String(row[idx]).trim() : "";
};
const getRawCol = (key: string) => {
const idx = colMap[key];
if (idx === undefined || idx < 0) return undefined;
return row[idx];
};
const fot = getCol('fot');
const nome = getCol('nome');
const totalPagar = getCol('total');
const valorFree = getCol('vfree');
// Skip if Nome is empty AND (Total is 0 or empty AND Free is 0 or empty)
// AND FOT checks.
const isTotalZero = !totalPagar || parseFloat(totalPagar.replace(/[R$\s.]/g, '').replace(',', '.')) === 0;
const isFreeZero = !valorFree || parseFloat(valorFree.replace(/[R$\s.]/g, '').replace(',', '.')) === 0;
if (!fot && !nome) {
skipped++;
continue; // Basic skip for completely empty rows
}
if (!nome && isTotalZero && isFreeZero) {
skipped++;
continue; // Skip garbage rows with just FOT and no name/value
}
// VALIDATION STRICT: Skip if FOT and Nome are empty or just "-"
if ((!fot || fot.length < 1 || fot === '-') && (!nome || nome.length < 2)) {
skipped++;
continue;
}
const parseVal = (str: string) => {
if (!str) return 0;
// Handle 1.234,56 or 1234.56
let s = str.replace(/[R$\s]/g, '');
if (s.includes(',') && s.includes('.')) {
// 1.234,56 -> remove dots, replace comma with dot
s = s.replace(/\./g, '').replace(',', '.');
} else if (s.includes(',')) {
s = s.replace(',', '.');
}
return parseFloat(s) || 0;
};
const parseDateCol = (key: string) => {
const raw = getRawCol(key);
if (typeof raw === 'number') {
const dateObj = new Date(Math.round((raw - 25569)*86400*1000));
// Return YYYY-MM-DD for backend consistency
return dateObj.toISOString().split('T')[0];
}
const str = getCol(key);
// Try to fix DD/MM/YYYY to YYYY-MM-DD
if (str.includes('/')) {
const parts = str.split('/');
if (parts.length === 3) return `${parts[2]}-${parts[1]}-${parts[0]}`;
}
return str;
};
const item: ImportFinanceInput = {
fot: fot,
data: parseDateCol('data'),
tipo_evento: getCol('evento'),
tipo_servico: getCol('servico'),
nome: getCol('nome'),
whatsapp: getCol('whatsapp'),
cpf: getCol('cpf'),
tabela_free: getCol('tabela'),
valor_free: parseVal(getCol('vfree')),
valor_extra: parseVal(getCol('vextra')),
descricao_extra: getCol('descextra'),
total_pagar: parseVal(getCol('total')),
data_pgto: parseDateCol('datapgto'),
pgto_ok: getCol('pgtook').toLowerCase().includes('sim') || getCol('pgtook').toLowerCase() === 'ok',
};
mappedData.push(item);
}
setData(mappedData);
setSkippedCount(0);
setResult(null);
return; // EXIT early for Finance
}
// Start from row 1 (skip header) for OTHER inputs
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';
else if (activeTab === 'financeiro') endpoint = '/api/finance/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<HTMLDivElement>(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 (
<div className="min-h-screen bg-gray-50 pt-20 pb-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto space-y-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">Importação de Dados</h1>
<p className="mt-2 text-gray-600">
Carregue planilhas do Excel para alimentar o sistema.
</p>
</div>
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
onClick={() => handleTabChange('fot')}
className={`${
activeTab === 'fot'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
>
<Database className="w-4 h-4" />
Cadastro FOT
</button>
<button
onClick={() => handleTabChange('agenda')}
className={`${
activeTab === 'agenda'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
>
<Calendar className="w-4 h-4" />
Agenda de Eventos
</button>
<button
onClick={() => handleTabChange('profissionais')}
className={`${
activeTab === 'profissionais'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
>
<UserPlus className="w-4 h-4" />
Profissionais
</button>
<button
onClick={() => handleTabChange('financeiro')}
className={`${
activeTab === 'financeiro'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
>
<FileText className="w-4 h-4" />
Financeiro
</button>
</nav>
</div>
{/* Info Box */}
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
{activeTab === 'fot' && (
<p><strong>Colunas Esperadas (A-J):</strong> FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.</p>
)}
{activeTab === 'agenda' && (
<p><strong>Colunas Esperadas (A-AB):</strong> FOT, Data, ..., Tipo Evento, Obs, Local, Endereço, Horário, Qtds (Formandos, Foto, Cine...), Faltantes, Logística.</p>
)}
{activeTab === 'profissionais' && (
<p><strong>Colunas Esperadas (A-J):</strong> Nome, Função, Endereço, Cidade, UF, Whatsapp, <strong>CPF/CNPJ</strong> (Obrigatório), Banco, Agencia, Conta PIX.</p>
)}
{activeTab === 'financeiro' && (
<p><strong>Colunas Esperadas (A-N):</strong> FOT, Data, Tipo Evento, Tipo Serviço, Nome, Whatsapp, CPF, Tabela Free, Valor Free, Valor Extra, Desc. Extra, Total, Data Pgto, Pgto OK.</p>
)}
</div>
<div className="bg-white p-6 rounded-lg shadow space-y-4">
<div className="flex items-center justify-center w-full">
<label htmlFor="dropzone-file" className="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<Upload className="w-8 h-8 mb-3 text-gray-400" />
<p className="mb-2 text-sm text-gray-500"><span className="font-semibold">Clique para enviar</span> ou arraste o arquivo</p>
<p className="text-xs text-gray-500">XLSX, XLS (Excel)</p>
</div>
<input id="dropzone-file" type="file" className="hidden" accept=".xlsx, .xls" onChange={handleFileUpload} />
</label>
</div>
{filename && (
<div className="flex items-center gap-2 text-sm text-gray-700">
<FileText className="w-4 h-4" />
Arquivo selecionado: <span className="font-medium">{filename}</span> ({data.length} registros)
</div>
)}
</div>
{data.length > 0 && !result && (
<div className="bg-white rounded-lg shadow overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center flex-wrap gap-2">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-gray-900">Pré-visualização ({activeTab.toUpperCase()})</h3>
<span className="text-sm font-semibold bg-gray-200 px-2 py-1 rounded-full text-gray-700">Total: {data.length}</span>
</div>
<Button onClick={handleImport} isLoading={isLoading}>
Confirmar Importação
</Button>
</div>
<div
ref={tableContainerRef}
className={`overflow-auto max-h-[600px] border-b border-gray-200 ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
>
<table className="min-w-full divide-y divide-gray-200 relative">
<thead className="bg-gray-50 sticky top-0 z-10 shadow-sm">
<tr>
{activeTab === 'fot' && (
<>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Empresa</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Curso</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Instituição</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Ano</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Gastos</th>
</>
)}
{activeTab === 'agenda' && (
<>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Data</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Evento</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Local</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Horário</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Formandos</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Logística</th>
</>
)}
{activeTab === 'profissionais' && (
<>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Nome</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">CPF/CNPJ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Função (ID)</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Whatsapp</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Cidade/UF</th>
</>
)}
{activeTab === 'financeiro' && (
<>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Data</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Profissional</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Serviço</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Total</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Status</th>
</>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.map((row, idx) => (
<tr key={idx} className="hover:bg-gray-50 transition-colors">
{activeTab === 'fot' && (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.empresa_nome}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.curso_nome}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.instituicao}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.ano_formatura_label}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.gastos_captacao?.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
</>
)}
{activeTab === 'agenda' && (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.data}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.tipo_evento}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.local}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.horario}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.qtd_formandos}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 truncate max-w-xs">{row.logistica_observacoes}</td>
</>
)}
{activeTab === 'profissionais' && (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.nome}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cpf_cnpj_titular}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 flex items-center gap-1">
{row.funcao_profissional_id ? (
<span className="text-green-600 font-mono text-xs">MATCH</span>
) : (
<span className="text-red-500 font-mono text-xs">NO ID</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.whatsapp}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cidade}/{row.uf}</td>
</>
)}
{activeTab === 'financeiro' && (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.data}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.nome}<br/><span className="text-xs text-gray-400">{row.cpf}</span></td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.tipo_servico}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.total_pagar.toFixed(2)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{row.pgto_ok ? (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full">Pago</span>
) : (
<span className="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded-full">Pendente</span>
)}
</td>
</>
)}
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right flex justify-end items-center gap-4">
{skippedCount > 0 && (
<span className="text-sm text-yellow-600 font-medium flex items-center gap-1">
<AlertTriangle className="w-4 h-4" />
{skippedCount} registros ignorados (sem CPF/CNPJ)
</span>
)}
<span className="text-sm font-semibold text-gray-700">Total de Registros: {data.length}</span>
</div>
</div>
)}
{result && (
<div className={`p-4 rounded-md ${result.errors.length > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}>
<div className="flex items-center mb-2">
{result.errors.length === 0 ? (
<CheckCircle className="w-6 h-6 text-green-500 mr-2" />
) : (
<AlertTriangle className="w-6 h-6 text-yellow-500 mr-2" />
)}
<h3 className="text-lg font-medium text-gray-900">Resultado da Importação</h3>
</div>
<p className="text-sm text-gray-700 mb-2">
Sucesso: <strong>{result.success}</strong> registros importados/atualizados.
</p>
{result.errors.length > 0 && (
<div className="mt-2">
<p className="text-sm font-bold text-red-600 mb-1">Erros ({result.errors.length}):</p>
<ul className="list-disc pl-5 text-xs text-red-600 max-h-40 overflow-y-auto">
{result.errors.map((err, idx) => (
<li key={idx}>{err}</li>
))}
</ul>
</div>
)}
<div className="mt-4">
<Button variant="outline" onClick={() => { setData([]); setResult(null); setFilename(""); }}>
Importar Novo Arquivo
</Button>
</div>
</div>
)}
</div>
</div>
);
};