Files
pieced-portal/src/components/admin/billing/custom-invoice-editor.tsx
admin ed915ec539
Some checks failed
Build and Push / build (push) Failing after 59s
Phase7b: Manual Invoice
2026-05-26 23:04:09 +02:00

538 lines
18 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 { useState, useMemo, useCallback } from "react";
import { useRouter } from "next/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<CustomInvoiceDraftLine[]>(
draft.payload.lines.length > 0
? draft.payload.lines
: [{ description: "", quantity: 1, unitPriceChf: 0 }]
);
const [busy, setBusy] = useState<null | "save" | "preview" | "issue" | "delete">(
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<CustomInvoiceDraftLine>) => {
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<boolean> => {
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 (
<div className="flex flex-col gap-6">
{/* Bill-to preview — read-only, sourced from the org's billing
snapshot. Issued at issue time. */}
<Card>
<CardHeader>{t("editorBillToHeading")}</CardHeader>
<div className="p-4 text-sm">
{orgBilling ? (
<>
<p className="font-medium">{orgBilling.companyName}</p>
{orgBilling.contactName && (
<p className="text-text-secondary text-xs">
{orgBilling.contactName}
</p>
)}
<p className="text-text-secondary text-xs">
{orgBilling.streetAddress}, {orgBilling.postalCode}{" "}
{orgBilling.city}, {orgBilling.country}
</p>
{orgBilling.vatNumber && (
<p className="text-text-muted text-xs mt-1">
MWST/VAT: {orgBilling.vatNumber}
</p>
)}
<p className="text-text-muted text-xs">
{orgBilling.billingEmail}
</p>
</>
) : (
<p className="text-error">{t("editorNoBillingSnapshot")}</p>
)}
</div>
</Card>
{/* Dates + locale + payment method */}
<Card>
<CardHeader>{t("editorMetadataHeading")}</CardHeader>
<div className="p-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorIssueDateLabel")}
</label>
<input
type="date"
value={issueDate}
onChange={(e) => {
setIssueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorDueDateLabel")}
</label>
<input
type="date"
value={dueDate}
onChange={(e) => {
setDueDate(e.target.value);
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorLocaleLabel")}
</label>
<select
value={locale}
onChange={(e) => {
setLocale(e.target.value as "de" | "en" | "fr" | "it");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
{LOCALE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs uppercase tracking-wider text-text-muted">
{t("editorPaymentMethodLabel")}
</label>
<select
value={paymentMethod}
onChange={(e) => {
setPaymentMethod(e.target.value as "invoice" | "card");
setDirty(true);
}}
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
>
<option value="invoice">{t("editorPaymentInvoice")}</option>
<option value="card">{t("editorPaymentCard")}</option>
</select>
</div>
</div>
</Card>
{/* Line editor */}
<Card>
<CardHeader>{t("editorLinesHeading")}</CardHeader>
<div className="p-4">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted text-left">
<tr>
<th className="pb-2 pr-3">{t("editorLineDescription")}</th>
<th className="pb-2 pr-3 w-20 text-right">
{t("editorLineQty")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineUnitPrice")}
</th>
<th className="pb-2 pr-3 w-32 text-right">
{t("editorLineAmount")}
</th>
<th className="pb-2 w-12"></th>
</tr>
</thead>
<tbody>
{lines.map((ln, idx) => {
const amount =
Math.round(
(Number(ln.quantity) || 0) *
(Number(ln.unitPriceChf) || 0) *
100
) / 100;
return (
<tr key={idx} className="border-t border-border">
<td className="py-2 pr-3">
<input
type="text"
value={ln.description}
onChange={(e) =>
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}
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.quantity}
onChange={(e) =>
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"
/>
</td>
<td className="py-2 pr-3">
<input
type="number"
step="0.01"
value={ln.unitPriceChf}
onChange={(e) =>
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"
/>
</td>
<td className="py-2 pr-3 text-right font-mono text-sm whitespace-nowrap">
<span className={amount < 0 ? "text-error" : ""}>
CHF {amount.toFixed(2)}
</span>
</td>
<td className="py-2 text-right">
<button
onClick={() => removeLine(idx)}
className="text-text-muted hover:text-error text-lg leading-none"
title={t("editorLineRemove")}
type="button"
>
×
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex gap-2 mt-3">
<button
onClick={addLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3"
>
+ {t("editorAddLine")}
</button>
<button
onClick={addDiscountLine}
type="button"
className="px-3 py-1.5 rounded-md border border-border text-sm hover:bg-surface-3 text-text-secondary"
title={t("editorAddDiscountHint")}
>
{t("editorAddDiscount")}
</button>
</div>
</div>
</Card>
{/* Admin notes */}
<Card>
<CardHeader>{t("editorNotesHeading")}</CardHeader>
<div className="p-4">
<textarea
value={adminNotes}
onChange={(e) => {
setAdminNotes(e.target.value);
setDirty(true);
}}
placeholder={t("editorNotesPlaceholder")}
rows={2}
maxLength={2000}
className="w-full px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
/>
<p className="text-xs text-text-muted mt-1">
{t("editorNotesHint")}
</p>
</div>
</Card>
{/* Totals preview */}
<Card>
<CardHeader>{t("editorTotalsHeading")}</CardHeader>
<div className="p-4 max-w-sm ml-auto text-sm">
<div className="flex justify-between py-1">
<span className="text-text-muted">{t("editorSubtotal")}</span>
<span className="font-mono">CHF {totals.subtotal.toFixed(2)}</span>
</div>
<div className="flex justify-between py-1">
<span className="text-text-muted">
{t("editorVat")} ({totals.vatRate.toFixed(1)}%)
</span>
<span className="font-mono">CHF {totals.vatAmount.toFixed(2)}</span>
</div>
<div className="flex justify-between py-2 border-t border-border mt-1 font-medium">
<span>{t("editorTotal")}</span>
<span className="font-mono">CHF {totals.total.toFixed(2)}</span>
</div>
<p className="text-xs text-text-muted mt-2 italic">
{t("editorTotalsEstimateNote")}
</p>
</div>
</Card>
{/* Error + actions */}
{error && (
<div className="text-sm text-error border border-error/30 bg-error/10 rounded-md px-4 py-2">
{error}
</div>
)}
<div className="flex flex-wrap gap-2 justify-between items-center">
<button
onClick={deleteDraft}
disabled={busy !== null}
className="px-4 py-2 rounded-md border border-error text-error text-sm disabled:opacity-50 hover:bg-error/10"
type="button"
>
{busy === "delete" ? t("deleting") : t("editorDeleteBtn")}
</button>
<div className="flex gap-2 ml-auto">
<button
onClick={save}
disabled={busy !== null || !dirty}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "save"
? t("saving")
: dirty
? t("editorSaveBtn")
: t("editorSavedBtn")}
</button>
<button
onClick={preview}
disabled={busy !== null || lines.length === 0}
className="px-4 py-2 rounded-md border border-border text-sm disabled:opacity-50"
type="button"
>
{busy === "preview" ? t("previewing") : t("editorPreviewBtn")}
</button>
<button
onClick={issue}
disabled={busy !== null || !canIssue}
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
type="button"
>
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
</button>
</div>
</div>
</div>
);
}