ADD: changed to rect router

This commit is contained in:
hwinkel
2026-03-10 21:49:01 +01:00
parent 44e79e657f
commit 4bc57b2c4e
102 changed files with 5067 additions and 4824 deletions
+285
View File
@@ -0,0 +1,285 @@
import { useState, useCallback } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { calcItemAmounts, calcInvoiceTotals, formatCurrency, TAX_RATES } from "@/lib/tax";
import { Plus, Trash2 } from "lucide-react";
interface Customer {
id: string;
name: string;
}
interface ItemFormData {
position: number;
description: string;
quantity: string;
unit: string;
unitPrice: string;
taxRate: string;
netAmount: number;
taxAmount: number;
grossAmount: number;
}
interface InvoiceFormValues {
customerId: string;
issueDate: string;
deliveryDate: string;
dueDate: string;
notes: string;
items: ItemFormData[];
}
interface InvoiceFormProps {
customers: Customer[];
companyId: string;
onSubmit: (data: Record<string, unknown>) => Promise<void>;
defaultValues?: Partial<InvoiceFormValues>;
}
const defaultItem = (): ItemFormData => ({
position: 1,
description: "",
quantity: "1",
unit: "Stück",
unitPrice: "0.00",
taxRate: "19",
netAmount: 0,
taxAmount: 0,
grossAmount: 0,
});
export function InvoiceForm({ customers, companyId, onSubmit }: InvoiceFormProps) {
const today = new Date().toISOString().split("T")[0];
const dueDefault = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
const { register, handleSubmit, watch, setValue, control, formState: { errors, isSubmitting } } = useForm<InvoiceFormValues>({
defaultValues: {
customerId: "",
issueDate: today,
deliveryDate: today,
dueDate: dueDefault,
notes: "",
items: [defaultItem()],
},
});
const { fields, append, remove } = useFieldArray({ control, name: "items" });
const watchedItems = watch("items");
const recalcItem = useCallback((index: number) => {
const item = watchedItems[index];
if (!item) return;
const qty = parseFloat(item.quantity) || 0;
const price = parseFloat(item.unitPrice) || 0;
const rate = parseFloat(item.taxRate) || 0;
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
setValue(`items.${index}.netAmount`, netAmount);
setValue(`items.${index}.taxAmount`, taxAmount);
setValue(`items.${index}.grossAmount`, grossAmount);
}, [watchedItems, setValue]);
const totals = calcInvoiceTotals(
watchedItems.map((item) => ({
netAmount: item.netAmount ?? 0,
taxAmount: item.taxAmount ?? 0,
grossAmount: item.grossAmount ?? 0,
}))
);
async function handleFormSubmit(data: InvoiceFormValues) {
const items = data.items.map((item, i) => {
const qty = parseFloat(item.quantity) || 0;
const price = parseFloat(item.unitPrice) || 0;
const rate = parseFloat(item.taxRate) || 0;
const { netAmount, taxAmount, grossAmount } = calcItemAmounts(qty, price, rate);
return {
position: i + 1,
description: item.description,
quantity: qty,
unit: item.unit || undefined,
unitPrice: price,
taxRate: rate,
netAmount,
taxAmount,
grossAmount,
};
});
const totals = calcInvoiceTotals(items);
await onSubmit({
companyId,
customerId: data.customerId,
issueDate: data.issueDate,
deliveryDate: data.deliveryDate || undefined,
dueDate: data.dueDate,
notes: data.notes || undefined,
items,
...totals,
});
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label>Kunde *</Label>
<Select onValueChange={(v) => setValue("customerId", v)}>
<SelectTrigger>
<SelectValue placeholder="Kunde auswählen..." />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{errors.customerId && <p className="text-xs text-red-600">Pflichtfeld</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label>Rechnungsdatum *</Label>
<Input type="date" {...register("issueDate", { required: true })} />
</div>
<div className="space-y-1.5">
<Label>Leistungsdatum</Label>
<Input type="date" {...register("deliveryDate")} />
</div>
<div className="space-y-1.5">
<Label>Fällig am *</Label>
<Input type="date" {...register("dueDate", { required: true })} />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Rechnungspositionen</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append(defaultItem())}
>
<Plus className="h-3.5 w-3.5" /> Position hinzufügen
</Button>
</div>
<div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="grid grid-cols-12 gap-2 px-3 py-2 bg-gray-50 border-b border-gray-200 text-xs font-medium text-gray-600">
<div className="col-span-4">Beschreibung</div>
<div className="col-span-1">Menge</div>
<div className="col-span-1">Einh.</div>
<div className="col-span-2">Einzelpreis</div>
<div className="col-span-1">MwSt.</div>
<div className="col-span-2 text-right">Gesamt (brutto)</div>
<div className="col-span-1"></div>
</div>
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-12 gap-2 px-3 py-2.5 border-b border-gray-100 last:border-0 items-center">
<div className="col-span-4">
<Input
{...register(`items.${index}.description`, { required: true })}
placeholder="Leistungsbeschreibung"
className="text-sm"
/>
</div>
<div className="col-span-1">
<Input
{...register(`items.${index}.quantity`)}
className="text-sm text-right"
onBlur={() => recalcItem(index)}
/>
</div>
<div className="col-span-1">
<Input
{...register(`items.${index}.unit`)}
placeholder="Stück"
className="text-sm"
/>
</div>
<div className="col-span-2">
<Input
{...register(`items.${index}.unitPrice`)}
className="text-sm text-right"
onBlur={() => recalcItem(index)}
/>
</div>
<div className="col-span-1">
<Select
defaultValue="19"
onValueChange={(v) => {
setValue(`items.${index}.taxRate`, v);
recalcItem(index);
}}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAX_RATES.map((r) => (
<SelectItem key={r.value} value={String(r.value)}>
{r.value}%
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-2 text-right text-sm font-medium text-gray-900">
{formatCurrency(watchedItems[index]?.grossAmount ?? 0)}
</div>
<div className="col-span-1 flex justify-end">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-red-400 hover:text-red-600 hover:bg-red-50"
onClick={() => remove(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<div className="w-64 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600">
<span>Netto</span>
<span>{formatCurrency(totals.netTotal)}</span>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span>MwSt.</span>
<span>{formatCurrency(totals.taxTotal)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-gray-900 border-t border-gray-200 pt-1.5">
<span>Gesamt (brutto)</span>
<span>{formatCurrency(totals.grossTotal)}</span>
</div>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label>Anmerkungen</Label>
<Textarea {...register("notes")} placeholder="Zahlungsbedingungen, Hinweise..." rows={3} />
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting} size="lg">
{isSubmitting ? "Erstelle Rechnung..." : "Rechnung erstellen"}
</Button>
</div>
</form>
);
}
+395
View File
@@ -0,0 +1,395 @@
import React from "react";
import {
Document,
Page,
Text,
View,
StyleSheet,
} from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: {
fontFamily: "Helvetica",
fontSize: 9,
color: "#111827",
paddingTop: 50,
paddingBottom: 60,
paddingLeft: 55,
paddingRight: 55,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 30,
},
companyName: {
fontSize: 16,
fontFamily: "Helvetica-Bold",
color: "#1e1b4b",
marginBottom: 3,
},
companyInfo: {
fontSize: 8,
color: "#6b7280",
lineHeight: 1.5,
},
invoiceTitle: {
fontSize: 22,
fontFamily: "Helvetica-Bold",
color: "#1e1b4b",
marginBottom: 20,
},
metaGrid: {
flexDirection: "row",
gap: 0,
marginBottom: 20,
backgroundColor: "#f9fafb",
padding: 10,
borderRadius: 4,
},
metaItem: {
flex: 1,
},
metaLabel: {
fontSize: 7,
color: "#9ca3af",
textTransform: "uppercase",
marginBottom: 2,
},
metaValue: {
fontSize: 9,
fontFamily: "Helvetica-Bold",
color: "#111827",
},
addressSection: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 25,
},
addressBlock: {
flex: 1,
},
addressLabel: {
fontSize: 7,
color: "#9ca3af",
textTransform: "uppercase",
marginBottom: 4,
},
addressName: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
color: "#111827",
marginBottom: 2,
},
addressLine: {
fontSize: 8.5,
color: "#374151",
lineHeight: 1.4,
},
tableHeader: {
flexDirection: "row",
backgroundColor: "#1e1b4b",
paddingVertical: 6,
paddingHorizontal: 8,
borderRadius: 3,
marginBottom: 0,
},
tableHeaderText: {
color: "#ffffff",
fontSize: 8,
fontFamily: "Helvetica-Bold",
},
tableRow: {
flexDirection: "row",
paddingVertical: 5,
paddingHorizontal: 8,
borderBottomColor: "#e5e7eb",
borderBottomWidth: 0.5,
},
tableRowAlt: {
backgroundColor: "#f9fafb",
},
col_pos: { width: "5%" },
col_desc: { width: "40%" },
col_qty: { width: "10%", textAlign: "right" },
col_unit: { width: "8%", textAlign: "center" },
col_price: { width: "14%", textAlign: "right" },
col_tax: { width: "8%", textAlign: "center" },
col_total: { width: "15%", textAlign: "right" },
totalsSection: {
marginTop: 15,
flexDirection: "row",
justifyContent: "flex-end",
},
totalsTable: {
width: 220,
},
totalsRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 3,
},
totalsLabel: {
fontSize: 9,
color: "#6b7280",
},
totalsValue: {
fontSize: 9,
color: "#374151",
},
totalsFinalRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 4,
borderTopColor: "#1e1b4b",
borderTopWidth: 1.5,
marginTop: 3,
},
totalsFinalLabel: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
color: "#1e1b4b",
},
totalsFinalValue: {
fontSize: 10,
fontFamily: "Helvetica-Bold",
color: "#1e1b4b",
},
notes: {
marginTop: 20,
padding: 10,
backgroundColor: "#f9fafb",
borderRadius: 4,
},
notesLabel: {
fontSize: 7,
color: "#9ca3af",
textTransform: "uppercase",
marginBottom: 3,
},
notesText: {
fontSize: 8.5,
color: "#374151",
lineHeight: 1.5,
},
footer: {
position: "absolute",
bottom: 30,
left: 55,
right: 55,
borderTopColor: "#e5e7eb",
borderTopWidth: 0.5,
paddingTop: 8,
flexDirection: "row",
justifyContent: "space-between",
},
footerText: {
fontSize: 7,
color: "#9ca3af",
},
});
function formatMoney(amount: number) {
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
}
function formatDate(date: Date | string) {
return new Intl.DateTimeFormat("de-DE").format(new Date(date));
}
interface InvoicePDFProps {
invoice: {
number: string;
issueDate: Date | string;
deliveryDate?: Date | string | null;
dueDate: Date | string;
notes?: string | null;
netTotal: number | string | { toString(): string };
taxTotal: number | string | { toString(): string };
grossTotal: number | string | { toString(): string };
company: {
name: string;
legalForm?: string | null;
taxId?: string | null;
vatId?: string | null;
address: string;
zip: string;
city: string;
email?: string | null;
phone?: string | null;
bankIban?: string | null;
bankBic?: string | null;
bankName?: string | null;
};
customer: {
name: string;
vatId?: string | null;
address: string;
zip: string;
city: string;
country: string;
};
items: Array<{
position: number;
description: string;
quantity: number | string | { toString(): string };
unit?: string | null;
unitPrice: number | string | { toString(): string };
taxRate: number | string | { toString(): string };
netAmount: number | string | { toString(): string };
taxAmount: number | string | { toString(): string };
grossAmount: number | string | { toString(): string };
}>;
};
}
export function InvoicePDFDocument({ invoice }: InvoicePDFProps) {
const n = (v: unknown) => Number(v);
const taxGroups = invoice.items.reduce(
(acc, item) => {
const rate = n(item.taxRate);
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
acc[rate].net += n(item.netAmount);
acc[rate].tax += n(item.taxAmount);
return acc;
},
{} as Record<number, { net: number; tax: number }>
);
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.companyName}>{invoice.company.name}</Text>
{invoice.company.legalForm && (
<Text style={styles.companyInfo}>{invoice.company.legalForm}</Text>
)}
<Text style={styles.companyInfo}>{invoice.company.address}</Text>
<Text style={styles.companyInfo}>{invoice.company.zip} {invoice.company.city}</Text>
{invoice.company.email && (
<Text style={styles.companyInfo}>{invoice.company.email}</Text>
)}
{invoice.company.phone && (
<Text style={styles.companyInfo}>{invoice.company.phone}</Text>
)}
</View>
<View style={{ alignItems: "flex-end" }}>
{invoice.company.taxId && (
<Text style={styles.companyInfo}>St.-Nr.: {invoice.company.taxId}</Text>
)}
{invoice.company.vatId && (
<Text style={styles.companyInfo}>USt-IdNr.: {invoice.company.vatId}</Text>
)}
</View>
</View>
<Text style={styles.invoiceTitle}>Rechnung</Text>
<View style={styles.addressSection}>
<View style={styles.addressBlock}>
<Text style={styles.addressLabel}>Rechnungsempfänger</Text>
<Text style={styles.addressName}>{invoice.customer.name}</Text>
<Text style={styles.addressLine}>{invoice.customer.address}</Text>
<Text style={styles.addressLine}>{invoice.customer.zip} {invoice.customer.city}</Text>
{invoice.customer.country !== "DE" && (
<Text style={styles.addressLine}>{invoice.customer.country}</Text>
)}
{invoice.customer.vatId && (
<Text style={{ ...styles.addressLine, marginTop: 3 }}>
USt-IdNr.: {invoice.customer.vatId}
</Text>
)}
</View>
</View>
<View style={styles.metaGrid}>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rechnungsnummer</Text>
<Text style={styles.metaValue}>{invoice.number}</Text>
</View>
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Rechnungsdatum</Text>
<Text style={styles.metaValue}>{formatDate(invoice.issueDate)}</Text>
</View>
{invoice.deliveryDate && (
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Leistungsdatum</Text>
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate)}</Text>
</View>
)}
<View style={styles.metaItem}>
<Text style={styles.metaLabel}>Fällig am</Text>
<Text style={styles.metaValue}>{formatDate(invoice.dueDate)}</Text>
</View>
</View>
<View style={styles.tableHeader}>
<Text style={{ ...styles.tableHeaderText, ...styles.col_pos }}>#</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_desc }}>Beschreibung</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_qty }}>Menge</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_unit }}>Einh.</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_price }}>EP (netto)</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_tax }}>MwSt.</Text>
<Text style={{ ...styles.tableHeaderText, ...styles.col_total }}>Gesamt (brutto)</Text>
</View>
{invoice.items.map((item, idx) => (
<View key={idx} style={[styles.tableRow, idx % 2 === 1 ? styles.tableRowAlt : {}]}>
<Text style={{ ...styles.col_pos, fontSize: 9 }}>{item.position}</Text>
<Text style={{ ...styles.col_desc, fontSize: 9 }}>{item.description}</Text>
<Text style={{ ...styles.col_qty, fontSize: 9 }}>{n(item.quantity)}</Text>
<Text style={{ ...styles.col_unit, fontSize: 9 }}>{item.unit ?? ""}</Text>
<Text style={{ ...styles.col_price, fontSize: 9 }}>{formatMoney(n(item.unitPrice))}</Text>
<Text style={{ ...styles.col_tax, fontSize: 9 }}>{n(item.taxRate)}%</Text>
<Text style={{ ...styles.col_total, fontSize: 9, fontFamily: "Helvetica-Bold" }}>
{formatMoney(n(item.grossAmount))}
</Text>
</View>
))}
<View style={styles.totalsSection}>
<View style={styles.totalsTable}>
<View style={styles.totalsRow}>
<Text style={styles.totalsLabel}>Nettobetrag</Text>
<Text style={styles.totalsValue}>{formatMoney(n(invoice.netTotal))}</Text>
</View>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
<View key={rate} style={styles.totalsRow}>
<Text style={styles.totalsLabel}>MwSt. {rate}% auf {formatMoney(net)}</Text>
<Text style={styles.totalsValue}>{formatMoney(tax)}</Text>
</View>
))}
<View style={styles.totalsFinalRow}>
<Text style={styles.totalsFinalLabel}>Gesamtbetrag (inkl. MwSt.)</Text>
<Text style={styles.totalsFinalValue}>{formatMoney(n(invoice.grossTotal))}</Text>
</View>
</View>
</View>
{invoice.notes && (
<View style={styles.notes}>
<Text style={styles.notesLabel}>Hinweise</Text>
<Text style={styles.notesText}>{invoice.notes}</Text>
</View>
)}
<View style={styles.footer} fixed>
<Text style={styles.footerText}>
{invoice.company.name}
{invoice.company.taxId ? ` · St.-Nr.: ${invoice.company.taxId}` : ""}
{invoice.company.vatId ? ` · USt-IdNr.: ${invoice.company.vatId}` : ""}
</Text>
{invoice.company.bankIban && (
<Text style={styles.footerText}>
{invoice.company.bankName ? `${invoice.company.bankName} · ` : ""}
IBAN: {invoice.company.bankIban}
{invoice.company.bankBic ? ` · BIC: ${invoice.company.bankBic}` : ""}
</Text>
)}
</View>
</Page>
</Document>
);
}
@@ -0,0 +1,14 @@
import { Badge } from "@/components/ui/badge";
import { InvoiceStatus } from "@prisma/client";
const statusConfig: Record<InvoiceStatus, { label: string; variant: "secondary" | "default" | "success" | "destructive" | "warning" }> = {
DRAFT: { label: "Entwurf", variant: "secondary" },
SENT: { label: "Versendet", variant: "warning" },
PAID: { label: "Bezahlt", variant: "success" },
CANCELLED: { label: "Storniert", variant: "destructive" },
};
export function InvoiceStatusBadge({ status }: { status: InvoiceStatus }) {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
}