Files
finanzen/src/pages/TransactionsPage.tsx

552 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLInputElement>;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={ariaLabel}
onChange={onToggle}
className="h-4 w-4 rounded border-border accent-primary"
/>
);
});
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<Id<"categories">, 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 (
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex h-6 items-center rounded px-1.5 text-xs hover:bg-accent"
>
{cat ? (
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
{cat.name}
</Badge>
) : (
<span className="text-muted-foreground">Kategorie</span>
)}
</button>
);
}
return (
<Select
defaultOpen
value={tx.categoryId ?? "none"}
onValueChange={(v) => {
if (v !== "none") onUpdate(tx._id, v as Id<"categories">);
setOpen(false);
}}
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
>
<SelectTrigger className="h-7 w-[130px]">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories?.map((c) => (
<SelectItem key={c._id} value={c._id}>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: c.color }}
/>
{c.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
});
const AssignedCell = memo(function AssignedCell({
assignedMonth,
bookingMonth,
}: {
assignedMonth: string | undefined;
bookingMonth: string | undefined;
}) {
if (!assignedMonth || assignedMonth === bookingMonth) return null;
return <Badge variant="outline">{formatMonth(assignedMonth)}</Badge>;
});
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 (
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => onEdit(tx)}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => onAssign(tx)}>
Monat
</Button>
<Button size="sm" variant="ghost" onClick={() => onRemove(tx._id)}>
Löschen
</Button>
</div>
);
});
/* ── 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<Record<string, boolean>>({});
const [editTx, setEditTx] = useState<Tx | null>(null);
const [assignTx, setAssignTx] = useState<Tx | null>(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<ColumnDef<Tx>[]>(
() => [
{
id: "select",
header: () => (
<RowCheckbox
checked={visibleSelectionState.allSelected}
indeterminate={
visibleSelectionState.someSelected && !visibleSelectionState.allSelected
}
disabled={visibleTransactions.length === 0}
ariaLabel={
visibleSelectionState.allSelected
? "Alle sichtbaren Transaktionen abwählen"
: "Alle sichtbaren Transaktionen auswählen"
}
onToggle={(event) => handleToggleAllVisible(event.target.checked)}
/>
),
cell: ({ row }) => (
<RowCheckbox
checked={row.getIsSelected()}
ariaLabel="Transaktion auswählen"
onToggle={(event) => 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 }) => (
<AccountCell
name={row.original.accountId ? accountMap.get(row.original.accountId) : undefined}
/>
),
},
{
id: "category",
header: "Kategorie",
cell: ({ row }) => (
<CategoryCell
tx={row.original}
categories={categories}
categoryMap={categoryMap}
onUpdate={handleUpdateCategory}
/>
),
},
{
accessorKey: "amount",
header: () => <span className="float-right">Betrag</span>,
cell: ({ row }) => (
<span className={`float-right font-medium ${amountClass(row.original.amount)}`}>
{formatAmount(row.original.amount)}
</span>
),
},
{
id: "assigned",
header: "Zuordnung",
cell: ({ row }) => {
const bookingMonth = row.original.bookingDate?.slice(0, 7);
return (
<AssignedCell assignedMonth={row.original.assignedMonth} bookingMonth={bookingMonth} />
);
},
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<ActionsCell
tx={row.original}
onEdit={handleEdit}
onAssign={handleAssign}
onRemove={handleRemove}
/>
),
},
],
[
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<HTMLDivElement>(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 (
<div className="flex h-[calc(100vh-8rem)] min-h-0 flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Suche…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-xs"
/>
<Select value={type} onValueChange={(v) => setType(v as typeof type)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Typen</SelectItem>
<SelectItem value="einnahme">Einnahmen</SelectItem>
<SelectItem value="ausgabe">Ausgaben</SelectItem>
</SelectContent>
</Select>
<CategoryFilter />
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={pendingOnly}
onChange={(e) => setPendingOnly(e.target.checked)}
/>
Nur offene
</label>
<Button variant="outline" onClick={handleResetFilters}>
<RotateCcw className="h-4 w-4" />
Zurücksetzen
</Button>
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
{visibleSelectionState.someSelected && (
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm">
<span className="font-medium">
{visibleSelectionState.selectedCount} ausgewählt
</span>
<span className="text-muted-foreground">Summe</span>
<span className={`font-semibold tabular-nums ${amountClass(selectedTotal)}`}>
{formatAmount(selectedTotal)}
</span>
</div>
)}
{selectedIds.length > 0 && categories && (
<Select
onValueChange={async (categoryId) => {
await bulkCategory({
ids: selectedIds,
categoryId: categoryId as Id<"categories">,
});
toast.success("Kategorie zugewiesen");
setRowSelection({});
}}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Bulk-Kategorie" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c._id} value={c._id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="min-h-0 flex-1 rounded-md border">
<div ref={parentRef} className="relative h-full w-full overflow-auto">
<table className="w-full caption-bottom text-sm">
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id} className="sticky top-0 z-10 bg-background">
{flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && <tr style={{ height: paddingTop }} />}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
data-index={virtualRow.index}
ref={(node: HTMLTableRowElement | null) =>
rowVirtualizer.measureElement(node)
}
className="border-b transition-colors hover:bg-muted/50"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</tr>
);
})}
{paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
</TableBody>
</table>
</div>
</div>
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<TransactionFormDialog
open={!!editTx}
onOpenChange={(o) => !o && setEditTx(null)}
transaction={editTx ?? undefined}
/>
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
</div>
);
}