540 lines
19 KiB
TypeScript
540 lines
19 KiB
TypeScript
"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<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">
|
||
<div className="overflow-x-auto">
|
||
<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>
|
||
<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-surface-0 text-sm disabled:opacity-50"
|
||
type="button"
|
||
>
|
||
{busy === "issue" ? t("issuing") : t("editorIssueBtn")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|