vendaweb-api/src/app/DRE/analitico.tsx

774 lines
27 KiB
TypeScript

"use client";
import * as React from "react";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Download, Filter, X } from "lucide-react";
import * as XLSX from "xlsx";
interface AnaliticoItem {
codigo_grupo: string;
codigo_subgrupo: string;
codigo_fornecedor: string;
nome_fornecedor: string;
id: number;
codfilial: string;
recnum: number;
data_competencia: string;
data_vencimento: string;
data_pagamento: string;
data_caixa: string;
codigo_conta: string;
conta: string;
codigo_centrocusto: string;
valor: number;
historico: string;
historico2: string;
created_at: string;
updated_at: string;
}
interface AnaliticoProps {
filtros: {
dataInicio: string;
dataFim: string;
centroCusto?: string;
codigoGrupo?: string;
codigoSubgrupo?: string;
codigoConta?: string;
};
}
export default function AnaliticoComponent({ filtros }: AnaliticoProps) {
const [data, setData] = React.useState<AnaliticoItem[]>([]);
const [loading, setLoading] = React.useState(false);
const [globalFilter, setGlobalFilter] = React.useState("");
const [columnFilters, setColumnFilters] = React.useState<any[]>([]);
const [open, setOpen] = React.useState(false);
const [conditions, setConditions] = React.useState([
{ column: "", operator: "contains", value: "" },
]);
const fetchData = React.useCallback(async () => {
// Só faz a requisição se tiver dataInicio e dataFim
if (!filtros.dataInicio || !filtros.dataFim) {
setData([]);
return;
}
setLoading(true);
try {
const params = new URLSearchParams({
dataInicio: filtros.dataInicio,
dataFim: filtros.dataFim,
...(filtros.centroCusto && { centroCusto: filtros.centroCusto }),
...(filtros.codigoGrupo && { codigoGrupo: filtros.codigoGrupo }),
...(filtros.codigoSubgrupo && {
codigoSubgrupo: filtros.codigoSubgrupo,
}),
...(filtros.codigoConta && { codigoConta: filtros.codigoConta }),
});
const response = await fetch(`/api/analitico?${params}`);
if (response.ok) {
const result = await response.json();
setData(result as AnaliticoItem[]);
} else {
console.error("Erro ao buscar dados:", await response.text());
}
} catch (error) {
console.error("Erro ao buscar dados:", error);
} finally {
setLoading(false);
}
}, [filtros]);
React.useEffect(() => {
fetchData();
}, [fetchData]);
const columns = React.useMemo(
() => [
{
accessorKey: "data_competencia",
header: "Data Comp.",
filterFn: "advancedText",
cell: ({ getValue }: { getValue: () => string }) => {
const value = getValue();
return new Date(value).toLocaleDateString("pt-BR");
},
},
{
accessorKey: "data_vencimento",
header: "Data Venc.",
filterFn: "advancedText",
cell: ({ getValue }: { getValue: () => string }) => {
const value = getValue();
return new Date(value).toLocaleDateString("pt-BR");
},
},
{
accessorKey: "data_caixa",
header: "Data Caixa",
filterFn: "advancedText",
cell: ({ getValue }: { getValue: () => string }) => {
const value = getValue();
return new Date(value).toLocaleDateString("pt-BR");
},
},
{
accessorKey: "codigo_fornecedor",
header: "Cód. Fornec.",
filterFn: "advancedText",
},
{
accessorKey: "nome_fornecedor",
header: "Fornecedor",
filterFn: "advancedText",
},
{
accessorKey: "codigo_centrocusto",
header: "Cód. Centro",
filterFn: "advancedText",
},
{
accessorKey: "codigo_conta",
header: "Cód. Conta",
filterFn: "advancedText",
},
{ accessorKey: "conta", header: "Conta", filterFn: "advancedText" },
{
accessorKey: "valor",
header: "Valor",
filterFn: "advancedText",
cell: ({ getValue }: { getValue: () => number }) => {
const value = getValue();
const formatted = new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
const isNegative = value < 0;
return (
<span className={isNegative ? "text-red-600" : "text-gray-900"}>
{formatted}
</span>
);
},
},
{
accessorKey: "historico",
header: "Histórico",
filterFn: "advancedText",
},
{
accessorKey: "historico2",
header: "Histórico 2",
filterFn: "advancedText",
},
{ accessorKey: "recnum", header: "Recnum", filterFn: "advancedText" },
],
[]
);
const filterFns = React.useMemo(
() => ({
advancedText: (row: any, columnId: string, filters: any[]) => {
if (!filters || filters.length === 0) return true;
// Se veio um único filtro (objeto), transforma em array
const conds = Array.isArray(filters) ? filters : [filters];
// A coluna deve atender a todas as condições aplicáveis a ela
return conds.every((filter) => {
const raw = row.getValue(columnId);
const v = raw == null ? "" : String(raw);
const op = filter.operator;
const q = (filter.value ?? "").toString();
const a = v.toLowerCase();
const b = q.toLowerCase();
switch (op) {
case "contains":
return a.includes(b);
case "equals":
return a === b;
case "startsWith":
return a.startsWith(b);
case "endsWith":
return a.endsWith(b);
case "empty":
return a.length === 0;
case "notEmpty":
return a.length > 0;
default:
return true;
}
});
},
}),
[]
);
const table = useReactTable({
data,
columns,
state: { globalFilter, columnFilters },
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
filterFns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 36,
overscan: 20,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const applyFilters = () => {
// Agrupar múltiplas condições por coluna
const grouped: Record<string, any[]> = {};
conditions.forEach((c) => {
if (
c.column &&
(c.operator === "empty" ||
c.operator === "notEmpty" ||
(c.value ?? "") !== "")
) {
if (!grouped[c.column]) grouped[c.column] = [];
grouped[c.column].push({ operator: c.operator, value: c.value });
}
});
// Converte em formato aceito pelo TanStack
const filters = Object.keys(grouped).map((col) => ({
id: col,
value: grouped[col],
}));
setColumnFilters(filters);
setOpen(false);
};
const clearFilters = () => {
setConditions([{ column: "", operator: "contains", value: "" }]);
setColumnFilters([]);
setGlobalFilter("");
};
const [totalValor, setTotalValor] = React.useState(0);
React.useEffect(() => {
// Usar dados filtrados da tabela em vez dos dados originais
const filteredData = table.getRowModel().rows.map(row => row.original);
const newTotal = filteredData.reduce((sum, item) => {
const valor =
typeof item.valor === "string" ? parseFloat(item.valor) : item.valor;
return sum + (isNaN(valor) ? 0 : valor);
}, 0);
console.log('🔄 Calculando total:', {
totalRows: table.getRowModel().rows.length,
originalDataLength: data.length,
newTotal,
columnFilters: columnFilters.length,
globalFilter
});
setTotalValor(newTotal);
}, [table, data, columnFilters, globalFilter]);
const exportToExcel = () => {
if (data.length === 0) return;
// Usar dados filtrados da tabela em vez dos dados originais
const filteredData = table.getRowModel().rows.map(row => row.original);
if (filteredData.length === 0) {
alert('Nenhum dado filtrado para exportar');
return;
}
// Preparar dados para exportação
const exportData = filteredData.map((item) => ({
"Data Competência": new Date(item.data_competencia).toLocaleDateString(
"pt-BR"
),
"Data Vencimento": new Date(item.data_vencimento).toLocaleDateString(
"pt-BR"
),
"Data Caixa": new Date(item.data_caixa).toLocaleDateString("pt-BR"),
"Código Fornecedor": item.codigo_fornecedor,
Fornecedor: item.nome_fornecedor,
"Código Centro Custo": item.codigo_centrocusto,
"Centro Custo": item.codigo_centrocusto, // Assumindo que é o mesmo valor
"Código Conta": item.codigo_conta,
Conta: item.conta,
Valor:
typeof item.valor === "string" ? parseFloat(item.valor) : item.valor,
Histórico: item.historico,
"Histórico 2": item.historico2,
Recnum: item.recnum,
}));
// Criar workbook
const wb = XLSX.utils.book_new();
const ws = XLSX.utils.json_to_sheet(exportData);
// Adicionar resumo na segunda aba
const resumoData = [
{ Métrica: "Total de Registros", Valor: filteredData.length },
{ Métrica: "Valor Total", Valor: totalValor },
{ Métrica: "Filtros Aplicados", Valor: columnFilters.length > 0 || globalFilter ? "Sim" : "Não" },
];
const wsResumo = XLSX.utils.json_to_sheet(resumoData);
// Adicionar abas ao workbook
XLSX.utils.book_append_sheet(wb, ws, "Dados Analíticos");
XLSX.utils.book_append_sheet(wb, wsResumo, "Resumo");
// Gerar nome do arquivo com data e hora
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, "-");
const hasFilters = columnFilters.length > 0 || globalFilter;
const fileName = `analitico${hasFilters ? '_filtrado' : ''}_${timestamp}.xlsx`;
// Fazer download
XLSX.writeFile(wb, fileName);
};
return (
<div className="w-full max-w-none mx-auto p-6">
{/* Header Section */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
{/* <div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-xl flex items-center justify-center shadow-lg">
<svg
className="w-7 h-7 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div> */}
<div>
<h1 className="text-2xl font-bold text-gray-900">
Análise Analítica
</h1>
<p className="text-sm text-gray-500">
Relatório detalhado de transações
</p>
</div>
</div>
{/* Controls */}
<div className="flex gap-3 flex-wrap">
<Input
placeholder="Filtrar tudo..."
value={globalFilter ?? ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setGlobalFilter(e.target.value)
}
className="w-64 bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
<Button
variant="outline"
onClick={() => setOpen(true)}
className="bg-white border-gray-300 hover:bg-blue-50 hover:border-blue-300 text-gray-700"
>
<Filter className="w-4 h-4 mr-2" />
Filtros Avançados
{columnFilters.length > 0 && (
<span className="ml-2 bg-blue-600 text-white text-xs px-2 py-1 rounded-full">
{columnFilters.length}
</span>
)}
</Button>
{(columnFilters.length > 0 || globalFilter) && (
<Button
variant="outline"
onClick={clearFilters}
className="bg-white border-gray-300 hover:bg-red-50 hover:border-red-300 text-gray-700"
>
<X className="w-4 h-4 mr-2" />
Limpar Filtros
</Button>
)}
{data.length > 0 && (
<Button
onClick={exportToExcel}
variant="outline"
size="sm"
className="flex items-center gap-2 bg-white border-gray-300 hover:bg-green-50 hover:border-green-300 text-gray-700"
>
<Download className="h-4 w-4" />
Exportar XLSX
</Button>
)}
</div>
</div>
{/* Table Container */}
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-x-auto">
<div className="min-w-[1200px]">
{/* Table Header */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200 sticky top-0 z-20">
<div className="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-semibold text-gray-700 uppercase tracking-wide">
<div className="col-span-1">Data Comp.</div>
<div className="col-span-1">Data Venc.</div>
<div className="col-span-1">Data Caixa</div>
<div className="col-span-1">Cód. Fornec.</div>
<div className="col-span-2">Fornecedor</div>
<div className="col-span-1">Cód. Centro</div>
<div className="col-span-1">Cód. Conta</div>
<div className="col-span-1">Conta</div>
<div className="col-span-2 text-right">Valor</div>
<div className="col-span-1">Recnum</div>
</div>
</div>
{/* Table Body */}
<div
ref={parentRef}
className="max-h-[500px] overflow-auto scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 hover:scrollbar-thumb-gray-400"
style={{
scrollbarWidth: "thin",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
<p className="text-gray-500">Carregando dados...</p>
</div>
</div>
) : virtualRows.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<p className="text-gray-500">Nenhum dado encontrado</p>
</div>
</div>
) : (
<div
className="relative"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
return (
<div
key={row.id}
className="absolute top-0 left-0 w-full grid grid-cols-12 gap-4 px-4 py-3 text-sm border-b border-gray-100 hover:bg-gray-50 transition-colors"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<div className="col-span-1 text-gray-600">
{new Date(
row.original.data_competencia
).toLocaleDateString("pt-BR")}
</div>
<div className="col-span-1 text-gray-600">
{new Date(
row.original.data_vencimento
).toLocaleDateString("pt-BR")}
</div>
<div className="col-span-1 text-gray-600">
{new Date(row.original.data_caixa).toLocaleDateString(
"pt-BR"
)}
</div>
<div className="col-span-1 font-medium text-gray-900">
{row.original.codigo_fornecedor}
</div>
<div
className="col-span-2 text-gray-700 truncate"
title={row.original.nome_fornecedor}
>
{row.original.nome_fornecedor}
</div>
<div className="col-span-1 text-gray-600">
{row.original.codigo_centrocusto}
</div>
<div className="col-span-1 text-gray-600">
{row.original.codigo_conta}
</div>
<div
className="col-span-1 text-gray-700 truncate"
title={row.original.conta}
>
{row.original.conta}
</div>
<div
className={`col-span-2 text-right font-semibold whitespace-nowrap ${
row.original.valor < 0
? "text-red-600"
: "text-gray-900"
}`}
>
{new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(row.original.valor)}
</div>
<div className="col-span-1 text-gray-500">
{row.original.recnum}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Summary Footer - Integrado */}
{data.length > 0 && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border-t border-blue-200 p-6">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
{/* <div className="w-12 h-12 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div> */}
<div>
<h3 className="text-lg font-bold text-gray-900">
Total de Registros:{" "}
<span className="text-blue-600">
{table.getRowModel().rows.length}
</span>
</h3>
<p className="text-sm text-gray-600">
Transações encontradas
</p>
</div>
</div>
<div className="text-right">
<h3 className="text-lg font-bold">
<span
className={
totalValor < 0 ? "text-red-600" : "text-green-600"
}
>
Valor Total:{" "}
{new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(totalValor)}
</span>
</h3>
<p className="text-sm text-gray-600">
Soma de todos os valores
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Advanced Filters Dialog */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl w-full mx-4 bg-white">
<DialogHeader className="pb-4">
<DialogTitle className="text-xl font-semibold text-gray-900">
Filtros Avançados
</DialogTitle>
</DialogHeader>
<div className="space-y-4 max-h-96 overflow-y-auto bg-white">
{conditions.map((cond, idx) => (
<div
key={idx}
className="flex gap-3 items-start p-4 bg-gray-50 rounded-lg border border-gray-200"
>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Coluna
</label>
<Select
value={cond.column}
onValueChange={(v: string) => {
const next = [...conditions];
next[idx].column = v;
setConditions(next);
}}
>
<SelectTrigger className="w-full bg-white border-gray-300">
<SelectValue placeholder="Selecione a coluna" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem
key={col.accessorKey}
value={col.accessorKey}
>
{col.header}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Operador
</label>
<Select
value={cond.operator}
onValueChange={(v: string) => {
const next = [...conditions];
next[idx].operator = v;
if (v === "empty" || v === "notEmpty")
next[idx].value = "";
setConditions(next);
}}
>
<SelectTrigger className="w-full bg-white border-gray-300">
<SelectValue placeholder="Selecione o operador" />
</SelectTrigger>
<SelectContent>
<SelectItem value="contains">contém</SelectItem>
<SelectItem value="equals">igual a</SelectItem>
<SelectItem value="startsWith">começa com</SelectItem>
<SelectItem value="endsWith">termina com</SelectItem>
<SelectItem value="empty">está vazio</SelectItem>
<SelectItem value="notEmpty">não está vazio</SelectItem>
</SelectContent>
</Select>
</div>
{!(
cond.operator === "empty" || cond.operator === "notEmpty"
) && (
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Valor
</label>
<Input
value={cond.value}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const next = [...conditions];
next[idx].value = e.target.value;
setConditions(next);
}}
placeholder="Digite o valor"
className="w-full bg-white border-gray-300"
/>
</div>
)}
{conditions.length > 1 && (
<div className="flex items-end">
<Button
variant="outline"
size="sm"
onClick={() => {
const next = conditions.filter((_, i) => i !== idx);
setConditions(next);
}}
className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-200"
>
</Button>
</div>
)}
</div>
))}
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() =>
setConditions((prev) => [
...prev,
{ column: "", operator: "contains", value: "" },
])
}
className="flex items-center gap-2 text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-200"
>
<span className="text-lg">+</span>
Adicionar condição
</Button>
</div>
</div>
<DialogFooter className="flex gap-3 pt-6 border-t border-gray-200">
<Button
variant="outline"
onClick={clearFilters}
className="flex-1 border-gray-300 text-gray-700 hover:bg-gray-50"
>
Limpar todos
</Button>
<Button
onClick={applyFilters}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white"
>
Aplicar filtros
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}