photum/frontend/pages/ImportData.tsx
NANDO9322 60155bdf56 feat: implementação da Importação de Excel e melhorias na Gestão de FOT
Backend:
- Implementa rota e serviço de importação em lote (`/api/import/fot`).
- Adiciona suporte a "Upsert" para atualizar registros existentes sem duplicar.
- Corrige e migra schema do banco: ajuste na precisão de valores monetários e correções de sintaxe.

Frontend:
- Cria página de Importação de Dados com visualização de log e tratamento de erros.
- Implementa melhorias de UX nas tabelas (Importação e Gestão de FOT):
  - Contadores de total de registros.
  - Funcionalidade "Drag-to-Scroll" (arrastar para rolar).
  - Barra de rolagem superior sincronizada na tabela de gestão.
- Corrige bug de "tela branca" ao filtrar dados vazios na gestão.
2026-02-02 11:19:56 -03:00

267 lines
12 KiB
TypeScript

import React, { useState } from 'react';
import * as XLSX from 'xlsx';
import { useAuth } from '../contexts/AuthContext';
import { Button } from '../components/Button';
import { Upload, FileText, CheckCircle, AlertTriangle } from 'lucide-react';
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
interface ImportInput {
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;
}
export const ImportData: React.FC = () => {
const { token } = useAuth();
const [data, setData] = useState<ImportInput[]>([]);
const [preview, setPreview] = useState<ImportInput[]>([]);
const [filename, setFilename] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<{ success: number; errors: string[] } | null>(null);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFilename(file.name);
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[][];
// Assuming header is row 0
// Map columns based on index (A=0, B=1, ... J=9) based on screenshot
const mappedData: ImportInput[] = [];
// 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 safely
const getStr = (idx: number) => row[idx] ? String(row[idx]).trim() : "";
// Skip empty FOT lines?
const fot = getStr(0);
if (!fot) continue;
// Parse Gastos (Remove 'R$', replace ',' with '.')
let gastosStr = getStr(8); // Col I
// Remove R$, spaces, thousands separator (.) and replace decimal (,) with .
// Example: "R$ 2.500,00" -> "2500.00"
gastosStr = gastosStr.replace(/[R$\s.]/g, '').replace(',', '.');
const gastos = parseFloat(gastosStr) || 0;
const importItem: ImportInput = {
fot: fot,
empresa_nome: getStr(1), // Col B
curso_nome: getStr(2), // Col C
observacoes: getStr(3), // Col D
instituicao: getStr(4), // Col E
ano_formatura_label: getStr(5), // Col F
cidade: getStr(6), // Col G
estado: getStr(7), // Col H
gastos_captacao: gastos, // Col I
pre_venda: getStr(9).toLowerCase().includes('sim'), // Col J
};
mappedData.push(importItem);
}
setData(mappedData);
setPreview(mappedData.slice(0, 5));
setResult(null);
};
reader.readAsBinaryString(file);
};
const handleImport = async () => {
if (!token) return;
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/api/import/fot`, {
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();
setResult({
success: resData.SuccessCount,
errors: resData.Errors || []
});
// Clear data on success? Maybe keep for review.
} catch (error) {
console.error("Import error:", error);
alert("Erro ao importar dados. Verifique o console.");
} finally {
setIsLoading(false);
}
};
// Drag scroll logic
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; // Scroll-fast
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 (FOT)</h1>
<p className="mt-2 text-gray-600">
Importe dados da planilha Excel para o sistema. Certifique-se que as colunas seguem o padrão.
</p>
<div className="mt-2 text-sm text-gray-500 bg-blue-50 p-4 rounded-md">
<strong>Colunas Esperadas (A-J):</strong> FOT, Empresa, Curso, Observações, Instituição, Ano Formatura, Cidade, Estado, Gastos Captação, Pré Venda.
</div>
</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</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>
{/* Scrollable Container with Drag Support */}
<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>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Empresa</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Curso</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Instituição</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Ano</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Cidade/UF</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Gastos</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Pré Venda</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">
<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">{row.cidade}/{row.estado}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.gastos_captacao.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.pre_venda ? 'Sim' : 'Não'}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-right">
<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([]); setPreview([]); setResult(null); setFilename(""); }}>
Importar Novo Arquivo
</Button>
</div>
</div>
)}
</div>
</div>
);
};