"use client"; import { useState, useMemo, useCallback } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; import { Card, CardHeader } from "@/components/ui/card"; import type { CustomInvoiceDraftLine, CustomInvoiceDraftPayload, InvoiceDraftRecord, OrgBilling, } from "@/types"; interface Props { draft: InvoiceDraftRecord; orgBilling: OrgBilling | null; } const LOCALE_OPTIONS = [ { value: "de", label: "Deutsch" }, { value: "en", label: "English" }, { value: "fr", label: "Français" }, { value: "it", label: "Italiano" }, ]; /** * Custom invoice editor — Phase 8. * * Local state mirrors the persisted payload. Save persists the * current state via PUT. Preview re-renders the PDF in-memory (no * persistence). Issue allocates the invoice number and emails the * customer. * * VAT preview is computed client-side from the country in the org * billing snapshot — it's an estimate for the admin's eye, not * authoritative. The server recomputes at issue time using the * same vatRateForAddress() helper to ensure consistency. * * Discount/Rabatt is supported via a row with a negative * unitPriceChf. The "Add discount" button seeds a new row with * quantity 1 and a -50 placeholder to nudge the admin toward the * intended sign. */ export function CustomInvoiceEditor({ draft, orgBilling }: Props) { const t = useTranslations("adminBilling"); const router = useRouter(); // Editable state — initialized from the draft payload. const [issueDate, setIssueDate] = useState(draft.payload.issueDate); const [dueDate, setDueDate] = useState(draft.payload.dueDate); const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">( draft.payload.locale ); const [paymentMethod, setPaymentMethod] = useState<"invoice" | "card">( draft.payload.paymentMethod ); const [adminNotes, setAdminNotes] = useState(draft.payload.adminNotes ?? ""); const [lines, setLines] = useState( draft.payload.lines.length > 0 ? draft.payload.lines : [{ description: "", quantity: 1, unitPriceChf: 0 }] ); const [busy, setBusy] = useState( null ); const [error, setError] = useState(""); const [dirty, setDirty] = useState(false); // Build current payload — used by every action. const buildPayload = useCallback((): CustomInvoiceDraftPayload => { return { issueDate, dueDate, locale, paymentMethod, adminNotes: adminNotes.trim() ? adminNotes.trim() : undefined, lines: lines.map((ln) => ({ description: ln.description, quantity: Number(ln.quantity) || 0, unitPriceChf: Number(ln.unitPriceChf) || 0, })), }; }, [issueDate, dueDate, locale, paymentMethod, adminNotes, lines]); // Client-side VAT estimate. The auth-of-truth math runs server-side // at issue time; this is just to show the admin what they're about // to commit to. const totals = useMemo(() => { const subtotal = Math.round( lines.reduce( (s, ln) => s + (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0), 0 ) * 100 ) / 100; // Country-based VAT estimate. Mirrors vatRateForAddress() — // simplified because the editor doesn't know the platform // pricing config. Defaults to 8.1 for CH/LI; 0 otherwise. const country = (orgBilling?.country ?? "").toUpperCase(); let vatRate = 0; if (country === "CH" || country === "LI") { vatRate = 8.1; } else if (orgBilling?.vatNumber) { vatRate = 0; // reverse charge } else { vatRate = 0; // out of scope OR consumer (server will fix) } const vatAmount = Math.round(subtotal * (vatRate / 100) * 100) / 100; const total = Math.round((subtotal + vatAmount) * 100) / 100; return { subtotal, vatRate, vatAmount, total }; }, [lines, orgBilling]); // Line management const updateLine = (idx: number, patch: Partial) => { setLines((prev) => prev.map((ln, i) => (i === idx ? { ...ln, ...patch } : ln)) ); setDirty(true); }; const addLine = () => { setLines((prev) => [ ...prev, { description: "", quantity: 1, unitPriceChf: 0 }, ]); setDirty(true); }; const addDiscountLine = () => { setLines((prev) => [ ...prev, { description: t("editorRabattDefaultDescription"), quantity: 1, unitPriceChf: -50 }, ]); setDirty(true); }; const removeLine = (idx: number) => { setLines((prev) => prev.filter((_, i) => i !== idx)); setDirty(true); }; // Actions const save = async (): Promise => { setError(""); setBusy("save"); try { const res = await fetch( `/api/admin/billing/invoice-drafts/${draft.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPayload()), } ); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setDirty(false); return true; } catch (e: any) { setError(e.message); return false; } finally { setBusy(null); } }; const preview = async () => { // Save first if there are unsaved changes — otherwise the // preview reflects stale data. if (dirty) { const ok = await save(); if (!ok) return; } // Open the preview in a new tab. The browser handles the PDF // download/render natively; we don't need to fetch the bytes // ourselves. window.open( `/api/admin/billing/invoice-drafts/${draft.id}/preview`, "_blank", "noopener" ); }; const issue = async () => { if (!confirm(t("editorIssueConfirm"))) return; if (dirty) { const ok = await save(); if (!ok) return; } setError(""); setBusy("issue"); try { const res = await fetch( `/api/admin/billing/invoice-drafts/${draft.id}/issue`, { method: "POST" } ); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); // The draft was deleted server-side; go look at the new invoice. router.push(`/admin/billing/invoices/${j.invoice.id}`); } catch (e: any) { setError(e.message); setBusy(null); } }; const deleteDraft = async () => { if (!confirm(t("editorDeleteConfirm"))) return; setError(""); setBusy("delete"); try { const res = await fetch( `/api/admin/billing/invoice-drafts/${draft.id}`, { method: "DELETE" } ); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || `HTTP ${res.status}`); } router.push("/admin/billing/invoice-drafts"); } catch (e: any) { setError(e.message); setBusy(null); } }; // No billing snapshot = can't issue. Save still works so admin // can come back once the customer has completed onboarding. const canIssue = !!orgBilling && lines.length > 0 && lines.every((ln) => ln.description.trim().length > 0); return (
{/* Bill-to preview — read-only, sourced from the org's billing snapshot. Issued at issue time. */} {t("editorBillToHeading")}
{orgBilling ? ( <>

{orgBilling.companyName}

{orgBilling.contactName && (

{orgBilling.contactName}

)}

{orgBilling.streetAddress}, {orgBilling.postalCode}{" "} {orgBilling.city}, {orgBilling.country}

{orgBilling.vatNumber && (

MWST/VAT: {orgBilling.vatNumber}

)}

{orgBilling.billingEmail}

) : (

{t("editorNoBillingSnapshot")}

)}
{/* Dates + locale + payment method */} {t("editorMetadataHeading")}
{ setIssueDate(e.target.value); setDirty(true); }} className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" />
{ setDueDate(e.target.value); setDirty(true); }} className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm" />
{/* Line editor */} {t("editorLinesHeading")}
{lines.map((ln, idx) => { const amount = Math.round( (Number(ln.quantity) || 0) * (Number(ln.unitPriceChf) || 0) * 100 ) / 100; return ( ); })}
{t("editorLineDescription")} {t("editorLineQty")} {t("editorLineUnitPrice")} {t("editorLineAmount")}
updateLine(idx, { description: e.target.value }) } placeholder={t("editorLineDescriptionPlaceholder")} className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm" maxLength={500} /> updateLine(idx, { quantity: parseFloat(e.target.value) || 0, }) } className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right" /> updateLine(idx, { unitPriceChf: parseFloat(e.target.value) || 0, }) } className="w-full px-2 py-1.5 rounded border border-border bg-surface-2 text-sm font-mono text-right" /> CHF {amount.toFixed(2)}
{/* Admin notes */} {t("editorNotesHeading")}