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

@@ -10,6 +10,7 @@ import { DashboardPage } from "./pages/DashboardPage";
import { TransactionsPage } from "./pages/TransactionsPage";
import { CategoriesPage } from "./pages/CategoriesPage";
import { LoansPage } from "./pages/LoansPage";
import { SavingsChatPage } from "./pages/SavingsChatPage";
import { ImportPage } from "./pages/ImportPage";
import { SettingsPage } from "./pages/SettingsPage";
import { Skeleton } from "./components/ui/skeleton";
@@ -49,6 +50,7 @@ const router = createBrowserRouter([
children: [
{ path: "/", element: <DashboardPage /> },
{ path: "/transaktionen", element: <TransactionsPage /> },
{ path: "/talk", element: <SavingsChatPage /> },
{ path: "/kategorien", element: <CategoriesPage /> },
{ path: "/kredite", element: <LoansPage /> },
{ path: "/import", element: <ImportPage /> },

View File

@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { toCategoryPieData } from "./categoryBreakdownData";
describe("toCategoryPieData", () => {
test("uses positive chart values while preserving signed expense amounts", () => {
expect(
toCategoryPieData([
{
name: "Lebensmittel",
amount: -123.45,
color: "#ef4444",
block: "variabel",
},
{
name: "Rueckerstattung",
amount: 12,
color: "#22c55e",
},
]),
).toEqual([
{
name: "Lebensmittel",
amount: -123.45,
chartAmount: 123.45,
color: "#ef4444",
block: "variabel",
},
{
name: "Rueckerstattung",
amount: 12,
chartAmount: 12,
color: "#22c55e",
},
]);
});
});

View File

@@ -3,24 +3,20 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recha
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { formatAmount } from "@/lib/format";
import { type CategoryBreakdownItem, toCategoryPieData } from "./categoryBreakdownData";
type Item = {
name: string;
amount: number;
color: string;
block?: "wiederkehrend" | "variabel";
};
export function CategoryBreakdownChart({ data }: { data: Item[] }) {
export function CategoryBreakdownChart({ data }: { data: CategoryBreakdownItem[] }) {
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
const filtered = data.filter((d) => {
if (filter === "all") return true;
return d.block === filter;
});
const pieData = toCategoryPieData(filtered);
const total = pieData.reduce((sum, item) => sum + item.chartAmount, 0);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle>Ausgaben nach Kategorie</CardTitle>
<div className="flex gap-1">
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
@@ -30,18 +26,56 @@ export function CategoryBreakdownChart({ data }: { data: Item[] }) {
))}
</div>
</CardHeader>
<CardContent className="h-80">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={filtered} dataKey="amount" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{filtered.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => formatAmount(Number(v ?? 0))} />
<Legend />
</PieChart>
</ResponsiveContainer>
<CardContent>
{pieData.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine Ausgaben im gewählten Zeitraum</p>
) : (
<div className="grid gap-6 lg:grid-cols-[minmax(260px,0.85fr)_minmax(320px,1.15fr)] lg:items-center">
<div className="h-72 min-h-72 min-w-0 w-full">
<ResponsiveContainer width="100%" height="100%" minWidth={240} minHeight={240}>
<PieChart>
<Pie data={pieData} dataKey="chartAmount" nameKey="name" cx="50%" cy="50%" outerRadius={105}>
{pieData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip
formatter={(v, _name, item) =>
formatAmount(
typeof item.payload?.amount === "number" ? item.payload.amount : Number(v ?? 0),
)
}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="max-h-72 min-w-0 overflow-y-auto pr-1">
<ul className="space-y-2">
{pieData.map((entry) => {
const share = total > 0 ? entry.chartAmount / total : 0;
return (
<li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm">
<div className="flex min-w-0 items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-sm"
style={{ backgroundColor: entry.color }}
aria-hidden="true"
/>
<span className="truncate font-medium">{entry.name}</span>
</div>
<div className="text-right tabular-nums">
<div className="font-medium">{formatAmount(entry.amount)}</div>
<div className="text-xs text-muted-foreground">{Math.round(share * 100)}%</div>
</div>
</li>
);
})}
</ul>
</div>
</div>
)}
</CardContent>
</Card>
);
@@ -67,13 +101,21 @@ export function FixedVariableSplit({
<CardContent className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={50}
outerRadius={80}
stroke="var(--card)"
strokeWidth={2}
>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
<Legend />
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
<Legend wrapperStyle={{ fontSize: 14 }} iconType="circle" />
</PieChart>
</ResponsiveContainer>
</CardContent>

View File

@@ -10,10 +10,12 @@ import {
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { eur } from "@/lib/format";
import { eur, formatEurCompact } from "@/lib/format";
type Point = { month: string; income: number; expenses: number; balance: number };
const axisTick = { fontSize: 13, fill: "var(--muted-foreground)" };
export function MonthlyTrendChart({ data }: { data: Point[] }) {
return (
<Card>
@@ -22,12 +24,25 @@ export function MonthlyTrendChart({ data }: { data: Point[] }) {
</CardHeader>
<CardContent className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis tickFormatter={(v) => eur.format(v)} />
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tick={axisTick}
tickLine={false}
axisLine={{ stroke: "var(--border)" }}
height={44}
interval="preserveStartEnd"
/>
<YAxis
tickFormatter={formatEurCompact}
tick={axisTick}
tickLine={false}
axisLine={false}
width={64}
/>
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
<Legend />
<Legend wrapperStyle={{ fontSize: 14, paddingTop: 8 }} />
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />

View File

@@ -0,0 +1,17 @@
export type CategoryBreakdownItem = {
name: string;
amount: number;
color: string;
block?: "wiederkehrend" | "variabel";
};
export type CategoryPieItem = CategoryBreakdownItem & {
chartAmount: number;
};
export function toCategoryPieData(data: CategoryBreakdownItem[]): CategoryPieItem[] {
return data.map((item) => ({
...item,
chartAmount: Math.abs(item.amount),
}));
}

View File

@@ -0,0 +1,88 @@
import { MessageCircle, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type ChatHistoryItem = {
id: string;
title: string;
updatedAt: number;
messageCount: number;
};
type ChatHistoryProps = {
items: ChatHistoryItem[];
activeId: string;
onSelect: (id: string) => void;
onCreate: () => void;
onDelete: (id: string) => void;
};
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
export function ChatHistory({
items,
activeId,
onSelect,
onCreate,
onDelete,
}: ChatHistoryProps) {
return (
<aside className="rounded-xl border bg-card text-card-foreground shadow-sm">
<div className="flex items-center justify-between gap-2 border-b p-3">
<div className="flex min-w-0 items-center gap-2">
<MessageCircle className="h-4 w-4 shrink-0" />
<h2 className="truncate text-sm font-semibold">Chat-Historie</h2>
</div>
<Button type="button" variant="outline" size="icon" onClick={onCreate} title="Neuer Chat">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="max-h-[64vh] overflow-y-auto p-2">
{items.length === 0 ? (
<p className="px-2 py-6 text-center text-sm text-muted-foreground">
Noch keine Chats
</p>
) : (
<div className="space-y-1">
{items.map((item) => (
<div
key={item.id}
className={cn(
"group flex items-center gap-1 rounded-md",
item.id === activeId ? "bg-accent" : "hover:bg-muted",
)}
>
<button
type="button"
onClick={() => onSelect(item.id)}
className="min-w-0 flex-1 px-2 py-2 text-left"
>
<span className="block truncate text-sm font-medium">{item.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{dateFormatter.format(new Date(item.updatedAt))} · {item.messageCount} Nachrichten
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="mr-1 h-8 w-8 opacity-70 hover:opacity-100"
onClick={() => onDelete(item.id)}
title="Chat löschen"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,129 @@
import { useMemo, useState } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, ChevronDown, X } from "lucide-react";
import { useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { useFilters } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const NONE_VALUE = "__none__";
export function CategoryFilter() {
const categories = useQuery(api.categories.list);
const { categoryIds, setCategoryIds } = useFilters();
const [open, setOpen] = useState(false);
const selectedSet = useMemo(() => new Set(categoryIds), [categoryIds]);
const noneSelected = selectedSet.has(NONE_VALUE);
const toggle = (value: string) => {
const next = new Set(categoryIds);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
setCategoryIds(Array.from(next));
};
const clear = () => setCategoryIds([]);
const label = useMemo(() => {
if (categoryIds.length === 0) return "Alle Kategorien";
const names: string[] = [];
if (noneSelected) names.push("Ohne Kategorie");
categories?.forEach((c) => {
if (selectedSet.has(c._id)) names.push(c.name);
});
if (names.length === 1) return names[0];
return `${names.length} Kategorien`;
}, [categoryIds.length, noneSelected, categories, selectedSet]);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<Button variant="outline" className="w-[200px] justify-between px-3">
<span className="truncate">{label}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
sideOffset={4}
className="z-50 w-[220px] rounded-md border bg-popover p-2 text-popover-foreground shadow-md"
>
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-xs font-medium text-muted-foreground">Kategorien filtern</span>
{categoryIds.length > 0 && (
<button
type="button"
onClick={clear}
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" /> Zurücksetzen
</button>
)}
</div>
<div className="max-h-72 overflow-auto">
<CategoryItem
value={NONE_VALUE}
label="Ohne Kategorie"
color="#9ca3af"
checked={noneSelected}
onToggle={() => toggle(NONE_VALUE)}
/>
{categories?.map((c) => (
<CategoryItem
key={c._id}
value={c._id}
label={c.name}
color={c.color}
checked={selectedSet.has(c._id)}
onToggle={() => toggle(c._id)}
/>
))}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}
function CategoryItem({
value,
label,
color,
checked,
onToggle,
}: {
value: string;
label: string;
color: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<label
htmlFor={`cat-filter-${value}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent",
checked && "bg-accent/50",
)}
>
<span className="flex h-4 w-4 items-center justify-center rounded border">
{checked && <Check className="h-3 w-3" />}
</span>
<input
id={`cat-filter-${value}`}
type="checkbox"
className="sr-only"
checked={checked}
onChange={onToggle}
/>
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
<span className="flex-1 truncate">{label}</span>
</label>
);
}

View File

@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
import {
CreditCard,
FolderTree,
MessageCircle,
Import,
LayoutDashboard,
Settings,
@@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
const links = [
{ to: "/", label: "Übersicht", icon: LayoutDashboard },
{ to: "/transaktionen", label: "Transaktionen", icon: Wallet },
{ to: "/talk", label: "Talk to Savings", icon: MessageCircle },
{ to: "/kategorien", label: "Kategorien", icon: FolderTree },
{ to: "/kredite", label: "Kredite", icon: CreditCard },
{ to: "/import", label: "CSV & comdirect", icon: Import },

View File

@@ -27,6 +27,8 @@ type FilterContextValue = {
setCustomRange: (from: string, to: string) => void;
accountId: string | undefined;
setAccountId: (id: string | undefined) => void;
categoryIds: string[];
setCategoryIds: (ids: string[]) => void;
monthBasis: MonthBasis;
setMonthBasis: (basis: MonthBasis) => void;
};
@@ -63,6 +65,7 @@ export function FilterProvider({ children }: { children: ReactNode }) {
const [customFrom, setCustomFrom] = useState<string>();
const [customTo, setCustomTo] = useState<string>();
const [accountId, setAccountId] = useState<string>();
const [categoryIds, setCategoryIds] = useState<string[]>([]);
const [monthBasis, setMonthBasis] = useState<MonthBasis>("effective");
const { from, to } = useMemo(
@@ -83,10 +86,12 @@ export function FilterProvider({ children }: { children: ReactNode }) {
},
accountId,
setAccountId,
categoryIds,
setCategoryIds,
monthBasis,
setMonthBasis,
}),
[preset, from, to, accountId, monthBasis],
[preset, from, to, accountId, categoryIds, monthBasis],
);
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;

View File

@@ -29,6 +29,17 @@ export function formatAmount(amount: number): string {
return eur.format(amount);
}
export function formatEurCompact(value: number): string {
const abs = Math.abs(value);
const sign = value < 0 ? "-" : "";
const suffix = (n: number, digits = 1) =>
`${sign}${n.toFixed(digits).replace(".", ",")}`;
if (abs >= 1_000_000) return `${suffix(abs / 1_000_000)} Mio. €`;
if (abs >= 10_000) return `${suffix(abs / 1000)}k €`;
if (abs >= 1000) return `${suffix(abs / 1000, 2)}k €`;
return eur.format(value);
}
export function amountClass(amount: number): string {
if (amount < 0) return "text-red-600 dark:text-red-400";
if (amount > 0) return "text-emerald-600 dark:text-emerald-400";

View File

@@ -0,0 +1,265 @@
import { type FormEvent, useEffect, useRef, useState } from "react";
import { useAction, useQuery } from "convex/react";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { api } from "../../convex/_generated/api";
import { useAccountFilterId } from "@/components/layout/AccountFilter";
import { useFilters } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ChatHistory, type ChatHistoryItem } from "@/components/chat/ChatHistory";
import { toast } from "sonner";
type ChatMessage = { role: "user" | "assistant"; content: string };
type ChatSession = {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messages: ChatMessage[];
};
const STORAGE_KEY = "savings-chat-sessions";
const initialAssistantMessage: ChatMessage = {
role: "assistant",
content: "Frag mich zu deinen Umsätzen ich werte sie im aktuellen Zeitraum aus.",
};
const fallbackMessages = [initialAssistantMessage];
function createSession(): ChatSession {
const now = Date.now();
const randomId =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `${now}-${Math.random().toString(36).slice(2)}`;
return {
id: randomId,
title: "Neuer Chat",
createdAt: now,
updatedAt: now,
messages: [initialAssistantMessage],
};
}
function loadSessions(): ChatSession[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [createSession()];
const parsed = JSON.parse(raw) as ChatSession[];
if (!Array.isArray(parsed) || parsed.length === 0) return [createSession()];
return parsed;
} catch {
return [createSession()];
}
}
function titleFromMessages(messages: ChatMessage[]) {
const firstUserMessage = messages.find((message) => message.role === "user")?.content.trim();
if (!firstUserMessage) return "Neuer Chat";
return firstUserMessage.length > 44 ? `${firstUserMessage.slice(0, 44)}` : firstUserMessage;
}
export function SavingsChatPage() {
const { from, to, monthBasis } = useFilters();
const accountId = useAccountFilterId();
const [draft, setDraft] = useState("");
const [sessions, setSessions] = useState<ChatSession[]>(loadSessions);
const [activeSessionId, setActiveSessionId] = useState(() => sessions[0]?.id ?? "");
const [isSubmitting, setIsSubmitting] = useState(false);
const listRef = useRef<HTMLDivElement | null>(null);
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const messages = activeSession?.messages ?? fallbackMessages;
const context = useQuery(api.savingsChat.getContext, {
from,
to,
accountId,
basis: monthBasis,
});
const ask = useAction(api.savingsChat.ask);
const buttonDisabled = isSubmitting || draft.trim().length === 0;
const formatAmount = (amount: number) =>
new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(amount);
const getContextSummary = () => {
if (!context) return "Lade Kontext…";
return `${context.totals.transactionCount} Umsätze · Einnahmen ${formatAmount(
context.totals.income,
)} · Ausgaben ${formatAmount(context.totals.expenses)}`;
};
useEffect(() => {
listRef.current?.lastElementChild?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
}, [sessions]);
const updateSession = (id: string, nextMessages: ChatMessage[]) => {
const now = Date.now();
setSessions((prev) =>
prev.map((session) =>
session.id === id
? {
...session,
title: titleFromMessages(nextMessages),
updatedAt: now,
messages: nextMessages,
}
: session,
),
);
};
const createNewChat = () => {
const session = createSession();
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.id);
setDraft("");
};
const deleteChat = (id: string) => {
const remaining = sessions.filter((session) => session.id !== id);
const nextSessions = remaining.length > 0 ? remaining : [createSession()];
setSessions(nextSessions);
if (id === activeSessionId) setActiveSessionId(nextSessions[0].id);
};
const historyItems: ChatHistoryItem[] = sessions
.map((session) => ({
id: session.id,
title: session.title,
updatedAt: session.updatedAt,
messageCount: session.messages.length,
}))
.sort((a, b) => b.updatedAt - a.updatedAt);
const submit = async (event: FormEvent) => {
event.preventDefault();
const content = draft.trim();
if (!content || isSubmitting) return;
const submittedSessionId = activeSessionId;
const typedNextMessages: ChatMessage[] = [...messages, { role: "user", content }];
updateSession(submittedSessionId, typedNextMessages);
setDraft("");
setIsSubmitting(true);
try {
const response = await ask({
messages: typedNextMessages,
from,
to,
accountId,
basis: monthBasis,
});
updateSession(submittedSessionId, [
...typedNextMessages,
{ role: "assistant", content: response.answer },
]);
} catch (error) {
console.error(error);
toast.error("Antwort konnte nicht geladen werden.");
updateSession(submittedSessionId, [
...typedNextMessages,
{
role: "assistant",
content: "Ich konnte gerade keine Antwort erzeugen. Bitte später erneut versuchen.",
},
]);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<ChatHistory
items={historyItems}
activeId={activeSessionId}
onSelect={setActiveSessionId}
onCreate={createNewChat}
onDelete={deleteChat}
/>
<div className="min-w-0 space-y-4">
<div className="mb-2 flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
<h1 className="text-lg font-semibold">Talk to your savings account</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Kontext der Auswertung</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p>Zeitraum: {from} bis {to}</p>
<p>Basis: {monthBasis === "effective" ? "Effektiv" : "Buchungstag"}</p>
<p>
Konto: {context?.accountName ?? "Alle Konten"} (Basis-Saldo{" "}
{context ? formatAmount(context.totals.balance) : "—"})
</p>
<p>
{context ? getContextSummary() : "Lade Kontext…"}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="h-[52vh] overflow-y-auto" ref={listRef}>
<div className="space-y-3">
{messages.map((message, index) => (
<div
key={`${message.role}-${index}`}
className={`rounded-lg border p-3 ${
message.role === "user" ? "bg-muted/50" : "bg-background"
}`}
>
<p className="text-xs uppercase text-muted-foreground">{message.role}</p>
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
</div>
))}
{isSubmitting && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Denk mit der KI nach
</div>
)}
</div>
</div>
</CardContent>
</Card>
<form className="flex gap-2" onSubmit={submit}>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Welche Auswertung soll ich machen?"
disabled={isSubmitting}
autoFocus
/>
<Button type="submit" disabled={buttonDisabled}>
<Send className="h-4 w-4" />
Senden
</Button>
</form>
<Separator />
<p className="text-xs text-muted-foreground">
Antwortmodell: {context ? "Server-seitig bereit" : "…"}
</p>
</div>
</div>
);
}

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>
);