initial commit

This commit is contained in:
Matthias
2026-06-15 11:33:23 +02:00
commit fc0a6fb975
155 changed files with 24526 additions and 0 deletions

View File

@@ -0,0 +1,242 @@
import { useMemo, useState } from "react";
import { useMutation, usePaginatedQuery, useQuery } from "convex/react";
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import { api } from "../../convex/_generated/api";
import type { Doc, Id } from "../../convex/_generated/dataModel";
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 { 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";
type Tx = Doc<"transactions">;
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 [editTx, setEditTx] = useState<Tx | null>(null);
const [assignTx, setAssignTx] = useState<Tx | null>(null);
const [createOpen, setCreateOpen] = useState(false);
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,
type: type === "all" ? undefined : type,
pendingOnly: pendingOnly || 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 columns = useMemo<ColumnDef<Tx>[]>(
() => [
{
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),
);
}}
/>
),
},
{
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 }) =>
row.original.accountId ? accountMap.get(row.original.accountId) ?? "" : "",
},
{
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>
);
},
},
{
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);
if (!row.original.assignedMonth || row.original.assignedMonth === bookingMonth) return null;
return <Badge variant="outline">{formatMonth(row.original.assignedMonth)}</Badge>;
},
},
{
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>
),
},
],
[categories, categoryMap, accountMap, selected, updateTx, removeTx],
);
const table = useReactTable({ data: results ?? [], columns, getCoreRowModel: getCoreRowModel() });
return (
<div className="space-y-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>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={pendingOnly} onChange={(e) => setPendingOnly(e.target.checked)} />
Nur offene
</label>
<Button onClick={() => setCreateOpen(true)}>Neu</Button>
{selected.length > 0 && categories && (
<Select
onValueChange={async (categoryId) => {
await bulkCategory({ ids: selected, categoryId: categoryId as Id<"categories"> });
toast.success("Kategorie zugewiesen");
setSelected([]);
}}
>
<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="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>
{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} />
<AssignMonthDialog transaction={assignTx} onClose={() => setAssignTx(null)} />
</div>
);
}