mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
Filtros multi-seleção em lançamentos (condição, forma de pagamento, pessoa, categoria, conta/cartão), changelog redesenhado como timeline colapsável com detecção de bump e resumo, e diálogos migrados para as animações utilitárias do tw-animate-css. Inclui ajustes de label no BulkActionDialog, refinamentos visuais na landing page e atualização da navbar. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
425 lines
12 KiB
TypeScript
425 lines
12 KiB
TypeScript
"use client";
|
|
import {
|
|
RiArrowLeftRightLine,
|
|
RiFileExcel2Line,
|
|
RiFlashlightFill,
|
|
} from "@remixicon/react";
|
|
import {
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
type RowSelectionState,
|
|
type SortingState,
|
|
useReactTable,
|
|
type VisibilityState,
|
|
} from "@tanstack/react-table";
|
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
import { type ReactNode, useMemo, useState } from "react";
|
|
import type {
|
|
TransactionsExportContext,
|
|
TransactionsPaginationState,
|
|
} from "@/features/transactions/lib/export-types";
|
|
import { EmptyState } from "@/shared/components/feedback/empty-state";
|
|
import { Button } from "@/shared/components/ui/button";
|
|
import { Card, CardContent } from "@/shared/components/ui/card";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/shared/components/ui/table";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@/shared/components/ui/tooltip";
|
|
import { cn } from "@/shared/utils/ui";
|
|
import { TransactionsExport } from "../transactions-export";
|
|
import type {
|
|
AccountCardFilterOption,
|
|
TransactionFilterOption,
|
|
TransactionItem,
|
|
} from "../types";
|
|
import { TransactionsBulkBar } from "./transactions-bulk-bar";
|
|
import { getTransactionColumns } from "./transactions-columns";
|
|
import { TransactionsFilters } from "./transactions-filters";
|
|
import { TransactionsPagination } from "./transactions-pagination";
|
|
|
|
type TransactionsTableProps = {
|
|
data: TransactionItem[];
|
|
currentUserId: string;
|
|
noteAsColumn?: boolean;
|
|
columnOrder?: string[] | null;
|
|
payerFilterOptions?: TransactionFilterOption[];
|
|
categoryFilterOptions?: TransactionFilterOption[];
|
|
accountCardFilterOptions?: AccountCardFilterOption[];
|
|
selectedPeriod?: string;
|
|
pagination?: TransactionsPaginationState;
|
|
exportContext?: TransactionsExportContext;
|
|
createSlot?: ReactNode;
|
|
onMassAdd?: () => void;
|
|
onEdit?: (item: TransactionItem) => void;
|
|
onCopy?: (item: TransactionItem) => void;
|
|
onImport?: (item: TransactionItem) => void;
|
|
onConfirmDelete?: (item: TransactionItem) => void;
|
|
onBulkDelete?: (items: TransactionItem[]) => void;
|
|
onBulkImport?: (items: TransactionItem[]) => void;
|
|
onViewDetails?: (item: TransactionItem) => void;
|
|
onRefund?: (item: TransactionItem) => void;
|
|
onToggleSettlement?: (item: TransactionItem) => void;
|
|
onAnticipate?: (item: TransactionItem) => void;
|
|
onViewAnticipationHistory?: (item: TransactionItem) => void;
|
|
isSettlementLoading?: (id: string) => boolean;
|
|
showActions?: boolean;
|
|
showFilters?: boolean;
|
|
};
|
|
|
|
export function TransactionsTable({
|
|
data,
|
|
currentUserId,
|
|
noteAsColumn = false,
|
|
columnOrder: columnOrderPreference = null,
|
|
payerFilterOptions = [],
|
|
categoryFilterOptions = [],
|
|
accountCardFilterOptions = [],
|
|
selectedPeriod,
|
|
pagination: serverPagination,
|
|
exportContext,
|
|
createSlot,
|
|
onMassAdd,
|
|
onEdit,
|
|
onCopy,
|
|
onImport,
|
|
onConfirmDelete,
|
|
onBulkDelete,
|
|
onBulkImport,
|
|
onViewDetails,
|
|
onRefund,
|
|
onToggleSettlement,
|
|
onAnticipate,
|
|
onViewAnticipationHistory,
|
|
isSettlementLoading,
|
|
showActions = true,
|
|
showFilters = true,
|
|
}: TransactionsTableProps) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const [sorting, setSorting] = useState<SortingState>([
|
|
{ id: "purchaseDate", desc: true },
|
|
]);
|
|
const [columnVisibility] = useState<VisibilityState>({
|
|
purchaseDate: false,
|
|
});
|
|
const [pagination, setPagination] = useState({
|
|
pageIndex: 0,
|
|
pageSize: 30,
|
|
});
|
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
const isServerPaginated = Boolean(serverPagination);
|
|
|
|
const columns = useMemo(
|
|
() =>
|
|
getTransactionColumns({
|
|
currentUserId,
|
|
noteAsColumn,
|
|
onEdit,
|
|
onCopy,
|
|
onImport,
|
|
onConfirmDelete,
|
|
onViewDetails,
|
|
onRefund,
|
|
onToggleSettlement,
|
|
onAnticipate,
|
|
onViewAnticipationHistory,
|
|
isSettlementLoading: isSettlementLoading ?? (() => false),
|
|
showActions,
|
|
columnOrder: columnOrderPreference,
|
|
}),
|
|
[
|
|
currentUserId,
|
|
noteAsColumn,
|
|
columnOrderPreference,
|
|
onEdit,
|
|
onCopy,
|
|
onImport,
|
|
onConfirmDelete,
|
|
onViewDetails,
|
|
onRefund,
|
|
onToggleSettlement,
|
|
onAnticipate,
|
|
onViewAnticipationHistory,
|
|
isSettlementLoading,
|
|
showActions,
|
|
],
|
|
);
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
state: isServerPaginated
|
|
? { sorting, columnVisibility, rowSelection }
|
|
: { sorting, columnVisibility, pagination, rowSelection },
|
|
onSortingChange: setSorting,
|
|
onPaginationChange: isServerPaginated ? undefined : setPagination,
|
|
onRowSelectionChange: setRowSelection,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getPaginationRowModel: isServerPaginated
|
|
? undefined
|
|
: getPaginationRowModel(),
|
|
manualPagination: isServerPaginated,
|
|
pageCount: serverPagination?.totalPages,
|
|
enableRowSelection: true,
|
|
});
|
|
|
|
const rowModel = table.getRowModel();
|
|
const hasRows = rowModel.rows.length > 0;
|
|
const totalRows = isServerPaginated
|
|
? (serverPagination?.totalItems ?? 0)
|
|
: table.getCoreRowModel().rows.length;
|
|
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
|
const selectedCount = selectedRows.length;
|
|
const selectedTotal = selectedRows.reduce(
|
|
(total, row) => total + (row.original.amount ?? 0),
|
|
0,
|
|
);
|
|
const currentPage = isServerPaginated
|
|
? (serverPagination?.page ?? 1)
|
|
: table.getState().pagination.pageIndex + 1;
|
|
const currentPageSize = isServerPaginated
|
|
? (serverPagination?.pageSize ?? pagination.pageSize)
|
|
: pagination.pageSize;
|
|
const totalPages = isServerPaginated
|
|
? Math.max(serverPagination?.totalPages ?? 1, 1)
|
|
: Math.max(table.getPageCount(), 1);
|
|
const canPreviousPage = currentPage > 1;
|
|
const canNextPage = currentPage < totalPages;
|
|
|
|
const hasOtherUserData = data.some((item) => item.userId !== currentUserId);
|
|
|
|
const handleBulkDelete = () => {
|
|
if (onBulkDelete && selectedCount > 0) {
|
|
onBulkDelete(selectedRows.map((row) => row.original));
|
|
setRowSelection({});
|
|
}
|
|
};
|
|
|
|
const handleBulkImport = () => {
|
|
if (onBulkImport && selectedCount > 0) {
|
|
onBulkImport(selectedRows.map((row) => row.original));
|
|
setRowSelection({});
|
|
}
|
|
};
|
|
|
|
const navigateToPage = (nextPage: number, nextPageSize = currentPageSize) => {
|
|
const nextParams = new URLSearchParams(searchParams.toString());
|
|
if (nextPage <= 1) {
|
|
nextParams.delete("page");
|
|
} else {
|
|
nextParams.set("page", nextPage.toString());
|
|
}
|
|
if (nextPageSize === 30) {
|
|
nextParams.delete("pageSize");
|
|
} else {
|
|
nextParams.set("pageSize", nextPageSize.toString());
|
|
}
|
|
const target = nextParams.toString()
|
|
? `${pathname}?${nextParams.toString()}`
|
|
: pathname;
|
|
router.replace(target, { scroll: false });
|
|
setRowSelection({});
|
|
};
|
|
|
|
const handlePageChange = (nextPage: number) => {
|
|
if (isServerPaginated) {
|
|
navigateToPage(nextPage);
|
|
} else {
|
|
table.setPageIndex(nextPage - 1);
|
|
}
|
|
};
|
|
|
|
const handlePageSizeChange = (size: number) => {
|
|
if (isServerPaginated) {
|
|
navigateToPage(1, size);
|
|
} else {
|
|
table.setPageSize(size);
|
|
}
|
|
};
|
|
|
|
const showTopControls =
|
|
Boolean(createSlot) || Boolean(onMassAdd) || showFilters;
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
{showTopControls ? (
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
{createSlot || onMassAdd ? (
|
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row">
|
|
{createSlot}
|
|
{onMassAdd ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={onMassAdd}
|
|
variant="outline"
|
|
size="icon"
|
|
className="hidden size-9 sm:inline-flex"
|
|
>
|
|
<RiFlashlightFill className="size-4" />
|
|
<span className="sr-only">
|
|
Adicionar múltiplos lançamentos
|
|
</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Adicionar múltiplos lançamentos</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
onClick={() => router.push("/transactions/import")}
|
|
variant="outline"
|
|
size="icon"
|
|
className="hidden size-9 sm:inline-flex"
|
|
>
|
|
<RiFileExcel2Line className="size-4" />
|
|
<span className="sr-only">Importar extrato</span>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Importar extrato</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
) : (
|
|
<span className={showFilters ? "hidden sm:block" : ""} />
|
|
)}
|
|
|
|
{showFilters ? (
|
|
<TransactionsFilters
|
|
payerOptions={payerFilterOptions}
|
|
categoryOptions={categoryFilterOptions}
|
|
accountCardOptions={accountCardFilterOptions}
|
|
className="w-full lg:flex-1 lg:justify-end"
|
|
hideAdvancedFilters={hasOtherUserData}
|
|
exportButton={
|
|
selectedPeriod ? (
|
|
<TransactionsExport
|
|
lancamentos={data}
|
|
period={selectedPeriod}
|
|
exportContext={exportContext}
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedCount > 0 &&
|
|
onBulkDelete &&
|
|
selectedRows.every((row) => row.original.userId === currentUserId) ? (
|
|
<TransactionsBulkBar
|
|
selectedCount={selectedCount}
|
|
selectedTotal={selectedTotal}
|
|
mode="delete"
|
|
onAction={handleBulkDelete}
|
|
/>
|
|
) : null}
|
|
|
|
{selectedCount > 0 &&
|
|
onBulkImport &&
|
|
selectedRows.some((row) => row.original.userId !== currentUserId) ? (
|
|
<TransactionsBulkBar
|
|
selectedCount={selectedCount}
|
|
selectedTotal={selectedTotal}
|
|
mode="import"
|
|
onAction={handleBulkImport}
|
|
/>
|
|
) : null}
|
|
|
|
<Card className="py-2">
|
|
<CardContent className="px-2 py-4 sm:px-4">
|
|
{hasRows ? (
|
|
<>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => (
|
|
<TableHead
|
|
key={header.id}
|
|
className="whitespace-nowrap"
|
|
>
|
|
{header.isPlaceholder
|
|
? null
|
|
: flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rowModel.rows.map((row) => (
|
|
<TableRow
|
|
key={row.id}
|
|
className={cn(
|
|
row.original.paymentMethod === "Boleto" &&
|
|
row.original.dueDate &&
|
|
!row.original.isSettled &&
|
|
new Date(row.original.dueDate) < new Date()
|
|
? "bg-destructive/3 hover:bg-destructive/5"
|
|
: undefined,
|
|
)}
|
|
>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
<TransactionsPagination
|
|
totalRows={totalRows}
|
|
currentPage={currentPage}
|
|
currentPageSize={currentPageSize}
|
|
totalPages={totalPages}
|
|
canPreviousPage={canPreviousPage}
|
|
canNextPage={canNextPage}
|
|
onPageChange={handlePageChange}
|
|
onPageSizeChange={handlePageSizeChange}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="flex w-full items-center justify-center py-12">
|
|
<EmptyState
|
|
media={<RiArrowLeftRightLine className="size-6 text-primary" />}
|
|
title="Nenhum lançamento encontrado"
|
|
description="Ajuste os filtros ou cadastre um novo lançamento para visualizar aqui."
|
|
/>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TooltipProvider>
|
|
);
|
|
}
|