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

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

67
src/App.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { Authenticated, Unauthenticated, AuthLoading } from "convex/react";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { RouterProvider, createBrowserRouter, Navigate, Outlet } from "react-router-dom";
import { Toaster } from "sonner";
import { convex } from "./lib/convex";
import { FilterProvider } from "./context/FilterContext";
import { AppShell } from "./components/layout/AppShell";
import { LoginPage } from "./pages/LoginPage";
import { DashboardPage } from "./pages/DashboardPage";
import { TransactionsPage } from "./pages/TransactionsPage";
import { CategoriesPage } from "./pages/CategoriesPage";
import { LoansPage } from "./pages/LoansPage";
import { ImportPage } from "./pages/ImportPage";
import { SettingsPage } from "./pages/SettingsPage";
import { Skeleton } from "./components/ui/skeleton";
import { SeedInitializer } from "./components/SeedInitializer";
function OutletWrapper() {
return <Outlet />;
}
function ProtectedLayout() {
return (
<>
<Authenticated>
<SeedInitializer />
<FilterProvider>
<AppShell>
<OutletWrapper />
</AppShell>
</FilterProvider>
</Authenticated>
<Unauthenticated>
<Navigate to="/login" replace />
</Unauthenticated>
<AuthLoading>
<div className="flex min-h-screen items-center justify-center p-8">
<Skeleton className="h-12 w-64" />
</div>
</AuthLoading>
</>
);
}
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{
element: <ProtectedLayout />,
children: [
{ path: "/", element: <DashboardPage /> },
{ path: "/transaktionen", element: <TransactionsPage /> },
{ path: "/kategorien", element: <CategoriesPage /> },
{ path: "/kredite", element: <LoansPage /> },
{ path: "/import", element: <ImportPage /> },
{ path: "/einstellungen", element: <SettingsPage /> },
],
},
]);
export default function App() {
return (
<ConvexAuthProvider client={convex}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</ConvexAuthProvider>
);
}

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,20 @@
import { useEffect, useRef } from "react";
import { useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
/** Legt Standard-Kategorien an, sobald die Auth-Session am Client aktiv ist. */
export function SeedInitializer() {
const ensureSeeded = useMutation(api.users.ensureSeeded);
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
void ensureSeeded({}).catch((error) => {
ran.current = false;
console.error("ensureSeeded fehlgeschlagen:", error);
});
}, [ensureSeeded]);
return null;
}

View File

@@ -0,0 +1,156 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
export function CategoryFormDialog({
open,
onOpenChange,
category,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
category?: Doc<"categories">;
}) {
const create = useMutation(api.categories.create);
const update = useMutation(api.categories.update);
const remove = useMutation(api.categories.remove);
const form = useForm({
defaultValues: {
name: "",
kind: "ausgabe" as "einnahme" | "ausgabe",
block: "variabel" as "wiederkehrend" | "variabel",
color: "#6366f1",
icon: "Circle",
sortOrder: 100,
},
});
useEffect(() => {
if (category) {
form.reset({
name: category.name,
kind: category.kind,
block: category.block ?? "variabel",
color: category.color,
icon: category.icon ?? "Circle",
sortOrder: category.sortOrder,
});
}
}, [category, form]);
const onSubmit = form.handleSubmit(async (values) => {
try {
if (category) {
await update({
id: category._id,
name: values.name,
kind: values.kind,
block: values.kind === "ausgabe" ? values.block : undefined,
color: values.color,
icon: values.icon,
sortOrder: values.sortOrder,
});
toast.success("Kategorie aktualisiert");
} else {
await create({
name: values.name,
kind: values.kind,
block: values.kind === "ausgabe" ? values.block : undefined,
color: values.color,
icon: values.icon,
sortOrder: values.sortOrder,
});
toast.success("Kategorie erstellt");
}
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Fehler");
}
});
const kind = form.watch("kind");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{category ? "Kategorie bearbeiten" : "Neue Kategorie"}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<Label>Name</Label>
<Input {...form.register("name")} />
</div>
<div>
<Label>Art</Label>
<Select value={kind} onValueChange={(v) => form.setValue("kind", v as "einnahme" | "ausgabe")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="einnahme">Einnahme</SelectItem>
<SelectItem value="ausgabe">Ausgabe</SelectItem>
</SelectContent>
</Select>
</div>
{kind === "ausgabe" && (
<div>
<Label>Block</Label>
<Select
value={form.watch("block")}
onValueChange={(v) => form.setValue("block", v as "wiederkehrend" | "variabel")}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="wiederkehrend">Wiederkehrend</SelectItem>
<SelectItem value="variabel">Variabel</SelectItem>
</SelectContent>
</Select>
</div>
)}
<div>
<Label>Farbe</Label>
<Input type="color" {...form.register("color")} />
</div>
<div>
<Label>Icon (lucide-Name)</Label>
<Input {...form.register("icon")} />
</div>
<div>
<Label>Sortierung</Label>
<Input type="number" {...form.register("sortOrder", { valueAsNumber: true })} />
</div>
<DialogFooter className="gap-2">
{category && !category.isSystem && (
<Button
type="button"
variant="destructive"
onClick={async () => {
if (confirm("Kategorie löschen?")) {
await remove({ id: category._id });
toast.success("Gelöscht");
onOpenChange(false);
}
}}
>
Löschen
</Button>
)}
<Button type="submit">Speichern</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,82 @@
import { useState } from "react";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { formatAmount } from "@/lib/format";
type Item = {
name: string;
amount: number;
color: string;
block?: "wiederkehrend" | "variabel";
};
export function CategoryBreakdownChart({ data }: { data: Item[] }) {
const [filter, setFilter] = useState<"all" | "wiederkehrend" | "variabel">("all");
const filtered = data.filter((d) => {
if (filter === "all") return true;
return d.block === filter;
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Ausgaben nach Kategorie</CardTitle>
<div className="flex gap-1">
{(["all", "wiederkehrend", "variabel"] as const).map((f) => (
<Button key={f} size="sm" variant={filter === f ? "default" : "outline"} onClick={() => setFilter(f)}>
{f === "all" ? "Alle" : f === "wiederkehrend" ? "Fixkosten" : "Variabel"}
</Button>
))}
</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>
</Card>
);
}
export function FixedVariableSplit({
fixed,
variable,
}: {
fixed: number;
variable: number;
}) {
const data = [
{ name: "Fixkosten", value: Math.abs(fixed), color: "#6366f1" },
{ name: "Variabel", value: Math.abs(variable), color: "#f97316" },
];
return (
<Card>
<CardHeader>
<CardTitle>Fix vs. variabel</CardTitle>
</CardHeader>
<CardContent className="h-64">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={50} outerRadius={80}>
{data.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip formatter={(v) => formatAmount(-Number(v ?? 0))} />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,39 @@
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { eur } from "@/lib/format";
type Point = { month: string; income: number; expenses: number; balance: number };
export function MonthlyTrendChart({ data }: { data: Point[] }) {
return (
<Card>
<CardHeader>
<CardTitle>Monatlicher Verlauf</CardTitle>
</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)} />
<Tooltip formatter={(v) => eur.format(Number(v ?? 0))} />
<Legend />
<Bar dataKey="income" name="Einnahmen" fill="#22c55e" />
<Bar dataKey="expenses" name="Ausgaben" fill="#ef4444" />
<Line dataKey="balance" name="Saldo" stroke="#6366f1" strokeWidth={2} />
</ComposedChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { useAction } from "convex/react";
import { api } from "../../../convex/_generated/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { toast } from "sonner";
import { subDays, format } from "date-fns";
export function ComdirectSyncPanel() {
const startAuth = useAction(api.comdirect.auth.start);
const confirmAuth = useAction(api.comdirect.auth.confirm);
const runSync = useAction(api.comdirect.sync.run);
const [zugangsnummer, setZugangsnummer] = useState("");
const [pin, setPin] = useState("");
const [tan, setTan] = useState("");
const [challengeType, setChallengeType] = useState<string | null>(null);
const [photoTan, setPhotoTan] = useState<string | null>(null);
const [step, setStep] = useState<"login" | "confirm" | "sync">("login");
const [from, setFrom] = useState(format(subDays(new Date(), 90), "yyyy-MM-dd"));
const [to, setTo] = useState(format(new Date(), "yyyy-MM-dd"));
const [loading, setLoading] = useState(false);
const handleStart = async () => {
setLoading(true);
try {
const result = await startAuth({ zugangsnummer, pin });
setPin("");
setChallengeType(result.challengeType);
setPhotoTan(result.photoTanPngBase64 ?? null);
setStep("confirm");
if (result.challengeType === "P_TAN_PUSH") {
toast.message("Bitte Freigabe in der photoTAN-App bestätigen");
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Anmeldung fehlgeschlagen");
} finally {
setLoading(false);
}
};
const handleConfirm = async () => {
setLoading(true);
try {
await confirmAuth({ tan: tan || undefined });
setTan("");
setStep("sync");
toast.success("comdirect-Session aktiv");
} catch (e) {
toast.error(e instanceof Error ? e.message : "TAN-Bestätigung fehlgeschlagen");
} finally {
setLoading(false);
}
};
const handleSync = async () => {
setLoading(true);
try {
const result = await runSync({ from, to });
toast.success(`${result.importedCount} importiert, ${result.skippedCount} übersprungen`);
setStep("login");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Sync fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>comdirect-Sync</CardTitle>
<CardDescription>
Halbautomatischer Abruf über Convex Actions. PIN wird nicht gespeichert. Nach dem Sync werden
Tokens gelöscht. Achtung: 3× falsche TAN sperrt den Zugang.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<AlertDescription>
Nur lesende Endpoints. Client-ID/Secret müssen in Convex-Env gesetzt sein (
COMDIRECT_CLIENT_ID, COMDIRECT_CLIENT_SECRET).
</AlertDescription>
</Alert>
{step === "login" && (
<>
<div>
<Label>Zugangsnummer</Label>
<Input value={zugangsnummer} onChange={(e) => setZugangsnummer(e.target.value)} />
</div>
<div>
<Label>Online-Banking-PIN</Label>
<Input type="password" value={pin} onChange={(e) => setPin(e.target.value)} />
</div>
<Button onClick={handleStart} disabled={loading}>
Anmelden & TAN anfordern
</Button>
</>
)}
{step === "confirm" && (
<>
{challengeType === "P_TAN_PUSH" && (
<p className="text-sm">Bitte die Push-TAN in der comdirect-App freigeben, dann bestätigen.</p>
)}
{photoTan && (
<img src={`data:image/png;base64,${photoTan}`} alt="photoTAN" className="mx-auto max-w-xs" />
)}
{(challengeType === "M_TAN" || challengeType === "P_TAN") && (
<div>
<Label>TAN</Label>
<Input value={tan} onChange={(e) => setTan(e.target.value)} />
</div>
)}
<Button onClick={handleConfirm} disabled={loading}>
Session aktivieren
</Button>
</>
)}
{step === "sync" && (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label>Von</Label>
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
</div>
<div>
<Label>Bis</Label>
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} />
</div>
</div>
<Button onClick={handleSync} disabled={loading}>
Jetzt synchronisieren
</Button>
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,225 @@
import { useCallback, useState } from "react";
import { parse, type ParseResult } from "papaparse";
import { format, parse as parseDate } from "date-fns";
import { useMutation, useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { categorize, parseCounterpartyFromBuchungstext, parseGermanAmount } from "@convex-lib/categorize";
import { resolveAssignedAndEffective } from "@convex-lib/month";
import { computeDedupHash } from "@convex-lib/comdirectMap";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ImportPreviewTable, type PreviewRow } from "./ImportPreviewTable";
import { toast } from "sonner";
const HEADER = "Buchungstag;Wertstellung (Valuta);Vorgang;Buchungstext;Umsatz in EUR";
function parseComdirectCsv(text: string): Array<Record<string, string>> {
const lines = text.split(/\r?\n/);
const rows: Array<Record<string, string>> = [];
let inBlock = false;
for (const line of lines) {
if (line.trim() === HEADER) {
inBlock = true;
continue;
}
if (!inBlock) continue;
if (
!line.trim() ||
line.startsWith("Alter Kontostand") ||
line.startsWith("Neuer Kontostand") ||
line.startsWith("Umsätze") ||
line.startsWith("Keine Umsätze")
) {
inBlock = false;
continue;
}
const parsed: ParseResult<string[]> = parse(line, { delimiter: ";", quoteChar: '"' });
const fields = parsed.data[0];
if (!fields || fields.length < 5) continue;
rows.push({
buchungstag: fields[0],
valuta: fields[1],
vorgang: fields[2],
buchungstext: fields[3],
betrag: fields[4],
});
}
return rows;
}
export function CsvImportWizard() {
const accounts = useQuery(api.accounts.list);
const settings = useQuery(api.settings.get);
const categories = useQuery(api.categories.list);
const commitRows = useMutation(api.imports.commitRows);
const [step, setStep] = useState<"upload" | "preview" | "done">("upload");
const [filename, setFilename] = useState("");
const [accountId, setAccountId] = useState<string>();
const [rows, setRows] = useState<PreviewRow[]>([]);
const [result, setResult] = useState<{ imported: number; skipped: number } | null>(null);
const processFile = useCallback(
async (file: File) => {
const buffer = await file.arrayBuffer();
const text = new TextDecoder("iso-8859-1").decode(buffer);
const parsed = parseComdirectCsv(text);
const ownNames = settings?.ownNames ?? [];
const salaryShift = settings?.salaryShift ?? {
enabled: true,
categoryNames: ["Gehalt & Besoldung"],
dayThreshold: 25,
};
const preview: PreviewRow[] = [];
for (const row of parsed) {
const isPending = row.buchungstag.trim().toLowerCase() === "offen";
let bookingDate: string | undefined;
if (!isPending) {
const d = parseDate(row.buchungstag, "dd.MM.yyyy", new Date());
bookingDate = format(d, "yyyy-MM-dd");
}
const amount = parseGermanAmount(row.betrag);
const { counterparty, description, rawText } = parseCounterpartyFromBuchungstext(row.buchungstext);
const categoryName = categorize(rawText, amount, row.vorgang, ownNames);
const { assignedMonth, effectiveMonth } = resolveAssignedAndEffective(
bookingDate,
amount,
categoryName,
salaryShift,
);
const dedupHash = await computeDedupHash({
accountId,
bookingDate,
amount,
description,
vorgang: row.vorgang,
});
preview.push({
bookingDate,
valueDate: row.valuta
? format(parseDate(row.valuta, "dd.MM.yyyy", new Date()), "yyyy-MM-dd")
: undefined,
description,
counterparty,
amount,
vorgang: row.vorgang,
isPending,
rawText,
categoryName,
assignedMonth,
effectiveMonth,
dedupHash,
});
}
setRows(preview);
setFilename(file.name);
setStep("preview");
},
[settings, accountId],
);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) void processFile(file);
};
const handleCommit = async () => {
if (!accountId) {
toast.error("Bitte Konto wählen");
return;
}
const res = await commitRows({
filename,
source: "comdirect-csv",
accountId: accountId as Id<"accounts">,
rows: rows.map((r) => ({
bookingDate: r.bookingDate,
valueDate: r.valueDate,
description: r.description,
counterparty: r.counterparty,
amount: r.amount,
vorgang: r.vorgang,
isPending: r.isPending,
rawText: r.rawText,
categoryName: r.categoryName,
assignedMonth: r.assignedMonth,
effectiveMonth: r.effectiveMonth,
dedupHash: r.dedupHash,
categoryId: categories?.find((c) => c.name === r.categoryName)?._id,
})),
});
setResult({ imported: res.importedCount, skipped: res.skippedCount });
setStep("done");
toast.success(`${res.importedCount} importiert, ${res.skippedCount} übersprungen`);
};
if (step === "upload") {
return (
<Card>
<CardHeader>
<CardTitle>CSV-Import (comdirect)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div
className="flex min-h-40 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<p className="mb-2 text-sm text-muted-foreground">
comdirect-CSV hier ablegen (ISO-8859-1, Semikolon)
</p>
<input
type="file"
accept=".csv,.CSV"
onChange={(e) => e.target.files?.[0] && void processFile(e.target.files[0])}
/>
</div>
</CardContent>
</Card>
);
}
if (step === "preview") {
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Konto wählen" />
</SelectTrigger>
<SelectContent>
{accounts?.map((a) => (
<SelectItem key={a._id} value={a._id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleCommit}>Import starten</Button>
<Button variant="outline" onClick={() => setStep("upload")}>
Zurück
</Button>
</div>
<ImportPreviewTable rows={rows} onRowsChange={setRows} categories={categories ?? []} />
</div>
);
}
return (
<Card>
<CardContent className="pt-6">
<p>
Import abgeschlossen: {result?.imported} Buchungen importiert, {result?.skipped} übersprungen.
</p>
<Button className="mt-4" onClick={() => setStep("upload")}>
Weiteren Import starten
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,82 @@
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 type { Doc } from "../../../convex/_generated/dataModel";
export type PreviewRow = {
bookingDate?: string;
valueDate?: string;
description: string;
counterparty?: string;
amount: number;
vorgang?: string;
isPending: boolean;
rawText?: string;
categoryName: string;
assignedMonth?: string;
effectiveMonth?: string;
dedupHash?: string;
isDuplicate?: boolean;
};
export function ImportPreviewTable({
rows,
onRowsChange,
categories,
}: {
rows: PreviewRow[];
onRowsChange: (rows: PreviewRow[]) => void;
categories: Doc<"categories">[];
}) {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beschreibung</TableHead>
<TableHead>Betrag</TableHead>
<TableHead>Kategorie</TableHead>
<TableHead>Monat</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={idx}>
<TableCell>{row.isPending ? "offen" : formatDate(row.bookingDate)}</TableCell>
<TableCell>{row.description}</TableCell>
<TableCell className={amountClass(row.amount)}>{formatAmount(row.amount)}</TableCell>
<TableCell>
<Select
value={row.categoryName}
onValueChange={(name) => {
const next = [...rows];
next[idx] = { ...row, categoryName: name };
onRowsChange(next);
}}
>
<SelectTrigger className="h-8 w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c._id} value={c.name}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
{row.assignedMonth && row.assignedMonth !== row.bookingDate?.slice(0, 7) && (
<Badge variant="outline">{formatMonth(row.assignedMonth)}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Id } from "../../../convex/_generated/dataModel";
import { useFilters } from "@/context/FilterContext";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function AccountFilter() {
const accounts = useQuery(api.accounts.list);
const { accountId, setAccountId } = useFilters();
return (
<Select
value={accountId ?? "all"}
onValueChange={(v) => setAccountId(v === "all" ? undefined : v)}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Alle Konten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Konten</SelectItem>
{accounts?.map((account) => (
<SelectItem key={account._id} value={account._id}>
{account.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function useAccountFilterId(): Id<"accounts"> | undefined {
const { accountId } = useFilters();
return accountId as Id<"accounts"> | undefined;
}

View File

@@ -0,0 +1,64 @@
import { useState } from "react";
import { Menu, Moon, Sun, LogOut } from "lucide-react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useNavigate } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { DateRangeFilter } from "./DateRangeFilter";
import { AccountFilter } from "./AccountFilter";
import { MonthBasisToggle } from "./MonthBasisToggle";
import { Button } from "@/components/ui/button";
export function AppShell({ children }: { children: React.ReactNode }) {
const [mobileOpen, setMobileOpen] = useState(false);
const [dark, setDark] = useState(() => document.documentElement.classList.contains("dark"));
const { signOut } = useAuthActions();
const navigate = useNavigate();
const toggleTheme = () => {
document.documentElement.classList.toggle("dark");
setDark((d) => !d);
localStorage.setItem("theme", dark ? "light" : "dark");
};
const handleLogout = async () => {
await signOut();
navigate("/login");
};
return (
<div className="flex min-h-screen bg-background">
<aside className="hidden w-64 shrink-0 border-r lg:block">
<Sidebar />
</aside>
{mobileOpen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setMobileOpen(false)} />
<aside className="relative z-50 h-full w-64 bg-background shadow-xl">
<Sidebar onNavigate={() => setMobileOpen(false)} />
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-30 flex flex-wrap items-center gap-3 border-b bg-background/95 p-4 backdrop-blur">
<Button variant="outline" size="icon" className="lg:hidden" onClick={() => setMobileOpen(true)}>
<Menu className="h-4 w-4" />
</Button>
<DateRangeFilter />
<AccountFilter />
<MonthBasisToggle />
<div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleTheme}>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={handleLogout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useFilters, type DatePreset } from "@/context/FilterContext";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
const presets: { value: DatePreset; label: string }[] = [
{ value: "current-month", label: "Aktueller Monat" },
{ value: "last-3-months", label: "Letzte 3 Monate" },
{ value: "last-6-months", label: "Letzte 6 Monate" },
{ value: "last-180-days", label: "Letzte 180 Tage" },
{ value: "this-year", label: "Dieses Jahr" },
{ value: "custom", label: "Benutzerdefiniert" },
];
export function DateRangeFilter() {
const { preset, setPreset, from, to, setCustomRange } = useFilters();
return (
<div className="flex flex-wrap items-center gap-2">
<Select value={preset} onValueChange={(v) => setPreset(v as DatePreset)}>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{presets.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
{preset === "custom" && (
<>
<Input type="date" value={from} onChange={(e) => setCustomRange(e.target.value, to)} className="w-auto" />
<span className="text-sm text-muted-foreground"></span>
<Input type="date" value={to} onChange={(e) => setCustomRange(from, e.target.value)} className="w-auto" />
</>
)}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { useFilters, type MonthBasis } from "@/context/FilterContext";
import { Button } from "@/components/ui/button";
export function MonthBasisToggle() {
const { monthBasis, setMonthBasis } = useFilters();
return (
<div className="flex rounded-lg border p-1">
<Button
size="sm"
variant={monthBasis === "effective" ? "default" : "ghost"}
onClick={() => setMonthBasis("effective")}
>
Zuordnungsmonat
</Button>
<Button
size="sm"
variant={monthBasis === "booking" ? "default" : "ghost"}
onClick={() => setMonthBasis("booking")}
>
Buchungsdatum
</Button>
</div>
);
}
export function useMonthBasis(): MonthBasis {
return useFilters().monthBasis;
}

View File

@@ -0,0 +1,47 @@
import { NavLink } from "react-router-dom";
import {
CreditCard,
FolderTree,
Import,
LayoutDashboard,
Settings,
Wallet,
} from "lucide-react";
import { cn } from "@/lib/utils";
const links = [
{ to: "/", label: "Übersicht", icon: LayoutDashboard },
{ to: "/transaktionen", label: "Transaktionen", icon: Wallet },
{ to: "/kategorien", label: "Kategorien", icon: FolderTree },
{ to: "/kredite", label: "Kredite", icon: CreditCard },
{ to: "/import", label: "CSV & comdirect", icon: Import },
{ to: "/einstellungen", label: "Einstellungen", icon: Settings },
];
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
return (
<nav className="flex flex-col gap-1 p-4">
<div className="mb-4 px-2">
<h1 className="text-lg font-bold">Finanz-Dashboard</h1>
<p className="text-xs text-muted-foreground">Persönliche Finanzverwaltung</p>
</div>
{links.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted",
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
);
}

View File

@@ -0,0 +1,80 @@
import { useMemo } from "react";
import {
Area,
AreaChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { Doc } from "../../../convex/_generated/dataModel";
import { buildSchedule } from "@convex-lib/amortization";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { formatAmount, formatDate } from "@/lib/format";
export function AmortizationSchedule({
loan,
onClose,
}: {
loan: Doc<"loans">;
onClose: () => void;
}) {
const scheduleResult = useMemo(() => {
return buildSchedule({
principal: loan.principal,
annualRate: loan.annualInterestRate,
startDate: new Date(loan.startDate),
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
});
}, [loan]);
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle>Tilgungsplan {loan.name}</DialogTitle>
</DialogHeader>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={scheduleResult.schedule}>
<XAxis dataKey="month" />
<YAxis tickFormatter={(v) => `${v}`} />
<Tooltip formatter={(v) => formatAmount(Number(v ?? 0))} />
<Area type="monotone" dataKey="balance" stroke="#6366f1" fill="#6366f133" name="Restschuld" />
</AreaChart>
</ResponsiveContainer>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Datum</TableHead>
<TableHead>Rate</TableHead>
<TableHead>Zins</TableHead>
<TableHead>Tilgung</TableHead>
<TableHead>Rest</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scheduleResult.schedule.map((row) => (
<TableRow key={row.month}>
<TableCell>{row.month}</TableCell>
<TableCell>{formatDate(row.date)}</TableCell>
<TableCell>{formatAmount(row.payment)}</TableCell>
<TableCell>{formatAmount(row.interest)}</TableCell>
<TableCell>{formatAmount(row.principal)}</TableCell>
<TableCell>{formatAmount(row.balance)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<p className="text-sm text-muted-foreground">
Gesamtzinsen: {formatAmount(scheduleResult.totalInterest)} · Enddatum:{" "}
{formatDate(scheduleResult.payoffDate.toISOString().slice(0, 10))}
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,162 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
export function LoanFormDialog({
open,
onOpenChange,
loan,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
loan?: Doc<"loans">;
}) {
const create = useMutation(api.loans.create);
const update = useMutation(api.loans.update);
const remove = useMutation(api.loans.remove);
const form = useForm({
defaultValues: {
name: "",
lender: "",
principal: 10000,
annualInterestRate: 3.5,
monthlyPayment: undefined as number | undefined,
termMonths: undefined as number | undefined,
startDate: new Date().toISOString().slice(0, 10),
currentBalance: undefined as number | undefined,
status: "aktiv" as "aktiv" | "abbezahlt" | "pausiert",
notes: "",
},
});
useEffect(() => {
if (loan) {
form.reset({
name: loan.name,
lender: loan.lender ?? "",
principal: loan.principal,
annualInterestRate: loan.annualInterestRate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
startDate: loan.startDate,
currentBalance: loan.currentBalance,
status: loan.status,
notes: loan.notes ?? "",
});
}
}, [loan, form]);
const onSubmit = form.handleSubmit(async (values) => {
try {
const payload = {
name: values.name,
lender: values.lender || undefined,
principal: values.principal,
annualInterestRate: values.annualInterestRate,
monthlyPayment: values.monthlyPayment,
termMonths: values.termMonths,
startDate: values.startDate,
currentBalance: values.currentBalance,
status: values.status,
notes: values.notes || undefined,
};
if (loan) {
await update({ id: loan._id, ...payload });
toast.success("Kredit aktualisiert");
} else {
await create(payload);
toast.success("Kredit angelegt");
}
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Fehler");
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{loan ? "Kredit bearbeiten" : "Neuer Kredit"}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label>Name</Label>
<Input {...form.register("name")} />
</div>
<div>
<Label>Gläubiger</Label>
<Input {...form.register("lender")} />
</div>
<div>
<Label>Status</Label>
<Select value={form.watch("status")} onValueChange={(v) => form.setValue("status", v as never)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="aktiv">Aktiv</SelectItem>
<SelectItem value="abbezahlt">Abbezahlt</SelectItem>
<SelectItem value="pausiert">Pausiert</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Kreditsumme</Label>
<Input type="number" step="0.01" {...form.register("principal", { valueAsNumber: true })} />
</div>
<div>
<Label>Jahreszins (%)</Label>
<Input type="number" step="0.01" {...form.register("annualInterestRate", { valueAsNumber: true })} />
</div>
<div>
<Label>Monatsrate</Label>
<Input type="number" step="0.01" {...form.register("monthlyPayment", { valueAsNumber: true })} />
</div>
<div>
<Label>Laufzeit (Monate)</Label>
<Input type="number" {...form.register("termMonths", { valueAsNumber: true })} />
</div>
<div>
<Label>Startdatum</Label>
<Input type="date" {...form.register("startDate")} />
</div>
<div>
<Label>Aktuelle Restschuld (optional)</Label>
<Input type="number" step="0.01" {...form.register("currentBalance", { valueAsNumber: true })} />
</div>
<div className="sm:col-span-2">
<Label>Notiz</Label>
<Input {...form.register("notes")} />
</div>
<DialogFooter className="sm:col-span-2 gap-2">
{loan && (
<Button
type="button"
variant="destructive"
onClick={async () => {
if (confirm("Kredit löschen?")) {
await remove({ id: loan._id });
onOpenChange(false);
}
}}
>
Löschen
</Button>
)}
<Button type="submit">Speichern</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,45 @@
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
export function AssignMonthDialog({
transaction,
onClose,
}: {
transaction: Doc<"transactions"> | null;
onClose: () => void;
}) {
const [month, setMonth] = useState(transaction?.assignedMonth ?? "");
const setAssignedMonth = useMutation(api.transactions.setAssignedMonth);
const handleSave = async () => {
if (!transaction) return;
await setAssignedMonth({ id: transaction._id, month: month || null });
toast.success("Monat zugeordnet");
onClose();
};
return (
<Dialog open={!!transaction} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Monat zuordnen</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Monat (YYYY-MM)</Label>
<Input value={month} onChange={(e) => setMonth(e.target.value)} placeholder="2025-02" />
<p className="text-xs text-muted-foreground">Leer lassen, um Zuordnung zu entfernen</p>
</div>
<DialogFooter>
<Button onClick={handleSave}>Speichern</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,207 @@
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "convex/react";
import { api } from "../../../convex/_generated/api";
import type { Doc } from "../../../convex/_generated/dataModel";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
const schema = z.object({
bookingDate: z.string().optional(),
description: z.string().min(1),
counterparty: z.string().optional(),
amountAbs: z.number().positive(),
isIncome: z.boolean(),
categoryId: z.string().optional(),
accountId: z.string().optional(),
notes: z.string().optional(),
isPending: z.boolean(),
assignedMonth: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
export function TransactionFormDialog({
open,
onOpenChange,
transaction,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
transaction?: Doc<"transactions">;
}) {
const categories = useQuery(api.categories.list);
const accounts = useQuery(api.accounts.list);
const create = useMutation(api.transactions.create);
const update = useMutation(api.transactions.update);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
description: "",
amountAbs: 0,
isIncome: false,
isPending: false,
},
});
useEffect(() => {
if (transaction) {
form.reset({
bookingDate: transaction.bookingDate,
description: transaction.description,
counterparty: transaction.counterparty,
amountAbs: Math.abs(transaction.amount),
isIncome: transaction.amount > 0,
categoryId: transaction.categoryId,
accountId: transaction.accountId,
notes: transaction.notes,
isPending: transaction.isPending,
assignedMonth: transaction.assignedMonth,
});
} else if (open) {
form.reset({
description: "",
amountAbs: 0,
isIncome: false,
isPending: false,
});
}
}, [transaction, open, form]);
const isIncome = form.watch("isIncome");
const filteredCategories = categories?.filter((c) =>
isIncome ? c.kind === "einnahme" : c.kind === "ausgabe",
);
const onSubmit = form.handleSubmit(async (values) => {
const amount = values.isIncome ? values.amountAbs : -values.amountAbs;
try {
if (transaction) {
await update({
id: transaction._id,
bookingDate: values.isPending ? undefined : values.bookingDate,
description: values.description,
counterparty: values.counterparty,
amount,
categoryId: values.categoryId as never,
accountId: values.accountId as never,
notes: values.notes,
isPending: values.isPending,
assignedMonth: values.assignedMonth ?? null,
});
toast.success("Transaktion aktualisiert");
} else {
await create({
bookingDate: values.isPending ? undefined : values.bookingDate,
description: values.description,
counterparty: values.counterparty,
amount,
categoryId: values.categoryId as never,
accountId: values.accountId as never,
notes: values.notes,
isPending: values.isPending,
assignedMonth: values.assignedMonth,
});
toast.success("Transaktion angelegt");
}
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Fehler beim Speichern");
}
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{transaction ? "Transaktion bearbeiten" : "Neue Transaktion"}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="flex items-center gap-2">
<Switch checked={form.watch("isPending")} onCheckedChange={(v) => form.setValue("isPending", v)} />
<Label>Offen (pending)</Label>
</div>
{!form.watch("isPending") && (
<div>
<Label>Buchungsdatum</Label>
<Input type="date" {...form.register("bookingDate")} />
</div>
)}
<div>
<Label>Beschreibung</Label>
<Input {...form.register("description")} />
</div>
<div>
<Label>Gegenpartei</Label>
<Input {...form.register("counterparty")} />
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<Switch checked={isIncome} onCheckedChange={(v) => form.setValue("isIncome", v)} />
<Label>Einnahme</Label>
</div>
<div className="flex-1">
<Label>Betrag</Label>
<Input type="number" step="0.01" {...form.register("amountAbs", { valueAsNumber: true })} />
</div>
</div>
<div>
<Label>Kategorie</Label>
<Select value={form.watch("categoryId") ?? ""} onValueChange={(v) => form.setValue("categoryId", v)}>
<SelectTrigger>
<SelectValue placeholder="Kategorie wählen" />
</SelectTrigger>
<SelectContent>
{filteredCategories?.map((c) => (
<SelectItem key={c._id} value={c._id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Konto</Label>
<Select value={form.watch("accountId") ?? ""} onValueChange={(v) => form.setValue("accountId", v)}>
<SelectTrigger>
<SelectValue placeholder="Konto wählen" />
</SelectTrigger>
<SelectContent>
{accounts?.map((a) => (
<SelectItem key={a._id} value={a._id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Buchungsmonat (abweichend, YYYY-MM)</Label>
<Input placeholder="2025-01" {...form.register("assignedMonth")} />
</div>
<div>
<Label>Notiz</Label>
<Input {...form.register("notes")} />
</div>
<DialogFooter>
<Button type="submit">Speichern</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
export function Alert({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
role="alert"
className={`relative w-full rounded-lg border px-4 py-3 text-sm ${className ?? ""}`}
{...props}
/>
);
}
export function AlertDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <div className={`text-sm [&_p]:leading-relaxed ${className ?? ""}`} {...props} />;
}

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Badge({
className,
style,
variant = "default",
...props
}: React.HTMLAttributes<HTMLSpanElement> & {
style?: React.CSSProperties;
variant?: "default" | "outline";
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
variant === "outline" && "border-border bg-transparent",
className,
)}
style={style}
{...props}
/>
);
}

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { buttonVariants };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
export const DialogPortal = DialogPrimitive.Portal;
export const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
export function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />;
}
export function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h2 className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />;
}
export function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />;
}

View File

@@ -0,0 +1,17 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";

View File

@@ -0,0 +1,11 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
{...props}
/>
);
}

View File

@@ -0,0 +1,68 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export const Select = SelectPrimitive.Root;
export const SelectValue = SelectPrimitive.Value;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
export const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
export const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;

View File

@@ -0,0 +1,15 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Separator({ className, orientation = "horizontal", ...props }: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { cn } from "@/lib/utils";
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
export const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitive.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
));
Switch.displayName = SwitchPrimitive.Root.displayName;

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Table({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) {
return (
<div className="relative w-full overflow-auto">
<table className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
);
}
export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
}
export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn("border-b transition-colors hover:bg-muted/50", className)} {...props} />;
}
export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th className={cn("h-10 px-2 text-left align-middle font-medium text-muted-foreground", className)} {...props} />
);
}
export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn("p-2 align-middle", className)} {...props} />;
}

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
export const Tabs = TabsPrimitive.Root;
export const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn("inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", className)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
export const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
export const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content ref={ref} className={cn("mt-2 focus-visible:outline-none", className)} {...props} />
));
TabsContent.displayName = TabsPrimitive.Content.displayName;

View File

@@ -0,0 +1,99 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
import {
endOfMonth,
endOfYear,
format,
startOfMonth,
startOfYear,
subDays,
subMonths,
} from "date-fns";
export type DatePreset =
| "current-month"
| "last-3-months"
| "last-6-months"
| "last-180-days"
| "this-year"
| "custom";
export type MonthBasis = "effective" | "booking";
type FilterContextValue = {
preset: DatePreset;
setPreset: (preset: DatePreset) => void;
from: string;
to: string;
setCustomRange: (from: string, to: string) => void;
accountId: string | undefined;
setAccountId: (id: string | undefined) => void;
monthBasis: MonthBasis;
setMonthBasis: (basis: MonthBasis) => void;
};
const FilterContext = createContext<FilterContextValue | null>(null);
function toIsoDate(d: Date): string {
return format(d, "yyyy-MM-dd");
}
function rangeForPreset(preset: DatePreset, customFrom?: string, customTo?: string) {
const now = new Date();
switch (preset) {
case "current-month":
return { from: toIsoDate(startOfMonth(now)), to: toIsoDate(endOfMonth(now)) };
case "last-3-months":
return { from: toIsoDate(startOfMonth(subMonths(now, 2))), to: toIsoDate(endOfMonth(now)) };
case "last-6-months":
return { from: toIsoDate(startOfMonth(subMonths(now, 5))), to: toIsoDate(endOfMonth(now)) };
case "last-180-days":
return { from: toIsoDate(subDays(now, 180)), to: toIsoDate(now) };
case "this-year":
return { from: toIsoDate(startOfYear(now)), to: toIsoDate(endOfYear(now)) };
case "custom":
return {
from: customFrom ?? toIsoDate(startOfMonth(now)),
to: customTo ?? toIsoDate(endOfMonth(now)),
};
}
}
export function FilterProvider({ children }: { children: ReactNode }) {
const [preset, setPreset] = useState<DatePreset>("current-month");
const [customFrom, setCustomFrom] = useState<string>();
const [customTo, setCustomTo] = useState<string>();
const [accountId, setAccountId] = useState<string>();
const [monthBasis, setMonthBasis] = useState<MonthBasis>("effective");
const { from, to } = useMemo(
() => rangeForPreset(preset, customFrom, customTo),
[preset, customFrom, customTo],
);
const value = useMemo(
() => ({
preset,
setPreset,
from,
to,
setCustomRange: (f: string, t: string) => {
setCustomFrom(f);
setCustomTo(t);
setPreset("custom");
},
accountId,
setAccountId,
monthBasis,
setMonthBasis,
}),
[preset, from, to, accountId, monthBasis],
);
return <FilterContext.Provider value={value}>{children}</FilterContext.Provider>;
}
export function useFilters() {
const ctx = useContext(FilterContext);
if (!ctx) throw new Error("useFilters muss innerhalb FilterProvider verwendet werden");
return ctx;
}

80
src/index.css Normal file
View File

@@ -0,0 +1,80 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

9
src/lib/convex.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ConvexReactClient } from "convex/react";
const convexUrl = import.meta.env.VITE_CONVEX_URL as string;
if (!convexUrl) {
console.warn("VITE_CONVEX_URL ist nicht gesetzt. Bitte npx convex dev ausführen.");
}
export const convex = new ConvexReactClient(convexUrl ?? "https://placeholder.convex.cloud");

41
src/lib/format.ts Normal file
View File

@@ -0,0 +1,41 @@
import { format as dfFormat } from "date-fns";
import { de } from "date-fns/locale";
export const eur = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
});
export const pct = new Intl.NumberFormat("de-DE", {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export function formatDate(date: Date | string | undefined): string {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
return dfFormat(d, "dd.MM.yyyy", { locale: de });
}
export function formatMonth(monthKey: string | undefined): string {
if (!monthKey) return "";
const [year, month] = monthKey.split("-");
return `${month}.${year}`;
}
export function formatAmount(amount: number): string {
if (amount === 0) return eur.format(0);
return eur.format(amount);
}
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";
return "";
}
export function dashIfZero(value: number, formatter: (n: number) => string = (n) => String(n)): string {
if (value === 0) return "";
return formatter(value);
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

19
src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
function ThemeInit() {
useEffect(() => {
const theme = localStorage.getItem("theme");
if (theme === "dark") document.documentElement.classList.add("dark");
}, []);
return null;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeInit />
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CategoryFormDialog } from "@/components/categories/CategoryFormDialog";
function CategorySection({
title,
categories,
onEdit,
}: {
title: string;
categories: Doc<"categories">[];
onEdit: (c: Doc<"categories">) => void;
}) {
return (
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{categories.length === 0 && (
<p className="text-sm text-muted-foreground">Keine Kategorien</p>
)}
{categories.map((cat) => (
<div key={cat._id} className="flex items-center justify-between rounded-lg border p-3">
<Badge style={{ backgroundColor: cat.color, color: "#fff", border: "none" }}>
{cat.name}
</Badge>
<Button size="sm" variant="outline" onClick={() => onEdit(cat)}>
Bearbeiten
</Button>
</div>
))}
</CardContent>
</Card>
);
}
export function CategoriesPage() {
const categories = useQuery(api.categories.list);
const [edit, setEdit] = useState<Doc<"categories"> | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const income = categories?.filter((c) => c.kind === "einnahme") ?? [];
const fixed = categories?.filter((c) => c.kind === "ausgabe" && c.block === "wiederkehrend") ?? [];
const variable = categories?.filter((c) => c.kind === "ausgabe" && c.block === "variabel") ?? [];
return (
<div className="space-y-4">
<Button onClick={() => setCreateOpen(true)}>Neue Kategorie</Button>
<div className="grid gap-4 lg:grid-cols-3">
<CategorySection title="Einnahmen" categories={income} onEdit={setEdit} />
<CategorySection title="Ausgaben wiederkehrend" categories={fixed} onEdit={setEdit} />
<CategorySection title="Ausgaben variabel" categories={variable} onEdit={setEdit} />
</div>
<CategoryFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<CategoryFormDialog
open={!!edit}
onOpenChange={(o) => !o && setEdit(null)}
category={edit ?? undefined}
/>
</div>
);
}

140
src/pages/DashboardPage.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { useFilters } from "@/context/FilterContext";
import { useAccountFilterId } from "@/components/layout/AccountFilter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { MonthlyTrendChart } from "@/components/charts/MonthlyTrendChart";
import { CategoryBreakdownChart, FixedVariableSplit } from "@/components/charts/CategoryBreakdownChart";
import { amountClass, formatAmount, formatDate, pct } from "@/lib/format";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { Id } from "../../convex/_generated/dataModel";
function KpiCard({ title, value, className }: { title: string; value: string; className?: string }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${className ?? ""}`}>{value}</div>
</CardContent>
</Card>
);
}
export function DashboardPage() {
const { from, to, monthBasis } = useFilters();
const accountId = useAccountFilterId();
const categories = useQuery(api.categories.list);
const summary = useQuery(api.dashboard.summary, { from, to, accountId, basis: monthBasis });
if (summary === undefined) {
return (
<div className="grid gap-4 md:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
);
}
const categoryMap = new Map(categories?.map((c) => [c._id, c]));
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard title="Einnahmen" value={formatAmount(summary.income)} className={amountClass(summary.income)} />
<KpiCard title="Ausgaben" value={formatAmount(summary.expenses)} className={amountClass(summary.expenses)} />
<KpiCard title="Fixkosten" value={formatAmount(summary.fixedCosts)} className={amountClass(summary.fixedCosts)} />
<KpiCard title="Variabel" value={formatAmount(summary.variableCosts)} className={amountClass(summary.variableCosts)} />
<KpiCard title="Saldo" value={formatAmount(summary.balance)} className={amountClass(summary.balance)} />
<KpiCard
title="Sparquote"
value={summary.savingsRate === null ? "" : pct.format(summary.savingsRate)}
/>
<KpiCard title="Monatliche Kreditrate" value={formatAmount(summary.totalLoanPayment)} />
<KpiCard title="Restschuld gesamt" value={formatAmount(summary.totalRemainingDebt)} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<MonthlyTrendChart data={summary.monthlyTrend} />
<FixedVariableSplit fixed={summary.fixedCosts} variable={summary.variableCosts} />
</div>
<CategoryBreakdownChart data={summary.categoryBreakdown} />
<div className="grid gap-4 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Letzte Buchungen</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Datum</TableHead>
<TableHead>Beschreibung</TableHead>
<TableHead className="text-right">Betrag</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.recentTransactions.map((tx) => {
const cat = tx.categoryId ? categoryMap.get(tx.categoryId as Id<"categories">) : undefined;
return (
<TableRow key={tx._id}>
<TableCell>{tx.isPending ? "offen" : formatDate(tx.bookingDate)}</TableCell>
<TableCell>
<div>{tx.description}</div>
{cat && (
<span className="text-xs" style={{ color: cat.color }}>
{cat.name}
</span>
)}
</TableCell>
<TableCell className={`text-right font-medium ${amountClass(tx.amount)}`}>
{formatAmount(tx.amount)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Laufende Kredite</CardTitle>
</CardHeader>
<CardContent>
{summary.activeLoans.length === 0 ? (
<p className="text-sm text-muted-foreground">Keine aktiven Kredite</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Restschuld</TableHead>
<TableHead>Rate</TableHead>
<TableHead>Ende</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.activeLoans.map((loan) => (
<TableRow key={loan._id}>
<TableCell>{loan.name}</TableCell>
<TableCell>{formatAmount(loan.currentBalance)}</TableCell>
<TableCell>{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : ""}</TableCell>
<TableCell>{formatDate(loan.payoffDate)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
);
}

20
src/pages/ImportPage.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { CsvImportWizard } from "@/components/import/CsvImportWizard";
import { ComdirectSyncPanel } from "@/components/import/ComdirectSyncPanel";
export function ImportPage() {
return (
<Tabs defaultValue="csv">
<TabsList>
<TabsTrigger value="csv">CSV-Import</TabsTrigger>
<TabsTrigger value="comdirect">comdirect-Sync</TabsTrigger>
</TabsList>
<TabsContent value="csv" className="mt-4">
<CsvImportWizard />
</TabsContent>
<TabsContent value="comdirect" className="mt-4">
<ComdirectSyncPanel />
</TabsContent>
</Tabs>
);
}

89
src/pages/LoansPage.tsx Normal file
View File

@@ -0,0 +1,89 @@
import { useMemo, useState } from "react";
import { useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Doc } from "../../convex/_generated/dataModel";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { formatAmount } from "@/lib/format";
import { LoanFormDialog } from "@/components/loans/LoanFormDialog";
import { AmortizationSchedule } from "@/components/loans/AmortizationSchedule";
import { buildSchedule, currentBalanceFromSchedule } from "@convex-lib/amortization";
export function LoansPage() {
const loans = useQuery(api.loans.list);
const [createOpen, setCreateOpen] = useState(false);
const [editLoan, setEditLoan] = useState<Doc<"loans"> | null>(null);
const [viewLoan, setViewLoan] = useState<Doc<"loans"> | null>(null);
const enriched = useMemo(() => {
return (loans ?? []).map((loan) => {
const startDate = new Date(loan.startDate);
const schedule = buildSchedule({
principal: loan.principal,
annualRate: loan.annualInterestRate,
startDate,
monthlyPayment: loan.monthlyPayment,
termMonths: loan.termMonths,
});
const balance =
loan.currentBalance ?? currentBalanceFromSchedule(schedule.schedule, startDate);
return { loan, schedule, balance };
});
}, [loans]);
return (
<div className="space-y-4">
<Button onClick={() => setCreateOpen(true)}>Neuer Kredit</Button>
<Card>
<CardHeader>
<CardTitle>Kredite</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Gläubiger</TableHead>
<TableHead>Summe</TableHead>
<TableHead>Zins</TableHead>
<TableHead>Rate</TableHead>
<TableHead>Restschuld</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{enriched.map(({ loan, balance }) => (
<TableRow key={loan._id}>
<TableCell>{loan.name}</TableCell>
<TableCell>{loan.lender ?? ""}</TableCell>
<TableCell>{formatAmount(loan.principal)}</TableCell>
<TableCell>{loan.annualInterestRate.toFixed(2)} %</TableCell>
<TableCell>{loan.monthlyPayment ? formatAmount(loan.monthlyPayment) : ""}</TableCell>
<TableCell>{formatAmount(balance)}</TableCell>
<TableCell>{loan.status}</TableCell>
<TableCell className="space-x-1">
<Button size="sm" variant="outline" onClick={() => setEditLoan(loan)}>
Bearbeiten
</Button>
<Button size="sm" variant="ghost" onClick={() => setViewLoan(loan)}>
Plan
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{viewLoan && (
<AmortizationSchedule loan={viewLoan} onClose={() => setViewLoan(null)} />
)}
<LoanFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<LoanFormDialog open={!!editLoan} onOpenChange={(o) => !o && setEditLoan(null)} loan={editLoan ?? undefined} />
</div>
);
}

71
src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuthActions } from "@convex-dev/auth/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
export function LoginPage() {
const { signIn } = useAuthActions();
const navigate = useNavigate();
const [mode, setMode] = useState<"signIn" | "signUp">("signIn");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await signIn("password", { email, password, flow: mode });
toast.success(mode === "signIn" ? "Willkommen zurück!" : "Konto erstellt");
navigate("/");
} catch (error) {
toast.error(error instanceof Error ? error.message : "Anmeldung fehlgeschlagen");
} finally {
setLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Finanz-Dashboard</CardTitle>
<CardDescription>Persönliche Finanzverwaltung Single-User Login</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Bitte warten…" : mode === "signIn" ? "Anmelden" : "Registrieren"}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => setMode(mode === "signIn" ? "signUp" : "signIn")}
>
{mode === "signIn" ? "Noch kein Konto? Registrieren" : "Bereits registriert? Anmelden"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

178
src/pages/SettingsPage.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
export function SettingsPage() {
const settings = useQuery(api.settings.get);
const accounts = useQuery(api.accounts.list);
const updateSettings = useMutation(api.settings.update);
const applySalaryShift = useMutation(api.transactions.applySalaryShift);
const createAccount = useMutation(api.accounts.create);
const updateAccount = useMutation(api.accounts.update);
const removeAccount = useMutation(api.accounts.remove);
const [ownNamesText, setOwnNamesText] = useState("");
const [salaryEnabled, setSalaryEnabled] = useState(true);
const [dayThreshold, setDayThreshold] = useState(25);
const [monthBasis, setMonthBasis] = useState<"effective" | "booking">("effective");
useEffect(() => {
if (settings) {
setOwnNamesText(settings.ownNames.join("\n"));
setSalaryEnabled(settings.salaryShift.enabled);
setDayThreshold(settings.salaryShift.dayThreshold);
setMonthBasis(settings.monthBasis);
}
}, [settings]);
const saveSettings = async () => {
await updateSettings({
ownNames: ownNamesText.split("\n").map((s) => s.trim()).filter(Boolean),
monthBasis,
salaryShift: {
enabled: salaryEnabled,
categoryNames: settings?.salaryShift.categoryNames ?? ["Gehalt & Besoldung"],
dayThreshold,
},
});
toast.success("Einstellungen gespeichert");
};
const [newAccount, setNewAccount] = useState({ name: "", type: "giro", openingBalance: 0 });
return (
<div className="mx-auto max-w-3xl space-y-6">
<Card>
<CardHeader>
<CardTitle>Konten</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{accounts?.map((account) => (
<div key={account._id} className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<div className="flex-1">
<div className="font-medium">{account.name}</div>
<div className="text-xs text-muted-foreground">
{account.type} · {account.iban ?? "keine IBAN"}
{account.externalId && " · comdirect verbunden"}
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => updateAccount({ id: account._id, isArchived: !account.isArchived })}
>
{account.isArchived ? "Reaktivieren" : "Archivieren"}
</Button>
<Button
size="sm"
variant="destructive"
onClick={async () => {
if (confirm("Konto löschen?")) await removeAccount({ id: account._id });
}}
>
Löschen
</Button>
</div>
))}
<Separator />
<div className="grid gap-2 sm:grid-cols-3">
<Input
placeholder="Name"
value={newAccount.name}
onChange={(e) => setNewAccount({ ...newAccount, name: e.target.value })}
/>
<Select
value={newAccount.type}
onValueChange={(v) => setNewAccount({ ...newAccount, type: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["giro", "tagesgeld", "kreditkarte", "bar", "sonstiges"].map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={async () => {
await createAccount({
name: newAccount.name,
type: newAccount.type,
openingBalance: newAccount.openingBalance,
});
setNewAccount({ name: "", type: "giro", openingBalance: 0 });
toast.success("Konto angelegt");
}}
>
Konto hinzufügen
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Auto-Kategorisierung & Monatslogik</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label>Eigene Namen (für interne Überträge, je Zeile)</Label>
<textarea
className="mt-2 min-h-24 w-full rounded-md border p-2 text-sm"
value={ownNamesText}
onChange={(e) => setOwnNamesText(e.target.value)}
/>
</div>
<div>
<Label>Standard Monats-Basis</Label>
<Select value={monthBasis} onValueChange={(v) => setMonthBasis(v as typeof monthBasis)}>
<SelectTrigger className="mt-2 w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="effective">Zuordnungsmonat (effective)</SelectItem>
<SelectItem value="booking">Buchungsdatum</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Switch checked={salaryEnabled} onCheckedChange={setSalaryEnabled} />
<Label>Gehalts-Folgemonat-Regel aktiv</Label>
</div>
<div>
<Label>Tag-Schwelle (ab Tag N Folgemonat)</Label>
<Input
type="number"
className="mt-2 w-32"
value={dayThreshold}
onChange={(e) => setDayThreshold(Number(e.target.value))}
/>
</div>
<div className="flex gap-2">
<Button onClick={saveSettings}>Speichern</Button>
<Button
variant="outline"
onClick={async () => {
const res = await applySalaryShift({});
toast.success(`${res.updated} Transaktionen aktualisiert`);
}}
>
Regel auf bestehende anwenden
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

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

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CONVEX_URL: string;
readonly VITE_CONVEX_SITE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}