mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 21:41:16 +00:00
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
"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";
|
||
|
||
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" };
|
||
}
|
||
|
||
function parseDecimal(value: string) {
|
||
const trimmed = value.trim();
|
||
const normalized = trimmed.includes(",") ? trimmed.replace(/\./g, "").replace(",", ".") : trimmed;
|
||
return normalized === "" ? Number.NaN : Number(normalized);
|
||
}
|
||
|
||
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-[70vh] w-full rounded-md border border-border" />
|
||
{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-[1fr_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 gap-2 rounded-lg border border-border p-3 md:grid-cols-[10rem_1fr_5rem_7rem_5rem_auto] md:items-end">
|
||
<Field>
|
||
<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>
|
||
<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="flex items-center justify-between gap-2 md:justify-end md:h-10">
|
||
<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="max-h-[90vh] overflow-y-auto 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>
|
||
);
|
||
}
|