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
+17
View File
@@ -0,0 +1,17 @@
@import "tailwindcss";
:root {
--background: #f9fafb;
--foreground: #111827;
}
body {
background: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
+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 };
+14
View File
@@ -0,0 +1,14 @@
import prisma from "./prisma";
export async function generateInvoiceNumber(companyId: string): Promise<string> {
const year = new Date().getFullYear();
const company = await prisma.company.update({
where: { id: companyId },
data: { invoiceSequence: { increment: 1 } },
select: { invoicePrefix: true, invoiceSequence: true },
});
const seq = String(company.invoiceSequence).padStart(3, "0");
return `${company.invoicePrefix}-${year}-${seq}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
+40
View File
@@ -0,0 +1,40 @@
export const TAX_RATES = [
{ label: "19% MwSt. (Regelsteuersatz)", value: 19 },
{ label: "7% MwSt. (ermäßigt)", value: 7 },
{ label: "0% (steuerfrei / §13b UStG)", value: 0 },
] as const;
export function calcItemAmounts(
quantity: number,
unitPrice: number,
taxRate: number
) {
const netAmount = Math.round(quantity * unitPrice * 100) / 100;
const taxAmount = Math.round(netAmount * (taxRate / 100) * 100) / 100;
const grossAmount = Math.round((netAmount + taxAmount) * 100) / 100;
return { netAmount, taxAmount, grossAmount };
}
export function calcInvoiceTotals(
items: Array<{ netAmount: number; taxAmount: number; grossAmount: number }>
) {
const netTotal = items.reduce((sum, i) => sum + i.netAmount, 0);
const taxTotal = items.reduce((sum, i) => sum + i.taxAmount, 0);
const grossTotal = items.reduce((sum, i) => sum + i.grossAmount, 0);
return {
netTotal: Math.round(netTotal * 100) / 100,
taxTotal: Math.round(taxTotal * 100) / 100,
grossTotal: Math.round(grossTotal * 100) / 100,
};
}
export function formatCurrency(amount: number | string): string {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(Number(amount));
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("de-DE").format(new Date(date));
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+25
View File
@@ -0,0 +1,25 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import "./app.css";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Annas Rechnungsmanager</title>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
+31
View File
@@ -0,0 +1,31 @@
import { type RouteConfig, index, route, layout } from "@react-router/dev/routes";
export default [
route("login", "routes/login.tsx"),
route("logout", "routes/logout.ts"),
layout("routes/dashboard-layout.tsx", [
index("routes/home.tsx"),
route("companies", "routes/companies.tsx"),
route("companies/new", "routes/companies.new.tsx"),
route("companies/:id", "routes/companies.$id.tsx"),
route("companies/:id/edit", "routes/companies.$id.edit.tsx"),
route("companies/:id/customers", "routes/companies.$id.customers.tsx"),
route("companies/:id/invoices", "routes/companies.$id.invoices.tsx"),
route("companies/:id/invoices/new", "routes/companies.$id.invoices.new.tsx"),
route("companies/:id/invoices/:invoiceId", "routes/companies.$id.invoices.$invoiceId.tsx"),
route("companies/:id/reports", "routes/companies.$id.reports.tsx"),
]),
// API resource routes
route("api/companies", "routes/api.companies.ts"),
route("api/companies/:id", "routes/api.companies.$id.ts"),
route("api/companies/:id/customers", "routes/api.companies.$id.customers.ts"),
route("api/companies/:id/invoices", "routes/api.companies.$id.invoices.ts"),
route("api/customers", "routes/api.customers.ts"),
route("api/customers/:id", "routes/api.customers.$id.ts"),
route("api/invoices", "routes/api.invoices.ts"),
route("api/invoices/:id", "routes/api.invoices.$id.ts"),
route("api/invoices/:id/pdf", "routes/api.invoices.$id.pdf.ts"),
route("api/reports", "routes/api.reports.ts"),
] satisfies RouteConfig;
+17
View File
@@ -0,0 +1,17 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: params.id },
orderBy: { name: "asc" },
});
return Response.json(customers);
}
+18
View File
@@ -0,0 +1,18 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const invoices = await prisma.invoice.findMany({
where: { companyId: params.id },
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
});
return Response.json(invoices);
}
+52
View File
@@ -0,0 +1,52 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().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(),
});
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(company);
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const company = await prisma.company.findFirst({ where: { id: params.id, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.company.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = companySchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.company.update({ where: { id: params.id }, data: parsed.data });
return Response.json(updated);
}
+51
View File
@@ -0,0 +1,51 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const companySchema = z.object({
name: z.string().min(1),
legalForm: z.string().optional(),
taxId: z.string().optional(),
vatId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().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().default("RE"),
});
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const companies = await prisma.company.findMany({
where: { userId: user.id },
include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" },
});
return Response.json(companies);
}
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = companySchema.safeParse(body);
if (!parsed.success) {
return Response.json({ error: parsed.error.issues }, { status: 400 });
}
const company = await prisma.company.create({
data: { ...parsed.data, userId: user.id },
});
return Response.json(company, { status: 201 });
}
+50
View File
@@ -0,0 +1,50 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const customer = await prisma.customer.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(customer);
}
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const customer = await prisma.customer.findFirst({
where: { id: params.id, company: { userId: user.id } },
});
if (!customer) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.customer.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PUT
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.customer.update({ where: { id: params.id }, data: parsed.data });
return Response.json(updated);
}
+33
View File
@@ -0,0 +1,33 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { z } from "zod";
const customerSchema = z.object({
companyId: z.string().min(1),
name: z.string().min(1),
vatId: z.string().optional(),
taxId: z.string().optional(),
address: z.string().min(1),
zip: z.string().min(1),
city: z.string().min(1),
country: z.string().optional().default("DE"),
email: z.string().email().optional().or(z.literal("")),
phone: z.string().optional(),
});
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = customerSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const company = await prisma.company.findFirst({
where: { id: parsed.data.companyId, userId: user.id },
});
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const customer = await prisma.customer.create({ data: parsed.data });
return Response.json(customer, { status: 201 });
}
+34
View File
@@ -0,0 +1,34 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await prisma.invoice.findFirst({
where: { id: params.id, company: { userId: user.id } },
include: {
items: { orderBy: { position: "asc" } },
customer: true,
company: true,
},
});
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
const { renderToBuffer } = await import("@react-pdf/renderer");
const React = (await import("react")).default;
const { InvoicePDFDocument } = await import("@/components/invoice/invoice-pdf");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const element = React.createElement(InvoicePDFDocument as any, { invoice }) as any;
const buffer = await renderToBuffer(element);
return new Response(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="rechnung-${invoice.number}.pdf"`,
},
});
}
+47
View File
@@ -0,0 +1,47 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { InvoiceStatus } from "@prisma/client";
import { z } from "zod";
async function getInvoice(id: string, userId: string) {
return prisma.invoice.findFirst({
where: { id, company: { userId } },
include: { items: { orderBy: { position: "asc" } }, customer: true, company: true },
});
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(invoice);
}
const statusSchema = z.object({ status: z.nativeEnum(InvoiceStatus) });
export async function action({ request, params }: { request: Request; params: { id: string } }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const invoice = await getInvoice(params.id, user.id);
if (!invoice) return Response.json({ error: "Not found" }, { status: 404 });
if (request.method === "DELETE") {
await prisma.invoice.delete({ where: { id: params.id } });
return Response.json({ ok: true });
}
// PATCH
const body = await request.json();
const parsed = statusSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const updated = await prisma.invoice.update({
where: { id: params.id },
data: { status: parsed.data.status },
});
return Response.json(updated);
}
+60
View File
@@ -0,0 +1,60 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { generateInvoiceNumber } from "@/lib/invoice-number";
import { z } from "zod";
const itemSchema = z.object({
position: z.number().int(),
description: z.string().min(1),
quantity: z.number().positive(),
unit: z.string().optional(),
unitPrice: z.number(),
taxRate: z.number(),
netAmount: z.number(),
taxAmount: z.number(),
grossAmount: z.number(),
});
const invoiceSchema = z.object({
companyId: z.string().min(1),
customerId: z.string().min(1),
issueDate: z.string(),
deliveryDate: z.string().optional(),
dueDate: z.string(),
notes: z.string().optional(),
items: z.array(itemSchema).min(1),
netTotal: z.number(),
taxTotal: z.number(),
grossTotal: z.number(),
});
export async function action({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const body = await request.json();
const parsed = invoiceSchema.safeParse(body);
if (!parsed.success) return Response.json({ error: parsed.error.issues }, { status: 400 });
const { items, companyId, ...invoiceData } = parsed.data;
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Company not found" }, { status: 404 });
const number = await generateInvoiceNumber(companyId);
const invoice = await prisma.invoice.create({
data: {
...invoiceData,
number,
companyId,
issueDate: new Date(invoiceData.issueDate),
deliveryDate: invoiceData.deliveryDate ? new Date(invoiceData.deliveryDate) : null,
dueDate: new Date(invoiceData.dueDate),
items: { create: items },
},
include: { items: true, customer: true },
});
return Response.json(invoice, { status: 201 });
}
+92
View File
@@ -0,0 +1,92 @@
import { getApiUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { InvoiceStatus } from "@prisma/client";
export async function loader({ request }: { request: Request }) {
const user = await getApiUser(request);
if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(request.url);
const companyId = searchParams.get("companyId");
const year = parseInt(searchParams.get("year") ?? String(new Date().getFullYear()));
if (!companyId) return Response.json({ error: "companyId required" }, { status: 400 });
const company = await prisma.company.findFirst({ where: { id: companyId, userId: user.id } });
if (!company) return Response.json({ error: "Not found" }, { status: 404 });
const invoices = await prisma.invoice.findMany({
where: {
companyId,
status: { in: [InvoiceStatus.PAID, InvoiceStatus.SENT] },
issueDate: {
gte: new Date(`${year}-01-01`),
lt: new Date(`${year + 1}-01-01`),
},
},
include: { items: true, customer: { select: { name: true } } },
orderBy: { issueDate: "asc" },
});
const monthly: Record<number, {
month: number;
invoiceCount: number;
netTotal: number;
taxTotal: number;
grossTotal: number;
taxGroups: Record<number, { netAmount: number; taxAmount: number }>;
}> = {};
for (let m = 1; m <= 12; m++) {
monthly[m] = { month: m, invoiceCount: 0, netTotal: 0, taxTotal: 0, grossTotal: 0, taxGroups: {} };
}
for (const invoice of invoices) {
const month = new Date(invoice.issueDate).getMonth() + 1;
const m = monthly[month];
m.invoiceCount++;
m.netTotal += Number(invoice.netTotal);
m.taxTotal += Number(invoice.taxTotal);
m.grossTotal += Number(invoice.grossTotal);
for (const item of invoice.items) {
const rate = Number(item.taxRate);
if (!m.taxGroups[rate]) m.taxGroups[rate] = { netAmount: 0, taxAmount: 0 };
m.taxGroups[rate].netAmount += Number(item.netAmount);
m.taxGroups[rate].taxAmount += Number(item.taxAmount);
}
}
const quarterly = [1, 2, 3, 4].map((q) => {
const months = [q * 3 - 2, q * 3 - 1, q * 3];
const data = months.map((m) => monthly[m]);
const taxGroups: Record<number, { netAmount: number; taxAmount: number }> = {};
for (const m of data) {
for (const [rate, group] of Object.entries(m.taxGroups)) {
const r = Number(rate);
if (!taxGroups[r]) taxGroups[r] = { netAmount: 0, taxAmount: 0 };
taxGroups[r].netAmount += group.netAmount;
taxGroups[r].taxAmount += group.taxAmount;
}
}
return {
quarter: q,
invoiceCount: data.reduce((s, m) => s + m.invoiceCount, 0),
netTotal: data.reduce((s, m) => s + m.netTotal, 0),
taxTotal: data.reduce((s, m) => s + m.taxTotal, 0),
grossTotal: data.reduce((s, m) => s + m.grossTotal, 0),
taxGroups,
};
});
const yearTotal = {
invoiceCount: invoices.length,
netTotal: invoices.reduce((s, i) => s + Number(i.netTotal), 0),
taxTotal: invoices.reduce((s, i) => s + Number(i.taxTotal), 0),
grossTotal: invoices.reduce((s, i) => s + Number(i.grossTotal), 0),
};
return Response.json({ year, monthly: Object.values(monthly), quarterly, yearTotal, invoices });
}
+246
View File
@@ -0,0 +1,246 @@
import { useState } from "react";
import { Link, useLoaderData, useParams, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Users, Plus, Edit, Trash2, ChevronLeft, Mail, Phone } from "lucide-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Pflichtfeld"),
vatId: z.string().optional(),
address: z.string().min(1, "Pflichtfeld"),
zip: z.string().min(1, "Pflichtfeld"),
city: z.string().min(1, "Pflichtfeld"),
country: z.string().optional(),
email: z.string().email("Ungültige E-Mail").optional().or(z.literal("")),
phone: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface Customer {
id: string;
name: string;
vatId?: string | null;
address: string;
zip: string;
city: string;
email?: string | null;
phone?: string | null;
}
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: params.id },
orderBy: { name: "asc" },
});
return { customers, companyId: params.id };
}
function CustomerForm({
defaultValues,
onSubmit,
submitLabel,
}: {
defaultValues?: Partial<FormData>;
onSubmit: (d: FormData) => Promise<void>;
submitLabel: string;
}) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { country: "DE", ...defaultValues },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label>Name *</Label>
<Input {...register("name")} placeholder="Beispiel AG" />
{errors.name && <p className="text-xs text-red-600">{errors.name.message}</p>}
</div>
<div className="col-span-2 space-y-1.5">
<Label>Straße & Nr. *</Label>
<Input {...register("address")} placeholder="Musterstr. 1" />
{errors.address && <p className="text-xs text-red-600">{errors.address.message}</p>}
</div>
<div className="space-y-1.5">
<Label>PLZ *</Label>
<Input {...register("zip")} placeholder="10115" />
</div>
<div className="space-y-1.5">
<Label>Ort *</Label>
<Input {...register("city")} placeholder="Berlin" />
</div>
<div className="space-y-1.5">
<Label>USt-IdNr.</Label>
<Input {...register("vatId")} placeholder="DE..." />
</div>
<div className="space-y-1.5">
<Label>E-Mail</Label>
<Input {...register("email")} type="email" placeholder="kontakt@..." />
</div>
<div className="col-span-2 space-y-1.5">
<Label>Telefon</Label>
<Input {...register("phone")} placeholder="+49..." />
</div>
</div>
<div className="flex justify-end pt-2">
<Button type="submit" disabled={isSubmitting}>{isSubmitting ? "Speichern..." : submitLabel}</Button>
</div>
</form>
);
}
export default function CustomersPage() {
const { customers, companyId } = useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [open, setOpen] = useState(false);
const [editCustomer, setEditCustomer] = useState<Customer | null>(null);
async function handleCreate(data: FormData) {
await fetch("/api/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, companyId }),
});
setOpen(false);
revalidate();
}
async function handleEdit(data: FormData) {
if (!editCustomer) return;
await fetch(`/api/customers/${editCustomer.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setEditCustomer(null);
revalidate();
}
async function handleDelete(customerId: string) {
if (!confirm("Kunden wirklich löschen?")) return;
await fetch(`/api/customers/${customerId}`, { method: "DELETE" });
revalidate();
}
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Kunden</h1>
<p className="text-gray-500 mt-1">{customers.length} Kunden</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="h-4 w-4" /> Kunde anlegen</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuer Kunde</DialogTitle>
</DialogHeader>
<CustomerForm onSubmit={handleCreate} submitLabel="Anlegen" />
</DialogContent>
</Dialog>
</div>
<Dialog open={!!editCustomer} onOpenChange={(o) => !o && setEditCustomer(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kunde bearbeiten</DialogTitle>
</DialogHeader>
{editCustomer && (
<CustomerForm
defaultValues={{
name: editCustomer.name,
address: editCustomer.address,
zip: editCustomer.zip,
city: editCustomer.city,
vatId: editCustomer.vatId ?? undefined,
email: editCustomer.email ?? undefined,
phone: editCustomer.phone ?? undefined,
}}
onSubmit={handleEdit}
submitLabel="Speichern"
/>
)}
</DialogContent>
</Dialog>
{customers.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Users className="h-10 w-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">Noch keine Kunden angelegt</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{customers.map((customer) => (
<Card key={customer.id}>
<CardContent className="pt-4">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-gray-900">{customer.name}</p>
<p className="text-sm text-gray-500 mt-0.5">{customer.address}, {customer.zip} {customer.city}</p>
{customer.vatId && <p className="text-xs text-gray-400 mt-0.5">USt-IdNr.: {customer.vatId}</p>}
<div className="flex gap-3 mt-2">
{customer.email && (
<span className="flex items-center gap-1 text-xs text-gray-500">
<Mail className="h-3 w-3" />{customer.email}
</span>
)}
{customer.phone && (
<span className="flex items-center gap-1 text-xs text-gray-500">
<Phone className="h-3 w-3" />{customer.phone}
</span>
)}
</div>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditCustomer(customer)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleDelete(customer.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { Link, useLoaderData, useNavigate } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { CompanyForm } from "@/components/company/company-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronLeft } from "lucide-react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const company = await prisma.company.findFirst({
where: { id: params.id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
return { company };
}
export default function EditCompanyPage() {
const { company } = useLoaderData<typeof loader>();
const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch(`/api/companies/${company.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) navigate(`/companies/${company.id}`);
}
return (
<div>
<Link
to={`/companies/${company.id}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Mandant bearbeiten</h1>
</div>
<Card>
<CardHeader>
<CardTitle>Mandantendaten</CardTitle>
</CardHeader>
<CardContent>
<CompanyForm
defaultValues={company as Record<string, string>}
onSubmit={handleSubmit}
submitLabel="Änderungen speichern"
/>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,297 @@
import { Link, useLoaderData, useNavigate, useRevalidator } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { ChevronLeft, Download, CheckCircle, Send, Trash2 } from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
export async function loader({
request,
params,
}: {
request: Request;
params: { id: string; invoiceId: string };
}) {
const user = await requireUser(request);
const { id, invoiceId } = params;
const invoice = await prisma.invoice.findFirst({
where: { id: invoiceId, companyId: id, company: { userId: user.id } },
include: {
items: { orderBy: { position: "asc" } },
customer: true,
company: true,
},
});
if (!invoice) throw new Response("Not Found", { status: 404 });
return {
invoice: {
...invoice,
netTotal: Number(invoice.netTotal),
taxTotal: Number(invoice.taxTotal),
grossTotal: Number(invoice.grossTotal),
issueDate: invoice.issueDate.toISOString(),
dueDate: invoice.dueDate.toISOString(),
deliveryDate: invoice.deliveryDate?.toISOString() ?? null,
items: invoice.items.map((item) => ({
...item,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
taxRate: Number(item.taxRate),
netAmount: Number(item.netAmount),
taxAmount: Number(item.taxAmount),
grossAmount: Number(item.grossAmount),
})),
},
};
}
export default function InvoiceDetailPage() {
const { invoice } = useLoaderData<typeof loader>();
const navigate = useNavigate();
const { revalidate } = useRevalidator();
const [loading, setLoading] = useState(false);
const id = invoice.companyId;
const taxGroups = invoice.items.reduce(
(acc, item) => {
const rate = item.taxRate;
if (!acc[rate]) acc[rate] = { net: 0, tax: 0 };
acc[rate].net += item.netAmount;
acc[rate].tax += item.taxAmount;
return acc;
},
{} as Record<number, { net: number; tax: number }>
);
async function updateStatus(status: InvoiceStatus) {
setLoading(true);
await fetch(`/api/invoices/${invoice.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
setLoading(false);
revalidate();
}
async function handleDelete() {
if (!confirm("Rechnung wirklich löschen?")) return;
await fetch(`/api/invoices/${invoice.id}`, { method: "DELETE" });
navigate(`/companies/${id}/invoices`);
}
async function downloadPdf() {
const res = await fetch(`/api/invoices/${invoice.id}/pdf`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `rechnung-${invoice.number}.pdf`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div>
<Link
to={`/companies/${id}/invoices`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
</Link>
<div className="flex items-start justify-between mb-8">
<div>
<div className="flex items-center gap-3 mb-1">
<h1 className="text-2xl font-bold text-gray-900">{invoice.number}</h1>
<InvoiceStatusBadge status={invoice.status} />
</div>
<p className="text-gray-500">
{invoice.customer.name} · {formatDate(invoice.issueDate)}
</p>
</div>
{/* Invoice Actions */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={downloadPdf}>
<Download className="h-4 w-4" /> PDF
</Button>
{invoice.status === "DRAFT" && (
<Button size="sm" onClick={() => updateStatus(InvoiceStatus.SENT)} disabled={loading}>
<Send className="h-4 w-4" /> Als versendet markieren
</Button>
)}
{invoice.status === "SENT" && (
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => updateStatus(InvoiceStatus.PAID)}
disabled={loading}
>
<CheckCircle className="h-4 w-4" /> Als bezahlt markieren
</Button>
)}
{(invoice.status === "DRAFT" || invoice.status === "CANCELLED") && (
<Button
variant="outline"
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-8 mb-8">
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Absender</p>
<p className="font-semibold text-gray-900">{invoice.company.name}</p>
{invoice.company.legalForm && <p className="text-sm text-gray-600">{invoice.company.legalForm}</p>}
<p className="text-sm text-gray-600">{invoice.company.address}</p>
<p className="text-sm text-gray-600">{invoice.company.zip} {invoice.company.city}</p>
{invoice.company.taxId && (
<p className="text-xs text-gray-500 mt-1">St.-Nr.: {invoice.company.taxId}</p>
)}
{invoice.company.vatId && (
<p className="text-xs text-gray-500">USt-IdNr.: {invoice.company.vatId}</p>
)}
</div>
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Rechnungsempfänger</p>
<p className="font-semibold text-gray-900">{invoice.customer.name}</p>
<p className="text-sm text-gray-600">{invoice.customer.address}</p>
<p className="text-sm text-gray-600">{invoice.customer.zip} {invoice.customer.city}</p>
{invoice.customer.vatId && (
<p className="text-xs text-gray-500 mt-1">USt-IdNr.: {invoice.customer.vatId}</p>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg">
<div>
<p className="text-xs text-gray-500">Rechnungsnummer</p>
<p className="text-sm font-semibold text-gray-900">{invoice.number}</p>
</div>
<div>
<p className="text-xs text-gray-500">Rechnungsdatum</p>
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.issueDate)}</p>
</div>
{invoice.deliveryDate && (
<div>
<p className="text-xs text-gray-500">Leistungsdatum</p>
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.deliveryDate)}</p>
</div>
)}
<div>
<p className="text-xs text-gray-500">Fällig am</p>
<p className="text-sm font-medium text-gray-900">{formatDate(invoice.dueDate)}</p>
</div>
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden mb-6">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-4 py-2.5 font-medium text-gray-600 w-8">#</th>
<th className="text-left px-4 py-2.5 font-medium text-gray-600">Beschreibung</th>
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Menge</th>
<th className="text-right px-4 py-2.5 font-medium text-gray-600">EP (netto)</th>
<th className="text-right px-4 py-2.5 font-medium text-gray-600">MwSt.</th>
<th className="text-right px-4 py-2.5 font-medium text-gray-600">Betrag (brutto)</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{invoice.items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-gray-500">{item.position}</td>
<td className="px-4 py-3 text-gray-900">{item.description}</td>
<td className="px-4 py-3 text-right text-gray-700">
{item.quantity} {item.unit && <span className="text-gray-500">{item.unit}</span>}
</td>
<td className="px-4 py-3 text-right text-gray-700">{formatCurrency(item.unitPrice)}</td>
<td className="px-4 py-3 text-right text-gray-700">{item.taxRate}%</td>
<td className="px-4 py-3 text-right font-medium text-gray-900">{formatCurrency(item.grossAmount)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end">
<div className="w-72 space-y-1.5">
<div className="flex justify-between text-sm text-gray-600">
<span>Nettobetrag</span>
<span>{formatCurrency(invoice.netTotal)}</span>
</div>
{Object.entries(taxGroups).map(([rate, { net, tax }]) => (
<div key={rate} className="flex justify-between text-sm text-gray-600">
<span>MwSt. {rate}% auf {formatCurrency(net)}</span>
<span>{formatCurrency(tax)}</span>
</div>
))}
<div className="flex justify-between text-base font-bold text-gray-900 border-t border-gray-300 pt-2">
<span>Gesamtbetrag (brutto)</span>
<span>{formatCurrency(invoice.grossTotal)}</span>
</div>
</div>
</div>
{invoice.notes && (
<div className="mt-6 pt-4 border-t border-gray-200">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Hinweise</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{invoice.notes}</p>
</div>
)}
{invoice.company.bankIban && (
<div className="mt-4 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-500">
Bankverbindung: {invoice.company.bankName && `${invoice.company.bankName} · `}
IBAN: {invoice.company.bankIban}
{invoice.company.bankBic && ` · BIC: ${invoice.company.bankBic}`}
</p>
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Zusammenfassung</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs text-gray-500">Netto</p>
<p className="font-medium text-gray-900">{formatCurrency(invoice.netTotal)}</p>
</div>
<div>
<p className="text-xs text-gray-500">MwSt.</p>
<p className="font-medium text-gray-900">{formatCurrency(invoice.taxTotal)}</p>
</div>
<div className="border-t border-gray-200 pt-2">
<p className="text-xs text-gray-500">Brutto</p>
<p className="text-lg font-bold text-gray-900">{formatCurrency(invoice.grossTotal)}</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Link, useLoaderData, useNavigate, redirect } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { InvoiceForm } from "@/components/invoice/invoice-form";
import { ChevronLeft } from "lucide-react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
const customers = await prisma.customer.findMany({
where: { companyId: id },
orderBy: { name: "asc" },
select: { id: true, name: true },
});
if (customers.length === 0) {
throw redirect(`/companies/${id}/customers`);
}
return { company, customers };
}
export default function NewInvoicePage() {
const { company, customers } = useLoaderData<typeof loader>();
const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch("/api/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const invoice = await res.json();
navigate(`/companies/${company.id}/invoices/${invoice.id}`);
} else {
alert("Fehler beim Erstellen der Rechnung.");
}
}
return (
<div>
<Link
to={`/companies/${company.id}/invoices`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zu Rechnungen
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Neue Rechnung</h1>
<p className="text-gray-500 mt-1">Für Mandant: {company.name}</p>
</div>
<Card>
<CardHeader>
<CardTitle>Rechnungsdaten</CardTitle>
</CardHeader>
<CardContent>
<InvoiceForm customers={customers} companyId={company.id} onSubmit={handleSubmit} />
</CardContent>
</Card>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { InvoiceStatusBadge } from "@/components/invoice/invoice-status-badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import { Plus, FileText, ChevronLeft } from "lucide-react";
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
});
if (!company) throw new Response("Not Found", { status: 404 });
const invoices = await prisma.invoice.findMany({
where: { companyId: id },
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
});
return {
company,
invoices: invoices.map((inv) => ({
...inv,
grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.toISOString(),
})),
};
}
export default function InvoicesPage() {
const { company, invoices } = useLoaderData<typeof loader>();
const id = company.id;
return (
<div>
<Link
to={`/companies/${id}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> {company.name}
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Rechnungen</h1>
<p className="text-gray-500 mt-1">{invoices.length} Rechnungen für {company.name}</p>
</div>
<Button asChild>
<Link to={`/companies/${id}/invoices/new`}>
<Plus className="h-4 w-4" /> Neue Rechnung
</Link>
</Button>
</div>
{invoices.length === 0 ? (
<Card>
<CardContent className="py-16 text-center">
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Rechnungen</h3>
<p className="text-gray-500 mb-6 text-sm">Erstellen Sie die erste Rechnung für diesen Mandanten.</p>
<Button asChild>
<Link to={`/companies/${id}/invoices/new`}>
<Plus className="h-4 w-4" /> Erste Rechnung erstellen
</Link>
</Button>
</CardContent>
</Card>
) : (
<Card>
<div className="divide-y divide-gray-100">
{invoices.map((invoice) => (
<Link
key={invoice.id}
to={`/companies/${id}/invoices/${invoice.id}`}
className="flex items-center justify-between px-6 py-4 hover:bg-gray-50 transition-colors group"
>
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-gray-100 group-hover:bg-indigo-50 transition-colors">
<FileText className="h-4 w-4 text-gray-500 group-hover:text-indigo-600 transition-colors" />
</div>
<div>
<p className="font-medium text-gray-900">{invoice.number}</p>
<p className="text-sm text-gray-500">{invoice.customer.name}</p>
</div>
</div>
<div className="flex items-center gap-6">
<p className="text-sm text-gray-500">{formatDate(invoice.issueDate)}</p>
<InvoiceStatusBadge status={invoice.status} />
<p className="font-medium text-gray-900 w-28 text-right">
{formatCurrency(invoice.grossTotal)}
</p>
</div>
</Link>
))}
</div>
</Card>
)}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useState, useEffect } from "react";
import { Link, useParams } from "react-router";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency } from "@/lib/tax";
import { ChevronLeft, TrendingUp, BarChart3 } from "lucide-react";
const MONTHS = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"];
interface TaxGroup {
netAmount: number;
taxAmount: number;
}
interface MonthData {
month: number;
invoiceCount: number;
netTotal: number;
taxTotal: number;
grossTotal: number;
taxGroups: Record<string, TaxGroup>;
}
interface QuarterData {
quarter: number;
invoiceCount: number;
netTotal: number;
taxTotal: number;
grossTotal: number;
taxGroups: Record<string, TaxGroup>;
}
interface ReportData {
year: number;
monthly: MonthData[];
quarterly: QuarterData[];
yearTotal: {
invoiceCount: number;
netTotal: number;
taxTotal: number;
grossTotal: number;
};
}
export default function ReportsPage() {
const { id: companyId } = useParams<{ id: string }>();
const [year, setYear] = useState(new Date().getFullYear());
const [data, setData] = useState<ReportData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/reports?companyId=${companyId}&year=${year}`)
.then((r) => r.json())
.then((d) => { setData(d); setLoading(false); });
}, [companyId, year]);
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i);
return (
<div>
<Link
to={`/companies/${companyId}`}
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zum Mandanten
</Link>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Steuerberichte</h1>
<p className="text-gray-500 mt-1">Auswertungen für Steuererklärung und USt-Voranmeldung</p>
</div>
<select
value={year}
onChange={(e) => setYear(Number(e.target.value))}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{years.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
{loading ? (
<div className="text-center text-gray-500 py-12">Lade Auswertung...</div>
) : data && (
<div className="space-y-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Rechnungen</p>
<p className="text-2xl font-bold text-gray-900">{data.yearTotal.invoiceCount}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Umsatz (netto)</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">USt. gesamt</p>
<p className="text-2xl font-bold text-indigo-600">{formatCurrency(data.yearTotal.taxTotal)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<p className="text-sm text-gray-500 mb-1">Umsatz (brutto)</p>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-indigo-600" />
<CardTitle>USt-Voranmeldung (quartalsweise)</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left pb-3 text-gray-500 font-medium">Quartal</th>
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
<th className="text-right pb-3 text-gray-500 font-medium">USt. 19%</th>
<th className="text-right pb-3 text-gray-500 font-medium">USt. 7%</th>
<th className="text-right pb-3 text-gray-500 font-medium">USt. gesamt</th>
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.quarterly.map((q) => (
<tr key={q.quarter} className="hover:bg-gray-50">
<td className="py-3 font-medium text-gray-900">Q{q.quarter} {year}</td>
<td className="py-3 text-right text-gray-700">{q.invoiceCount}</td>
<td className="py-3 text-right text-gray-700">{formatCurrency(q.netTotal)}</td>
<td className="py-3 text-right text-gray-700">
{q.taxGroups["19"] ? formatCurrency(q.taxGroups["19"].taxAmount) : "—"}
</td>
<td className="py-3 text-right text-gray-700">
{q.taxGroups["7"] ? formatCurrency(q.taxGroups["7"].taxAmount) : "—"}
</td>
<td className="py-3 text-right font-medium text-indigo-700">{formatCurrency(q.taxTotal)}</td>
<td className="py-3 text-right font-semibold text-gray-900">{formatCurrency(q.grossTotal)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300">
<td className="pt-3 font-bold text-gray-900">Gesamt {year}</td>
<td className="pt-3 text-right font-bold text-gray-900">{data.yearTotal.invoiceCount}</td>
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.netTotal)}</td>
<td className="pt-3 text-right font-bold text-gray-900">
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["19"]?.taxAmount ?? 0), 0))}
</td>
<td className="pt-3 text-right font-bold text-gray-900">
{formatCurrency(data.quarterly.reduce((s, q) => s + (q.taxGroups["7"]?.taxAmount ?? 0), 0))}
</td>
<td className="pt-3 text-right font-bold text-indigo-700">{formatCurrency(data.yearTotal.taxTotal)}</td>
<td className="pt-3 text-right font-bold text-gray-900">{formatCurrency(data.yearTotal.grossTotal)}</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-indigo-600" />
<CardTitle>Monatliche Übersicht</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left pb-3 text-gray-500 font-medium">Monat</th>
<th className="text-right pb-3 text-gray-500 font-medium">Rechnungen</th>
<th className="text-right pb-3 text-gray-500 font-medium">Netto</th>
<th className="text-right pb-3 text-gray-500 font-medium">USt.</th>
<th className="text-right pb-3 text-gray-500 font-medium">Brutto</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.monthly.map((m) => (
<tr key={m.month} className={`hover:bg-gray-50 ${m.invoiceCount === 0 ? "opacity-40" : ""}`}>
<td className="py-2.5 font-medium text-gray-900">{MONTHS[m.month - 1]} {year}</td>
<td className="py-2.5 text-right text-gray-700">{m.invoiceCount || "—"}</td>
<td className="py-2.5 text-right text-gray-700">
{m.netTotal > 0 ? formatCurrency(m.netTotal) : "—"}
</td>
<td className="py-2.5 text-right text-indigo-700">
{m.taxTotal > 0 ? formatCurrency(m.taxTotal) : "—"}
</td>
<td className="py-2.5 text-right font-medium text-gray-900">
{m.grossTotal > 0 ? formatCurrency(m.grossTotal) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { formatCurrency, formatDate } from "@/lib/tax";
import {
FileText, Users, BarChart3, Plus, Edit, Building2,
Mail, Phone, CreditCard, Receipt
} from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
const statusLabels: Record<InvoiceStatus, string> = {
DRAFT: "Entwurf",
SENT: "Versendet",
PAID: "Bezahlt",
CANCELLED: "Storniert",
};
const statusVariants: Record<InvoiceStatus, "secondary" | "default" | "success" | "destructive" | "warning"> = {
DRAFT: "secondary",
SENT: "warning",
PAID: "success",
CANCELLED: "destructive",
};
export async function loader({ request, params }: { request: Request; params: { id: string } }) {
const user = await requireUser(request);
const { id } = params;
const company = await prisma.company.findFirst({
where: { id, userId: user.id },
include: {
invoices: {
include: { customer: { select: { name: true } } },
orderBy: { issueDate: "desc" },
take: 5,
},
_count: { select: { invoices: true, customers: true } },
},
});
if (!company) throw new Response("Not Found", { status: 404 });
const revenue = await prisma.invoice.aggregate({
where: { companyId: id, status: InvoiceStatus.PAID },
_sum: { grossTotal: true },
});
return {
company: {
...company,
invoices: company.invoices.map((inv) => ({
...inv,
grossTotal: Number(inv.grossTotal),
issueDate: inv.issueDate.toISOString(),
dueDate: inv.dueDate.toISOString(),
})),
},
revenue: Number(revenue._sum.grossTotal ?? 0),
};
}
export default function CompanyPage() {
const { company, revenue } = useLoaderData<typeof loader>();
const id = company.id;
return (
<div>
<div className="flex items-start justify-between mb-8">
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-indigo-50">
<Building2 className="h-6 w-6 text-indigo-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">{company.name}</h1>
<p className="text-gray-500 mt-0.5">
{company.legalForm && `${company.legalForm} · `}
{company.zip} {company.city}
</p>
</div>
</div>
<Button variant="outline" asChild>
<Link to={`/companies/${id}/edit`}>
<Edit className="h-4 w-4" />
Bearbeiten
</Link>
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<Link to={`/companies/${id}/invoices/new`} className="block">
<Card className="hover:border-indigo-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-50">
<Plus className="h-4 w-4 text-indigo-600" />
</div>
<span className="text-sm font-medium text-gray-700">Rechnung erstellen</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/invoices`} className="block">
<Card className="hover:border-blue-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<span className="text-sm font-medium text-gray-700">Rechnungen</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/customers`} className="block">
<Card className="hover:border-green-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-50">
<Users className="h-4 w-4 text-green-600" />
</div>
<span className="text-sm font-medium text-gray-700">Kunden</span>
</CardContent>
</Card>
</Link>
<Link to={`/companies/${id}/reports`} className="block">
<Card className="hover:border-purple-200 hover:shadow-sm transition-all cursor-pointer">
<CardContent className="pt-4 pb-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-purple-50">
<BarChart3 className="h-4 w-4 text-purple-600" />
</div>
<span className="text-sm font-medium text-gray-700">Berichte</span>
</CardContent>
</Card>
</Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold text-gray-900">Letzte Rechnungen</h2>
<Link to={`/companies/${id}/invoices`} className="text-sm text-indigo-600 hover:text-indigo-700">
Alle anzeigen
</Link>
</div>
<Card>
{company.invoices.length === 0 ? (
<CardContent className="py-8 text-center text-gray-500">
<Receipt className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">Noch keine Rechnungen</p>
</CardContent>
) : (
<div className="divide-y divide-gray-100">
{company.invoices.map((invoice) => (
<Link
key={invoice.id}
to={`/companies/${id}/invoices/${invoice.id}`}
className="flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div>
<p className="text-sm font-medium text-gray-900">{invoice.number}</p>
<p className="text-xs text-gray-500">{invoice.customer.name} · {formatDate(invoice.issueDate)}</p>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariants[invoice.status]}>{statusLabels[invoice.status]}</Badge>
<span className="text-sm font-medium text-gray-900">
{formatCurrency(invoice.grossTotal)}
</span>
</div>
</Link>
))}
</div>
)}
</Card>
</div>
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-sm">Steuer & Umsatz</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs text-gray-500">Bezahlt (gesamt)</p>
<p className="font-semibold text-gray-900">{formatCurrency(revenue)}</p>
</div>
<div>
<p className="text-xs text-gray-500">Rechnungen</p>
<p className="font-semibold text-gray-900">{company._count.invoices}</p>
</div>
{company.taxId && (
<div>
<p className="text-xs text-gray-500">Steuernummer</p>
<p className="text-sm font-mono text-gray-900">{company.taxId}</p>
</div>
)}
{company.vatId && (
<div>
<p className="text-xs text-gray-500">USt-IdNr.</p>
<p className="text-sm font-mono text-gray-900">{company.vatId}</p>
</div>
)}
</CardContent>
</Card>
{(company.email || company.phone) && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Kontakt</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{company.email && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="h-3.5 w-3.5 shrink-0 text-gray-400" />
{company.email}
</div>
)}
{company.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="h-3.5 w-3.5 shrink-0 text-gray-400" />
{company.phone}
</div>
)}
</CardContent>
</Card>
)}
{company.bankIban && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Bankverbindung</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<CreditCard className="h-3.5 w-3.5 shrink-0 text-gray-400" />
<span className="font-mono text-xs">{company.bankIban}</span>
</div>
{company.bankName && <p className="text-xs text-gray-500 ml-5">{company.bankName}</p>}
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { Link, useNavigate } from "react-router";
import { CompanyForm } from "@/components/company/company-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ChevronLeft } from "lucide-react";
export default function NewCompanyPage() {
const navigate = useNavigate();
async function handleSubmit(data: Record<string, unknown>) {
const res = await fetch("/api/companies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
const company = await res.json();
navigate(`/companies/${company.id}`);
}
}
return (
<div>
<Link
to="/companies"
className="inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700 mb-6"
>
<ChevronLeft className="h-4 w-4" /> Zurück zu Mandanten
</Link>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Neuer Mandant</h1>
<p className="text-gray-500 mt-1">Legen Sie einen neuen Mandanten an</p>
</div>
<Card>
<CardHeader>
<CardTitle>Mandantendaten</CardTitle>
</CardHeader>
<CardContent>
<CompanyForm
onSubmit={handleSubmit}
submitLabel="Mandant anlegen"
/>
</CardContent>
</Card>
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2, Plus, FileText, Users } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
const companies = await prisma.company.findMany({
where: { userId: user.id },
include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" },
});
return { companies };
}
export default function CompaniesPage() {
const { companies } = useLoaderData<typeof loader>();
return (
<div>
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
<p className="text-gray-500 mt-1">{companies.length} Mandanten verwaltet</p>
</div>
<Button asChild>
<Link to="/companies/new">
<Plus className="h-4 w-4" />
Mandant anlegen
</Link>
</Button>
</div>
{companies.length === 0 ? (
<Card>
<CardContent className="py-16 text-center">
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<h3 className="font-semibold text-gray-700 mb-2">Noch keine Mandanten</h3>
<p className="text-gray-500 mb-6 text-sm">Legen Sie Ihren ersten Mandanten an, um loszulegen.</p>
<Button asChild>
<Link to="/companies/new">
<Plus className="h-4 w-4" />
Ersten Mandanten anlegen
</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{companies.map((company) => (
<Link key={company.id} to={`/companies/${company.id}`}>
<Card className="hover:shadow-md transition-all hover:border-indigo-200 cursor-pointer h-full">
<CardHeader>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-indigo-50 shrink-0">
<Building2 className="h-5 w-5 text-indigo-600" />
</div>
<div className="min-w-0">
<CardTitle className="text-base truncate">{company.name}</CardTitle>
{company.legalForm && (
<p className="text-xs text-gray-500 mt-0.5">{company.legalForm}</p>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-4 text-sm text-gray-600">
<span className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
{company._count.invoices} Rechnungen
</span>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{company._count.customers} Kunden
</span>
</div>
{company.city && (
<p className="text-xs text-gray-400 mt-2">{company.zip} {company.city}</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Outlet, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import { Sidebar } from "@/components/layout/sidebar";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
return { userName: user.name };
}
export default function DashboardLayout() {
const { userName } = useLoaderData<typeof loader>();
return (
<div className="flex min-h-screen bg-gray-50">
<Sidebar userName={userName} />
<main className="flex-1 overflow-auto">
<div className="max-w-7xl mx-auto px-6 py-8">
<Outlet />
</div>
</main>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { Link, useLoaderData } from "react-router";
import { requireUser } from "@/session.server";
import prisma from "@/lib/prisma";
import { formatCurrency } from "@/lib/tax";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Building2, FileText, Euro } from "lucide-react";
import { InvoiceStatus } from "@prisma/client";
export async function loader({ request }: { request: Request }) {
const user = await requireUser(request);
const userId = user.id;
const [companies, invoiceStats, paidTotal, openInvoices] = await Promise.all([
prisma.company.findMany({
where: { userId },
include: { _count: { select: { invoices: true, customers: true } } },
orderBy: { name: "asc" },
}),
prisma.invoice.aggregate({
where: { company: { userId } },
_count: true,
_sum: { grossTotal: true },
}),
prisma.invoice.aggregate({
where: { company: { userId }, status: InvoiceStatus.PAID },
_sum: { grossTotal: true },
}),
prisma.invoice.count({
where: { company: { userId }, status: { in: [InvoiceStatus.SENT, InvoiceStatus.DRAFT] } },
}),
]);
return {
companies,
totalInvoices: invoiceStats._count,
paidTotal: Number(paidTotal._sum.grossTotal ?? 0),
openInvoices,
};
}
export default function DashboardPage() {
const { companies, totalInvoices, paidTotal, openInvoices } = useLoaderData<typeof loader>();
return (
<div>
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-500 mt-1">Übersicht aller Mandanten und Rechnungen</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-indigo-50">
<Building2 className="h-5 w-5 text-indigo-600" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{companies.length}</p>
<p className="text-sm text-gray-500">Mandanten</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-blue-50">
<FileText className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{totalInvoices}</p>
<p className="text-sm text-gray-500">Rechnungen gesamt</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-yellow-50">
<FileText className="h-5 w-5 text-yellow-600" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{openInvoices}</p>
<p className="text-sm text-gray-500">Offen / Entwurf</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-green-50">
<Euro className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{formatCurrency(paidTotal)}</p>
<p className="text-sm text-gray-500">Bezahlt (brutto)</p>
</div>
</div>
</CardContent>
</Card>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Mandanten</h2>
<Link to="/companies" className="text-sm text-indigo-600 hover:text-indigo-700 font-medium">
Alle anzeigen
</Link>
</div>
{companies.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<Building2 className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">Noch keine Mandanten angelegt.</p>
<Link
to="/companies/new"
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
>
Mandant anlegen
</Link>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{companies.map((company) => (
<Link key={company.id} to={`/companies/${company.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader>
<CardTitle className="text-base">{company.name}</CardTitle>
{company.legalForm && (
<p className="text-xs text-gray-500">{company.legalForm}</p>
)}
</CardHeader>
<CardContent>
<div className="flex gap-4 text-sm text-gray-600">
<span>{company._count.invoices} Rechnungen</span>
<span>{company._count.customers} Kunden</span>
</div>
{company.city && (
<p className="text-xs text-gray-400 mt-1">{company.zip} {company.city}</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Form, useActionData, useNavigation, redirect } from "react-router";
import { login, createUserSession, getUserSession } from "@/session.server";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Calculator, AlertCircle } from "lucide-react";
export async function loader({ request }: { request: Request }) {
const { userId } = await getUserSession(request);
if (userId) throw redirect("/");
return null;
}
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await login(email, password);
if (!user) return { error: "E-Mail oder Passwort falsch." };
return createUserSession(user.id, user.name, "/");
}
export default function LoginPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const loading = navigation.state === "submitting";
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-50 flex items-center justify-center p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-indigo-600 mb-4">
<Calculator className="w-7 h-7 text-white" />
</div>
<h1 className="text-2xl font-bold text-gray-900">Annas Rechnungsmanager</h1>
<p className="text-gray-500 mt-1">Buchhaltung & Rechnungsverwaltung</p>
</div>
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>Geben Sie Ihre Zugangsdaten ein</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
{actionData?.error && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
<AlertCircle className="h-4 w-4 shrink-0" />
{actionData.error}
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
name="email"
type="email"
placeholder="anna@example.de"
required
autoComplete="email"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Anmelden..." : "Anmelden"}
</Button>
</Form>
</CardContent>
</Card>
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { logout } from "@/session.server";
export async function action({ request }: { request: Request }) {
return logout(request);
}
+66
View File
@@ -0,0 +1,66 @@
import { createCookieSessionStorage, redirect } from "react-router";
import bcrypt from "bcryptjs";
import prisma from "@/lib/prisma";
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30,
path: "/",
sameSite: "lax",
secrets: [process.env.AUTH_SECRET ?? "fallback-secret-change-in-production"],
secure: process.env.NODE_ENV === "production",
},
});
export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return null;
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return null;
return { id: user.id, email: user.email, name: user.name };
}
export async function createUserSession(
userId: string,
userName: string,
redirectTo: string
) {
const session = await sessionStorage.getSession();
session.set("userId", userId);
session.set("userName", userName);
return redirect(redirectTo, {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
}
export async function getUserSession(request: Request) {
const session = await sessionStorage.getSession(
request.headers.get("Cookie")
);
return {
userId: session.get("userId") as string | undefined,
userName: session.get("userName") as string | undefined,
};
}
export async function requireUser(request: Request) {
const { userId, userName } = await getUserSession(request);
if (!userId) throw redirect("/login");
return { id: userId, name: userName as string | undefined };
}
export async function getApiUser(request: Request) {
const { userId } = await getUserSession(request);
return userId ? { id: userId } : null;
}
export async function logout(request: Request) {
const session = await sessionStorage.getSession(
request.headers.get("Cookie")
);
return redirect("/login", {
headers: { "Set-Cookie": await sessionStorage.destroySession(session) },
});
}
+69
View File
@@ -0,0 +1,69 @@
export type { Company, Customer, Invoice, InvoiceItem, User, InvoiceStatus } from "@prisma/client";
export interface InvoiceItemInput {
id?: string;
position: number;
description: string;
quantity: number;
unit?: string;
unitPrice: number;
taxRate: number;
netAmount: number;
taxAmount: number;
grossAmount: number;
}
export interface InvoiceFormData {
customerId: string;
issueDate: string;
deliveryDate?: string;
dueDate: string;
notes?: string;
items: InvoiceItemInput[];
}
export interface CompanyFormData {
name: string;
legalForm?: string;
taxId?: string;
vatId?: string;
address: string;
zip: string;
city: string;
country?: string;
email?: string;
phone?: string;
website?: string;
bankIban?: string;
bankBic?: string;
bankName?: string;
invoicePrefix?: string;
}
export interface CustomerFormData {
name: string;
vatId?: string;
taxId?: string;
address: string;
zip: string;
city: string;
country?: string;
email?: string;
phone?: string;
}
export interface TaxGroupSummary {
taxRate: number;
netAmount: number;
taxAmount: number;
grossAmount: number;
}
export interface PeriodReport {
period: string;
invoiceCount: number;
netTotal: number;
taxTotal: number;
grossTotal: number;
taxGroups: TaxGroupSummary[];
}