initial commit
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user