since3-portal/modules/shared/ui/components/billing-dialog.tsx
2026-09-29 22:56:37 +02:00

389 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useTransition } from "react";
import { ArrowLeftIcon, EyeIcon, PlusIcon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { createInvoice } from "@/modules/shared/actions/invoices";
import {
InvoiceDraftSchema,
firstIssueMessage,
type InvoiceDraft,
type ProductTemplate,
} from "@/modules/shared/schemas/invoice";
import { formatCurrency, invoiceTotals, lineNet, toIsoDate } from "@/modules/shared/lib/invoice-math";
import { parseDecimalInput } from "@/modules/shared/lib/parse-decimal";
export type BillingPrefill = {
companyName: string;
billingStreet: string;
billingPostalCode: string;
billingCity: string;
billingCountry: string;
};
type ItemRow = {
key: number;
productTemplateId: string | null;
description: string;
quantity: string;
unitPrice: string;
taxRate: string;
};
const DEFAULT_PAYMENT_DAYS = 14;
const selectClassName =
"flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-50";
let nextRowKey = 0;
function emptyRow(): ItemRow {
return { key: nextRowKey++, productTemplateId: null, description: "", quantity: "1", unitPrice: "", taxRate: "0" };
}
const parseDecimal = parseDecimalInput;
function formatInputNumber(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 2, useGrouping: false });
}
function addDays(date: Date, days: number) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function BillingDialogContent({
profileId,
prefill,
nextInvoiceNumber,
templates,
onDone,
}: {
profileId: string;
prefill: BillingPrefill;
nextInvoiceNumber: string | null;
templates: ProductTemplate[];
onDone: () => void;
}) {
const [header, setHeader] = useState(() => {
const today = new Date();
return {
...prefill,
invoiceDate: toIsoDate(today),
dueDate: toIsoDate(addDays(today, DEFAULT_PAYMENT_DAYS)),
};
});
const [rows, setRows] = useState<ItemRow[]>(() => [emptyRow()]);
const [error, setError] = useState<string | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewPending, setPreviewPending] = useState(false);
const [createPending, startCreate] = useTransition();
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
const parsedItems = rows.map((row) => ({
productTemplateId: row.productTemplateId,
description: row.description,
quantity: parseDecimal(row.quantity),
unitPrice: parseDecimal(row.unitPrice),
taxRate: parseDecimal(row.taxRate),
}));
const validItems = parsedItems.filter(
(item) => Number.isFinite(item.quantity) && Number.isFinite(item.unitPrice) && Number.isFinite(item.taxRate)
);
const totals = invoiceTotals(validItems);
function updateHeader(field: keyof typeof header, value: string) {
setHeader((current) => ({ ...current, [field]: value }));
}
function updateRow(key: number, patch: Partial<ItemRow>) {
setRows((current) => current.map((row) => (row.key === key ? { ...row, ...patch } : row)));
}
function applyTemplate(key: number, templateId: string) {
const template = templates.find((entry) => entry.id === templateId);
if (!template) {
updateRow(key, { productTemplateId: null });
return;
}
updateRow(key, {
productTemplateId: template.id,
description: template.name,
unitPrice: formatInputNumber(template.unitPrice),
taxRate: formatInputNumber(template.taxRate),
});
}
function buildDraft(): InvoiceDraft | null {
const result = InvoiceDraftSchema.safeParse({ profileId, ...header, items: parsedItems });
if (!result.success) {
setError(firstIssueMessage(result.error));
return null;
}
setError(null);
return result.data;
}
async function handlePreview() {
const draft = buildDraft();
if (!draft) return;
setPreviewPending(true);
try {
const response = await fetch("/api/invoices/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ draft, invoiceNumber: nextInvoiceNumber ?? "ENTWURF" }),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
setError(body?.error ?? "Vorschau konnte nicht erstellt werden.");
return;
}
setPreviewUrl(URL.createObjectURL(await response.blob()));
} catch {
setError("Vorschau konnte nicht erstellt werden.");
} finally {
setPreviewPending(false);
}
}
function handleCreate() {
const draft = buildDraft();
if (!draft) return;
startCreate(async () => {
const result = await createInvoice(draft);
if ("error" in result) {
setError(result.error);
return;
}
onDone();
});
}
const createButton = (
<Button type="button" onClick={handleCreate} disabled={createPending || previewPending}>
{createPending ? "Wird erstellt..." : "Rechnung erstellen"}
</Button>
);
if (previewUrl) {
return (
<>
<DialogHeader>
<DialogTitle>Vorschau</DialogTitle>
</DialogHeader>
<iframe src={previewUrl} title="Rechnungsvorschau" className="h-[60dvh] w-full rounded-md border border-border sm:h-[70dvh]" />
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setPreviewUrl(null)} disabled={createPending}>
<ArrowLeftIcon className="size-4" /> Zurück zum Bearbeiten
</Button>
{createButton}
</DialogFooter>
</>
);
}
return (
<>
<DialogHeader>
<DialogTitle>Neue Rechnung</DialogTitle>
</DialogHeader>
<FieldGroup>
<div className="grid gap-4 md:grid-cols-2">
<Field>
<FieldLabel htmlFor="companyName">Firmenname <span className="text-destructive">*</span></FieldLabel>
<Input id="companyName" value={header.companyName} onChange={(e) => updateHeader("companyName", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="invoiceNumber">Rechnungsnummer</FieldLabel>
<Input id="invoiceNumber" value={nextInvoiceNumber ?? "Wird automatisch vergeben"} readOnly disabled />
<FieldDescription>Wird beim Erstellen fortlaufend vergeben.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="billingStreet">Straße <span className="text-destructive">*</span></FieldLabel>
<Input id="billingStreet" value={header.billingStreet} onChange={(e) => updateHeader("billingStreet", e.target.value)} />
</Field>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_4rem] gap-2">
<Field>
<FieldLabel htmlFor="billingPostalCode">PLZ <span className="text-destructive">*</span></FieldLabel>
<Input id="billingPostalCode" value={header.billingPostalCode} onChange={(e) => updateHeader("billingPostalCode", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="billingCity">Stadt <span className="text-destructive">*</span></FieldLabel>
<Input id="billingCity" value={header.billingCity} onChange={(e) => updateHeader("billingCity", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="billingCountry">Land</FieldLabel>
<Input
id="billingCountry"
maxLength={2}
value={header.billingCountry}
onChange={(e) => updateHeader("billingCountry", e.target.value.toUpperCase())}
/>
</Field>
</div>
<Field>
<FieldLabel htmlFor="invoiceDate">Rechnungsdatum <span className="text-destructive">*</span></FieldLabel>
<Input id="invoiceDate" type="date" value={header.invoiceDate} onChange={(e) => updateHeader("invoiceDate", e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="dueDate">Fälligkeit <span className="text-destructive">*</span></FieldLabel>
<Input id="dueDate" type="date" value={header.dueDate} onChange={(e) => updateHeader("dueDate", e.target.value)} />
</Field>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="font-medium">Positionen</h3>
<Button type="button" variant="outline" size="sm" onClick={() => setRows((current) => [...current, emptyRow()])}>
<PlusIcon className="size-4" /> Position
</Button>
</div>
{rows.map((row, index) => {
const item = parsedItems[index];
const net = Number.isFinite(item.quantity) && Number.isFinite(item.unitPrice) ? lineNet(item) : null;
return (
<div key={row.key} className="grid grid-cols-3 items-end gap-2 rounded-lg border border-border p-3 md:grid-cols-[10rem_1fr_5rem_7rem_5rem_auto]">
<Field className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`template-${row.key}`}>Vorlage</FieldLabel>
<select
id={`template-${row.key}`}
className={selectClassName}
value={row.productTemplateId ?? ""}
onChange={(e) => applyTemplate(row.key, e.target.value)}
disabled={templates.length === 0}
>
<option value="">{templates.length === 0 ? "Keine Vorlagen" : "Freie Position"}</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} ({formatCurrency(template.unitPrice)})
</option>
))}
</select>
</Field>
<Field className="col-span-3 md:col-span-1">
<FieldLabel htmlFor={`description-${row.key}`}>Bezeichnung</FieldLabel>
<Input id={`description-${row.key}`} value={row.description} onChange={(e) => updateRow(row.key, { description: e.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor={`quantity-${row.key}`}>Menge</FieldLabel>
<Input id={`quantity-${row.key}`} inputMode="decimal" value={row.quantity} onChange={(e) => updateRow(row.key, { quantity: e.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor={`price-${row.key}`}>Preis/Einh. (€)</FieldLabel>
<Input id={`price-${row.key}`} inputMode="decimal" placeholder="0,00" value={row.unitPrice} onChange={(e) => updateRow(row.key, { unitPrice: e.target.value })} />
</Field>
<Field>
<FieldLabel htmlFor={`tax-${row.key}`}>MwSt %</FieldLabel>
<Input id={`tax-${row.key}`} inputMode="decimal" value={row.taxRate} onChange={(e) => updateRow(row.key, { taxRate: e.target.value })} />
</Field>
<div className="col-span-3 flex items-center justify-between gap-2 md:col-span-1 md:h-10 md:justify-end">
<span className="min-w-20 text-right tabular-nums text-muted-foreground">
{net === null ? "–" : formatCurrency(net)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Position entfernen"
disabled={rows.length === 1}
onClick={() => setRows((current) => current.filter((entry) => entry.key !== row.key))}
>
<Trash2Icon className="size-4" />
</Button>
</div>
</div>
);
})}
<dl className="ml-auto grid w-full max-w-xs grid-cols-2 gap-1 text-sm tabular-nums">
<dt className="text-muted-foreground">Netto</dt>
<dd className="text-right">{formatCurrency(totals.net)}</dd>
<dt className="text-muted-foreground">MwSt</dt>
<dd className="text-right">{formatCurrency(totals.tax)}</dd>
<dt className="font-medium">Gesamt</dt>
<dd className="text-right font-medium">{formatCurrency(totals.gross)}</dd>
</dl>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={handlePreview} disabled={previewPending || createPending}>
<EyeIcon className="size-4" /> {previewPending ? "Wird geladen..." : "Vorschau"}
</Button>
{createButton}
</DialogFooter>
</>
);
}
export function BillingDialog({
profileId,
prefill,
nextInvoiceNumber,
templates,
trigger,
}: {
profileId: string;
prefill: BillingPrefill | null;
nextInvoiceNumber: string | null;
templates: ProductTemplate[];
trigger?: React.ReactElement;
}) {
const [open, setOpen] = useState(false);
const [instanceKey, setInstanceKey] = useState(0);
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) {
setInstanceKey((key) => key + 1);
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger render={trigger ?? <Button className="cursor-pointer" />}>
{!trigger && (
<>
<PlusIcon className="size-4" />
Neue Rechnung
</>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-5xl">
{prefill ? (
<BillingDialogContent
key={instanceKey}
profileId={profileId}
prefill={prefill}
nextInvoiceNumber={nextInvoiceNumber}
templates={templates}
onDone={() => handleOpenChange(false)}
/>
) : (
<>
<DialogHeader>
<DialogTitle>Neue Rechnung</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground">
Für diesen Client sind noch keine Stammdaten hinterlegt. Bitte zuerst die Stammdaten ausfüllen.
</p>
</>
)}
</DialogContent>
</Dialog>
);
}