fix: filtro customizado
This commit is contained in:
parent
9f7e19d80d
commit
a7adda84a5
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { DataGrid, GridToolbar } from "@mui/x-data-grid";
|
||||
import { DataGrid, GridToolbar, GridColDef, GridFilterModel } from "@mui/x-data-grid";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -19,7 +20,8 @@ import {
|
|||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { Download, Filter, X } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Download, Filter, X, Search, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
interface AnaliticoItem {
|
||||
|
|
@ -65,11 +67,207 @@ interface AnaliticoProps {
|
|||
};
|
||||
}
|
||||
|
||||
// Componente de filtro customizado estilo Excel
|
||||
interface ExcelFilterProps {
|
||||
column: GridColDef;
|
||||
data: any[];
|
||||
onFilterChange: (field: string, values: string[]) => void;
|
||||
onSortChange: (field: string, direction: 'asc' | 'desc' | null) => void;
|
||||
currentFilter?: string[];
|
||||
currentSort?: 'asc' | 'desc' | null;
|
||||
}
|
||||
|
||||
const ExcelFilter: React.FC<ExcelFilterProps> = ({
|
||||
column,
|
||||
data,
|
||||
onFilterChange,
|
||||
onSortChange,
|
||||
currentFilter = [],
|
||||
currentSort = null,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [selectedValues, setSelectedValues] = React.useState<string[]>(currentFilter);
|
||||
const [selectAll, setSelectAll] = React.useState(false);
|
||||
|
||||
// Obter valores únicos da coluna
|
||||
const uniqueValues = React.useMemo(() => {
|
||||
const values = data
|
||||
.map((row) => {
|
||||
const value = row[column.field];
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value);
|
||||
})
|
||||
.filter((value, index, self) => self.indexOf(value) === index && value !== "")
|
||||
.sort();
|
||||
|
||||
return values;
|
||||
}, [data, column.field]);
|
||||
|
||||
// Filtrar valores baseado na busca
|
||||
const filteredValues = React.useMemo(() => {
|
||||
if (!searchTerm) return uniqueValues;
|
||||
return uniqueValues.filter((value) =>
|
||||
value.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
}, [uniqueValues, searchTerm]);
|
||||
|
||||
// Verificar se todos estão selecionados
|
||||
React.useEffect(() => {
|
||||
setSelectAll(selectedValues.length === filteredValues.length && filteredValues.length > 0);
|
||||
}, [selectedValues, filteredValues]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedValues(filteredValues);
|
||||
} else {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValueToggle = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedValues([...selectedValues, value]);
|
||||
} else {
|
||||
setSelectedValues(selectedValues.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onFilterChange(column.field, selectedValues);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedValues([]);
|
||||
onFilterChange(column.field, []);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSort = (direction: 'asc' | 'desc') => {
|
||||
onSortChange(column.field, direction);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Filtrar por "{column.headerName}"
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Opções de ordenação */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">Ordenar</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleSort('asc')}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3 mr-1" />
|
||||
A a Z
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleSort('desc')}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3 mr-1" />
|
||||
Z a A
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs text-red-600 hover:text-red-700"
|
||||
onClick={handleClear}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Limpar Filtro
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Barra de pesquisa */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Pesquisar"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lista de valores com checkboxes */}
|
||||
<div className="max-h-60 overflow-y-auto border rounded-md">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center space-x-2 py-1">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<label htmlFor="select-all" className="text-sm font-medium">
|
||||
(Selecionar Tudo)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{filteredValues.map((value) => (
|
||||
<div key={value} className="flex items-center space-x-2 py-1">
|
||||
<Checkbox
|
||||
id={`value-${value}`}
|
||||
checked={selectedValues.includes(value)}
|
||||
onCheckedChange={(checked: boolean) => handleValueToggle(value, checked)}
|
||||
/>
|
||||
<label htmlFor={`value-${value}`} className="text-sm">
|
||||
{value}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<DialogFooter className="flex space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleApply}>
|
||||
OK
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AnaliticoComponent({ filtros }: AnaliticoProps) {
|
||||
const [data, setData] = React.useState<AnaliticoItem[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [globalFilter, setGlobalFilter] = React.useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [columnFilters, setColumnFilters] = React.useState<Record<string, string[]>>({});
|
||||
const [columnSorts, setColumnSorts] = React.useState<Record<string, 'asc' | 'desc' | null>>({});
|
||||
const [conditions, setConditions] = React.useState([
|
||||
{ column: "", operator: "contains", value: "" },
|
||||
]);
|
||||
|
|
@ -77,6 +275,21 @@ export default function AnaliticoComponent({ filtros }: AnaliticoProps) {
|
|||
// Estado para armazenar filtros externos (vindos do teste.tsx)
|
||||
const [filtrosExternos, setFiltrosExternos] = React.useState(filtros);
|
||||
|
||||
// Funções para gerenciar filtros customizados
|
||||
const handleColumnFilterChange = React.useCallback((field: string, values: string[]) => {
|
||||
setColumnFilters(prev => ({
|
||||
...prev,
|
||||
[field]: values
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleColumnSortChange = React.useCallback((field: string, direction: 'asc' | 'desc' | null) => {
|
||||
setColumnSorts(prev => ({
|
||||
...prev,
|
||||
[field]: direction
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Atualizar filtros externos quando os props mudarem
|
||||
React.useEffect(() => {
|
||||
console.log('🔄 Analítico - useEffect dos filtros chamado');
|
||||
|
|
@ -145,199 +358,219 @@ export default function AnaliticoComponent({ filtros }: AnaliticoProps) {
|
|||
}, [fetchData]);
|
||||
|
||||
// Definir colunas do DataGridPro
|
||||
const columns = React.useMemo(() => [
|
||||
{
|
||||
field: "data_vencimento",
|
||||
headerName: "Dt Venc",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
if (!params.value) return "-";
|
||||
try {
|
||||
return new Date(params.value).toLocaleDateString("pt-BR");
|
||||
} catch (error) {
|
||||
return params.value;
|
||||
}
|
||||
const columns = React.useMemo(() => {
|
||||
const baseColumns = [
|
||||
{
|
||||
field: "data_vencimento",
|
||||
headerName: "Dt Venc",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
if (!params.value) return "-";
|
||||
try {
|
||||
return new Date(params.value).toLocaleDateString("pt-BR");
|
||||
} catch (error) {
|
||||
return params.value;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "data_caixa",
|
||||
headerName: "Dt Caixa",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
if (!params.value) return "-";
|
||||
try {
|
||||
return new Date(params.value).toLocaleDateString("pt-BR");
|
||||
} catch (error) {
|
||||
return params.value;
|
||||
}
|
||||
{
|
||||
field: "data_caixa",
|
||||
headerName: "Dt Caixa",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
if (!params.value) return "-";
|
||||
try {
|
||||
return new Date(params.value).toLocaleDateString("pt-BR");
|
||||
} catch (error) {
|
||||
return params.value;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "entidade",
|
||||
headerName: "Entidade",
|
||||
width: 100,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => params.value || "-",
|
||||
},
|
||||
{
|
||||
field: "codigo_fornecedor",
|
||||
headerName: "Cod.Fornec",
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "nome_fornecedor",
|
||||
headerName: "Fornecedor",
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "codigo_centrocusto",
|
||||
headerName: "C Custo",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "codigo_conta",
|
||||
headerName: "Cod.Conta",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "conta",
|
||||
headerName: "Conta",
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "valor",
|
||||
headerName: "Vl.Realizado",
|
||||
type: "number" as const,
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
{
|
||||
field: "entidade",
|
||||
headerName: "Entidade",
|
||||
width: 100,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => params.value || "-",
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_previsto",
|
||||
headerName: "Vl.Previsto",
|
||||
type: "number" as const,
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
{
|
||||
field: "codigo_fornecedor",
|
||||
headerName: "Cod.Fornec",
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_confirmado",
|
||||
headerName: "Vl.Confirmado",
|
||||
type: "number" as const,
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
{
|
||||
field: "nome_fornecedor",
|
||||
headerName: "Fornecedor",
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_pago",
|
||||
headerName: "Vl.Pago",
|
||||
type: "number" as const,
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
{
|
||||
field: "codigo_centrocusto",
|
||||
headerName: "C Custo",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "historico",
|
||||
headerName: "Historico",
|
||||
flex: 1,
|
||||
minWidth: 250,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "historico2",
|
||||
headerName: "Historico 2",
|
||||
flex: 1,
|
||||
minWidth: 300,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "numero_lancamento",
|
||||
headerName: "Num.Lanc",
|
||||
width: 80,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => params.value || "-",
|
||||
},
|
||||
] as any, []);
|
||||
{
|
||||
field: "codigo_conta",
|
||||
headerName: "Cod.Conta",
|
||||
width: 150,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "conta",
|
||||
headerName: "Conta",
|
||||
flex: 1,
|
||||
minWidth: 180,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "valor",
|
||||
headerName: "Vl.Realizado",
|
||||
type: "number" as const,
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_previsto",
|
||||
headerName: "Vl.Previsto",
|
||||
type: "number" as const,
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_confirmado",
|
||||
headerName: "Vl.Confirmado",
|
||||
type: "number" as const,
|
||||
width: 140,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "valor_pago",
|
||||
headerName: "Vl.Pago",
|
||||
type: "number" as const,
|
||||
width: 130,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => {
|
||||
const value = params.value;
|
||||
if (value === null || value === undefined || value === "" || value === 0) return "-";
|
||||
const numValue = typeof value === "string" ? parseFloat(value) : Number(value);
|
||||
if (isNaN(numValue)) return "-";
|
||||
const formatted = new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(numValue);
|
||||
return (
|
||||
<span className={numValue < 0 ? "text-red-600" : "text-gray-900"}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "historico",
|
||||
headerName: "Historico",
|
||||
flex: 1,
|
||||
minWidth: 250,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "historico2",
|
||||
headerName: "Historico 2",
|
||||
flex: 1,
|
||||
minWidth: 300,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
},
|
||||
{
|
||||
field: "numero_lancamento",
|
||||
headerName: "Num.Lanc",
|
||||
width: 80,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
renderCell: (params: any) => params.value || "-",
|
||||
},
|
||||
];
|
||||
|
||||
// Adicionar renderHeader com filtro Excel para todas as colunas
|
||||
return baseColumns.map((col) => ({
|
||||
...col,
|
||||
renderHeader: (params: any) => (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm font-medium">{col.headerName}</span>
|
||||
<ExcelFilter
|
||||
column={col}
|
||||
data={data}
|
||||
onFilterChange={handleColumnFilterChange}
|
||||
onSortChange={handleColumnSortChange}
|
||||
currentFilter={columnFilters[col.field] || []}
|
||||
currentSort={columnSorts[col.field] || null}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
}, [data, columnFilters, columnSorts, handleColumnFilterChange, handleColumnSortChange]);
|
||||
|
||||
// Calcular totais das colunas de valores
|
||||
const columnTotals = React.useMemo(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue