Add savings chat analysis feature

This commit is contained in:
Matthias
2026-06-15 18:26:25 +02:00
parent d65e7681ac
commit 4869402d45
26 changed files with 2789 additions and 163 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
import {
flexRender,
@@ -6,12 +6,15 @@ import {
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 { 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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";
@@ -19,16 +22,138 @@ import { AssignMonthDialog } from "@/components/transactions/AssignMonthDialog";
import { toast } from "sonner";
type Tx = Doc<"transactions">;
type Category = Doc<"categories">;
/* ── Memoized cell components ────────────────────────────────────── */
const RowCheckbox = memo(function RowCheckbox({
checked,
onToggle,
}: {
checked: boolean;
onToggle: (e?: unknown) => void;
}) {
return <input type="checkbox" checked={checked} onChange={onToggle} />;
});
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 [selected, setSelected] = useState<Id<"transactions">[]>([]);
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 } = useFilters();
const categories = useQuery(api.categories.list);
const accounts = useQuery(api.accounts.list);
const removeTx = useMutation(api.transactions.remove);
@@ -41,12 +166,46 @@ export function TransactionsPage() {
search: search || undefined,
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 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 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 selectedIds = useMemo(
() => Object.keys(rowSelection).filter((k) => rowSelection[k]) as Id<"transactions">[],
[rowSelection],
);
const columns = useMemo<ColumnDef<Tx>[]>(
() => [
@@ -54,16 +213,9 @@ export function TransactionsPage() {
id: "select",
header: () => null,
cell: ({ row }) => (
<input
type="checkbox"
checked={selected.includes(row.original._id)}
onChange={(e) => {
setSelected((prev) =>
e.target.checked
? [...prev, row.original._id]
: prev.filter((id) => id !== row.original._id),
);
}}
<RowCheckbox
checked={row.getIsSelected()}
onToggle={row.getToggleSelectedHandler()}
/>
),
},
@@ -78,36 +230,23 @@ export function TransactionsPage() {
{
id: "account",
header: "Konto",
cell: ({ row }) =>
row.original.accountId ? accountMap.get(row.original.accountId) ?? "" : "",
cell: ({ row }) => (
<AccountCell
name={row.original.accountId ? accountMap.get(row.original.accountId) : undefined}
/>
),
},
{
id: "category",
header: "Kategorie",
cell: ({ row }) => {
return (
<Select
value={row.original.categoryId ?? "none"}
onValueChange={async (v) => {
if (v === "none") return;
await updateTx({ id: row.original._id, categoryId: v as Id<"categories"> });
}}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Kategorie" />
</SelectTrigger>
<SelectContent>
{categories?.map((c) => (
<SelectItem key={c._id} value={c._id}>
<Badge style={{ backgroundColor: c.color, color: "#fff", border: "none" }}>
{c.name}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
);
},
cell: ({ row }) => (
<CategoryCell
tx={row.original}
categories={categories}
categoryMap={categoryMap}
onUpdate={handleUpdateCategory}
/>
),
},
{
accessorKey: "amount",
@@ -123,44 +262,72 @@ export function TransactionsPage() {
header: "Zuordnung",
cell: ({ row }) => {
const bookingMonth = row.original.bookingDate?.slice(0, 7);
if (!row.original.assignedMonth || row.original.assignedMonth === bookingMonth) return null;
return <Badge variant="outline">{formatMonth(row.original.assignedMonth)}</Badge>;
return (
<AssignedCell assignedMonth={row.original.assignedMonth} bookingMonth={bookingMonth} />
);
},
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setEditTx(row.original)}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => setAssignTx(row.original)}>
Monat
</Button>
<Button
size="sm"
variant="ghost"
onClick={async () => {
if (confirm("Transaktion löschen?")) {
await removeTx({ id: row.original._id });
toast.success("Gelöscht");
}
}}
>
Löschen
</Button>
</div>
<ActionsCell
tx={row.original}
onEdit={handleEdit}
onAssign={handleAssign}
onRemove={handleRemove}
/>
),
},
],
[categories, categoryMap, accountMap, selected, updateTx, removeTx],
[
categories,
categoryMap,
accountMap,
handleUpdateCategory,
handleEdit,
handleAssign,
handleRemove,
],
);
const table = useReactTable({ data: results ?? [], columns, getCoreRowModel: getCoreRowModel() });
const table = useReactTable({
data: results ?? [],
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="space-y-4">
<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…"
@@ -178,17 +345,25 @@ export function TransactionsPage() {
<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)} />
<input
type="checkbox"
checked={pendingOnly}
onChange={(e) => setPendingOnly(e.target.checked)}
/>
Nur offene
</label>
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
{selected.length > 0 && categories && (
{selectedIds.length > 0 && categories && (
<Select
onValueChange={async (categoryId) => {
await bulkCategory({ ids: selected, categoryId: categoryId as Id<"categories"> });
await bulkCategory({
ids: selectedIds,
categoryId: categoryId as Id<"categories">,
});
toast.success("Kategorie zugewiesen");
setSelected([]);
setRowSelection({});
}}
>
<SelectTrigger className="w-[200px]">
@@ -205,37 +380,53 @@ export function TransactionsPage() {
)}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>{flexRender(h.column.columnDef.header, h.getContext())}</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<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>
{status === "CanLoadMore" && (
<Button variant="outline" onClick={() => loadMore(50)}>
Mehr laden
</Button>
)}
<TransactionFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<TransactionFormDialog open={!!editTx} onOpenChange={(o) => !o && setEditTx(null)} transaction={editTx ?? undefined} />
<TransactionFormDialog
open={!!editTx}
onOpenChange={(o) => !o && setEditTx(null)}
transaction={editTx ?? undefined}
/>
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
</div>
);