initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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