import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEventHandler, } from "react"; import { useMutation, usePaginatedQuery, useQuery } from "convex/react"; import { flexRender, getCoreRowModel, useReactTable, type ColumnDef, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { api } from "../../convex/_generated/api"; import type { Doc, Id } from "../../convex/_generated/dataModel"; import { useFilters } from "@/context/FilterContext"; import { useAccountFilterId } from "@/components/layout/AccountFilter"; import { CategoryFilter } from "@/components/layout/CategoryFilter"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { amountClass, formatAmount, formatDate, formatMonth } from "@/lib/format"; import { TransactionFormDialog } from "@/components/transactions/TransactionFormDialog"; import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog"; import { toast } from "sonner"; import { getVisibleSelectionState, selectedTransactionsTotal, toggleVisibleSelection, } from "./transactionsSelection"; import { getResetTransactionFilterState } from "@/lib/transactionFilterReset"; import { RotateCcw } from "lucide-react"; type Tx = Doc<"transactions">; type Category = Doc<"categories">; const EMPTY_TRANSACTIONS: Tx[] = []; /* ── Memoized cell components ────────────────────────────────────── */ const RowCheckbox = memo(function RowCheckbox({ checked, indeterminate = false, disabled = false, ariaLabel, onToggle, }: { checked: boolean; indeterminate?: boolean; disabled?: boolean; ariaLabel: string; onToggle: ChangeEventHandler; }) { const ref = useRef(null); useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate; }, [indeterminate]); return ( ); }); const AccountCell = memo(function AccountCell({ name }: { name: string | undefined }) { return <>{name ?? "–"}; }); const CategoryCell = memo(function CategoryCell({ tx, categories, categoryMap, onUpdate, }: { tx: Tx; categories: Category[] | undefined; categoryMap: Map, Category>; onUpdate: (id: Id<"transactions">, categoryId: Id<"categories">) => void; }) { const [open, setOpen] = useState(false); const cat = tx.categoryId ? categoryMap.get(tx.categoryId) : null; if (!open) { return ( ); } return ( ); }); const AssignedCell = memo(function AssignedCell({ assignedMonth, bookingMonth, }: { assignedMonth: string | undefined; bookingMonth: string | undefined; }) { if (!assignedMonth || assignedMonth === bookingMonth) return null; return {formatMonth(assignedMonth)}; }); const ActionsCell = memo(function ActionsCell({ tx, onEdit, onAssign, onRemove, }: { tx: Tx; onEdit: (tx: Tx) => void; onAssign: (tx: Tx) => void; onRemove: (id: Id<"transactions">) => void; }) { return (
); }); /* ── Page ────────────────────────────────────────────────────────── */ export function TransactionsPage() { const [search, setSearch] = useState(""); const [type, setType] = useState<"all" | "einnahme" | "ausgabe">("all"); const [pendingOnly, setPendingOnly] = useState(false); const [rowSelection, setRowSelection] = useState>({}); const [editTx, setEditTx] = useState(null); const [assignTx, setAssignTx] = useState(null); const [createOpen, setCreateOpen] = useState(false); const { categoryIds, from, to, monthBasis, setPreset, setAccountId, setCategoryIds, setMonthBasis, } = useFilters(); const accountId = useAccountFilterId(); const categories = useQuery(api.categories.list); const accounts = useQuery(api.accounts.list); const removeTx = useMutation(api.transactions.remove); const updateTx = useMutation(api.transactions.update); const bulkCategory = useMutation(api.transactions.bulkSetCategory); const { results, status, loadMore } = usePaginatedQuery( api.transactions.list, { search: search || undefined, from, to, accountId, basis: monthBasis, type: type === "all" ? undefined : type, pendingOnly: pendingOnly || undefined, categoryIds: categoryIds.length > 0 ? (categoryIds.filter((id) => id !== "__none__") as Id<"categories">[]) : undefined, withoutCategory: categoryIds.includes("__none__") || undefined, }, { initialNumItems: 50 }, ); const categoryMap = useMemo( () => new Map(categories?.map((c) => [c._id, c])), [categories], ); const accountMap = useMemo( () => new Map(accounts?.map((a) => [a._id, a.name])), [accounts], ); const visibleTransactions = results ?? EMPTY_TRANSACTIONS; const visibleSelectionState = useMemo( () => getVisibleSelectionState(visibleTransactions, rowSelection), [visibleTransactions, rowSelection], ); const selectedTotal = useMemo( () => selectedTransactionsTotal(visibleTransactions, rowSelection), [visibleTransactions, rowSelection], ); const handleUpdateCategory = useCallback( (id: Id<"transactions">, categoryId: Id<"categories">) => { void updateTx({ id, categoryId }); }, [updateTx], ); const handleEdit = useCallback((tx: Tx) => setEditTx(tx), []); const handleAssign = useCallback((tx: Tx) => setAssignTx(tx), []); const handleRemove = useCallback( async (id: Id<"transactions">) => { if (confirm("Transaktion löschen?")) { await removeTx({ id }); toast.success("Gelöscht"); } }, [removeTx], ); const handleToggleAllVisible = useCallback( (checked: boolean) => { setRowSelection((selection) => toggleVisibleSelection(selection, visibleTransactions, checked), ); }, [visibleTransactions], ); const handleResetFilters = useCallback(() => { const reset = getResetTransactionFilterState(); setPreset(reset.global.preset); setAccountId(reset.global.accountId); setCategoryIds([...reset.global.categoryIds]); setMonthBasis(reset.global.monthBasis); setSearch(reset.page.search); setType(reset.page.type); setPendingOnly(reset.page.pendingOnly); setRowSelection(reset.page.rowSelection); }, [setAccountId, setCategoryIds, setMonthBasis, setPreset]); const selectedIds = useMemo( () => visibleTransactions .filter((tx) => rowSelection[tx._id]) .map((tx) => tx._id), [visibleTransactions, rowSelection], ); const columns = useMemo[]>( () => [ { id: "select", header: () => ( handleToggleAllVisible(event.target.checked)} /> ), cell: ({ row }) => ( row.getToggleSelectedHandler()(event)} /> ), }, { accessorKey: "bookingDate", header: "Datum", cell: ({ row }) => row.original.isPending ? "offen" : formatDate(row.original.bookingDate), }, { accessorKey: "description", header: "Beschreibung" }, { accessorKey: "counterparty", header: "Gegenpartei" }, { id: "account", header: "Konto", cell: ({ row }) => ( ), }, { id: "category", header: "Kategorie", cell: ({ row }) => ( ), }, { accessorKey: "amount", header: () => Betrag, cell: ({ row }) => ( {formatAmount(row.original.amount)} ), }, { id: "assigned", header: "Zuordnung", cell: ({ row }) => { const bookingMonth = row.original.bookingDate?.slice(0, 7); return ( ); }, }, { id: "actions", header: "", cell: ({ row }) => ( ), }, ], [ categories, categoryMap, accountMap, handleUpdateCategory, handleEdit, handleAssign, handleRemove, visibleSelectionState, visibleTransactions.length, handleToggleAllVisible, ], ); const table = useReactTable({ data: visibleTransactions, columns, state: { rowSelection }, onRowSelectionChange: setRowSelection, getRowId: (row) => row._id, getCoreRowModel: getCoreRowModel(), }); /* ── Virtualization ── */ const parentRef = useRef(null); const rows = table.getRowModel().rows; const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => parentRef.current, estimateSize: () => 44, overscan: 10, }); const virtualRows = rowVirtualizer.getVirtualItems(); const totalSize = rowVirtualizer.getTotalSize(); const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const paddingBottom = virtualRows.length > 0 ? totalSize - virtualRows[virtualRows.length - 1].end : 0; /* ── Auto-load more when scrolling near the end ── */ useEffect(() => { if (status !== "CanLoadMore") return; const last = virtualRows[virtualRows.length - 1]; if (last && last.index >= rows.length - 10) { loadMore(50); } }, [virtualRows, rows.length, status, loadMore]); return (
setSearch(e.target.value)} className="max-w-xs" /> {visibleSelectionState.someSelected && (
{visibleSelectionState.selectedCount} ausgewählt Summe {formatAmount(selectedTotal)}
)} {selectedIds.length > 0 && categories && ( )}
{table.getHeaderGroups().map((hg) => ( {hg.headers.map((h) => ( {flexRender(h.column.columnDef.header, h.getContext())} ))} ))} {paddingTop > 0 && } {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; return ( rowVirtualizer.measureElement(node) } className="border-b transition-colors hover:bg-muted/50" > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} ); })} {paddingBottom > 0 && }
!o && setEditTx(null)} transaction={editTx ?? undefined} /> setAssignTx(null)} />
); }