initial commit
This commit is contained in:
184
src/App.css
Normal file
184
src/App.css
Normal 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
67
src/App.tsx
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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 |
20
src/components/SeedInitializer.tsx
Normal file
20
src/components/SeedInitializer.tsx
Normal 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;
|
||||
}
|
||||
156
src/components/categories/CategoryFormDialog.tsx
Normal file
156
src/components/categories/CategoryFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/charts/CategoryBreakdownChart.tsx
Normal file
82
src/components/charts/CategoryBreakdownChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/charts/MonthlyTrendChart.tsx
Normal file
39
src/components/charts/MonthlyTrendChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/components/import/ComdirectSyncPanel.tsx
Normal file
145
src/components/import/ComdirectSyncPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
src/components/import/CsvImportWizard.tsx
Normal file
225
src/components/import/CsvImportWizard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/import/ImportPreviewTable.tsx
Normal file
82
src/components/import/ImportPreviewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/layout/AccountFilter.tsx
Normal file
34
src/components/layout/AccountFilter.tsx
Normal 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;
|
||||
}
|
||||
64
src/components/layout/AppShell.tsx
Normal file
64
src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/layout/DateRangeFilter.tsx
Normal file
40
src/components/layout/DateRangeFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/layout/MonthBasisToggle.tsx
Normal file
29
src/components/layout/MonthBasisToggle.tsx
Normal 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;
|
||||
}
|
||||
47
src/components/layout/Sidebar.tsx
Normal file
47
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
src/components/loans/AmortizationSchedule.tsx
Normal file
80
src/components/loans/AmortizationSchedule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
src/components/loans/LoanFormDialog.tsx
Normal file
162
src/components/loans/LoanFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/transactions/AssignMonthDialog.tsx
Normal file
45
src/components/transactions/AssignMonthDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
src/components/transactions/TransactionFormDialog.tsx
Normal file
207
src/components/transactions/TransactionFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/ui/alert.tsx
Normal file
13
src/components/ui/alert.tsx
Normal 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} />;
|
||||
}
|
||||
24
src/components/ui/badge.tsx
Normal file
24
src/components/ui/badge.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/components/ui/button.tsx
Normal file
45
src/components/ui/button.tsx
Normal 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 };
|
||||
27
src/components/ui/card.tsx
Normal file
27
src/components/ui/card.tsx
Normal 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} />;
|
||||
}
|
||||
56
src/components/ui/dialog.tsx
Normal file
56
src/components/ui/dialog.tsx
Normal 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} />;
|
||||
}
|
||||
17
src/components/ui/input.tsx
Normal file
17
src/components/ui/input.tsx
Normal 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";
|
||||
11
src/components/ui/label.tsx
Normal file
11
src/components/ui/label.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/components/ui/select.tsx
Normal file
68
src/components/ui/select.tsx
Normal 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;
|
||||
15
src/components/ui/separator.tsx
Normal file
15
src/components/ui/separator.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
src/components/ui/skeleton.tsx
Normal file
5
src/components/ui/skeleton.tsx
Normal 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} />;
|
||||
}
|
||||
24
src/components/ui/switch.tsx
Normal file
24
src/components/ui/switch.tsx
Normal 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;
|
||||
32
src/components/ui/table.tsx
Normal file
32
src/components/ui/table.tsx
Normal 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} />;
|
||||
}
|
||||
40
src/components/ui/tabs.tsx
Normal file
40
src/components/ui/tabs.tsx
Normal 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;
|
||||
99
src/context/FilterContext.tsx
Normal file
99
src/context/FilterContext.tsx
Normal 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
80
src/index.css
Normal 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
9
src/lib/convex.ts
Normal 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
41
src/lib/format.ts
Normal 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
6
src/lib/utils.ts
Normal 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
19
src/main.tsx
Normal 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>,
|
||||
);
|
||||
68
src/pages/CategoriesPage.tsx
Normal file
68
src/pages/CategoriesPage.tsx
Normal 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
140
src/pages/DashboardPage.tsx
Normal 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
20
src/pages/ImportPage.tsx
Normal 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
89
src/pages/LoansPage.tsx
Normal 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
71
src/pages/LoginPage.tsx
Normal 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
178
src/pages/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
src/pages/TransactionsPage.tsx
Normal file
242
src/pages/TransactionsPage.tsx
Normal 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
10
src/vite-env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user