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.
267 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|