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
+136
View File
@@ -0,0 +1,136 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const schema = z.object({
name: z.string().min(1, "Name ist erforderlich"),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1, "Adresse ist erforderlich"),
zip: z.string().min(1, "PLZ ist erforderlich"),
city: z.string().min(1, "Ort ist erforderlich"),
country: z.string().optional(),
email: z.string().email("Ungültige E-Mail").optional().or(z.literal("")),
phone: z.string().optional(),
website: z.string().optional(),
bankIban: z.string().optional(),
bankBic: z.string().optional(),
bankName: z.string().optional(),
invoicePrefix: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface CompanyFormProps {
defaultValues?: Partial<FormData>;
onSubmit: (data: FormData) => Promise<void>;
submitLabel?: string;
}
function Field({ label, error, children }: { label: string; error?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<Label>{label}</Label>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}
export function CompanyForm({ defaultValues, onSubmit, submitLabel = "Speichern" }: CompanyFormProps) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { country: "DE", invoicePrefix: "RE", ...defaultValues },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Stammdaten</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Firmenname *" error={errors.name?.message}>
<Input {...register("name")} placeholder="Muster GmbH" />
</Field>
<Field label="Rechtsform" error={errors.legalForm?.message}>
<Input {...register("legalForm")} placeholder="GmbH, AG, UG..." />
</Field>
<Field label="Steuernummer" error={errors.taxId?.message}>
<Input {...register("taxId")} placeholder="123/456/78901" />
</Field>
<Field label="USt-IdNr." error={errors.vatId?.message}>
<Input {...register("vatId")} placeholder="DE123456789" />
</Field>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Anschrift</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Field label="Straße & Hausnummer *" error={errors.address?.message}>
<Input {...register("address")} placeholder="Musterstraße 1" />
</Field>
</div>
<Field label="PLZ *" error={errors.zip?.message}>
<Input {...register("zip")} placeholder="10115" />
</Field>
<Field label="Ort *" error={errors.city?.message}>
<Input {...register("city")} placeholder="Berlin" />
</Field>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Kontakt</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="E-Mail" error={errors.email?.message}>
<Input {...register("email")} type="email" placeholder="info@firma.de" />
</Field>
<Field label="Telefon" error={errors.phone?.message}>
<Input {...register("phone")} placeholder="+49 30 12345678" />
</Field>
<Field label="Website" error={errors.website?.message}>
<Input {...register("website")} placeholder="https://firma.de" />
</Field>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Bankverbindung</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Field label="IBAN" error={errors.bankIban?.message}>
<Input {...register("bankIban")} placeholder="DE89 3704 0044 0532 0130 00" />
</Field>
</div>
<Field label="BIC" error={errors.bankBic?.message}>
<Input {...register("bankBic")} placeholder="COBADEFFXXX" />
</Field>
<Field label="Kreditinstitut" error={errors.bankName?.message}>
<Input {...register("bankName")} placeholder="Commerzbank" />
</Field>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Rechnungseinstellungen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label="Rechnungsnummern-Präfix" error={errors.invoicePrefix?.message}>
<Input {...register("invoicePrefix")} placeholder="RE" />
</Field>
</div>
<p className="text-xs text-gray-500 mt-1">Format: {"{Präfix}"}-{"{Jahr}"}-{"{Nummer}"} z.B. RE-2024-001</p>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Speichern..." : submitLabel}
</Button>
</div>
</form>
);
}
+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>;
}
+78
View File
@@ -0,0 +1,78 @@
import { Link, Form, useLocation } from "react-router";
import {
Calculator,
Building2,
LayoutDashboard,
LogOut,
ChevronRight,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
}
const navItems: NavItem[] = [
{ label: "Dashboard", href: "/", icon: <LayoutDashboard className="h-4 w-4" /> },
{ label: "Mandanten", href: "/companies", icon: <Building2 className="h-4 w-4" /> },
];
export function Sidebar({ userName }: { userName?: string | null }) {
const location = useLocation();
const pathname = location.pathname;
return (
<aside className="w-60 shrink-0 flex flex-col bg-white border-r border-gray-200 min-h-screen">
<div className="flex items-center gap-3 px-4 py-5 border-b border-gray-200">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600">
<Calculator className="w-4 h-4 text-white" />
</div>
<span className="font-semibold text-gray-900 text-sm leading-tight">
Rechnungs-<br />manager
</span>
</div>
<nav className="flex-1 px-3 py-4 space-y-0.5">
{navItems.map((item) => {
const active = pathname === item.href || (item.href !== "/" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
to={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-indigo-50 text-indigo-700"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
)}
>
{item.icon}
{item.label}
{active && <ChevronRight className="ml-auto h-3 w-3 text-indigo-400" />}
</Link>
);
})}
</nav>
<div className="px-3 py-4 border-t border-gray-200">
{userName && (
<p className="text-xs text-gray-500 px-3 mb-2 truncate">{userName}</p>
)}
<Form method="post" action="/logout">
<Button
type="submit"
variant="ghost"
size="sm"
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
>
<LogOut className="h-4 w-4" />
Abmelden
</Button>
</Form>
</div>
</aside>
);
}
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-indigo-100 text-indigo-800",
secondary: "border-transparent bg-gray-100 text-gray-800",
destructive: "border-transparent bg-red-100 text-red-800",
success: "border-transparent bg-green-100 text-green-800",
warning: "border-transparent bg-yellow-100 text-yellow-800",
outline: "text-gray-700 border-gray-300",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+52
View File
@@ -0,0 +1,52 @@
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-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-indigo-600 text-white shadow hover:bg-indigo-700",
destructive: "bg-red-500 text-white shadow-sm hover:bg-red-600",
outline: "border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-700",
secondary: "bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200",
ghost: "hover:bg-gray-100 text-gray-700",
link: "text-indigo-600 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-lg px-6",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
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 { Button, buttonVariants };
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border border-gray-200 bg-white shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight text-gray-900", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-gray-500", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
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/50 backdrop-blur-sm", className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
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 rounded-xl border border-gray-200 bg-white p-6 shadow-xl",
className
)}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-md opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Schließen</span>
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight text-gray-900", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-gray-500", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose,
DialogContent, DialogHeader, DialogTitle, DialogDescription,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-lg border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn("text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+84
View File
@@ -0,0 +1,84 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
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-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-gray-400" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
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 min-w-[8rem] overflow-hidden rounded-lg border border-gray-200 bg-white text-gray-900 shadow-lg animate-in fade-in-0 zoom-in-95",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
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-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-indigo-50 focus:text-indigo-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
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;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-8 py-1.5 text-xs font-semibold text-gray-500", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectLabel };
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };