This commit is contained in:
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
537
src/components/admin/billing/custom-invoice-editor.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
145
src/components/admin/billing/draft-list.tsx
Normal file
145
src/components/admin/billing/draft-list.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import type { InvoiceDraftRecord } from "@/types";
|
||||
|
||||
interface Props {
|
||||
drafts: InvoiceDraftRecord[];
|
||||
/** Map ZITADEL org id → company name for friendlier display. */
|
||||
orgNameMap: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the drafts table with per-row Edit / Delete actions.
|
||||
*
|
||||
* The total preview is the algebraic sum of line amounts (the same
|
||||
* formula billing.computeCustomInvoiceTotals uses for the subtotal,
|
||||
* minus VAT — which we don't know without the org's billing
|
||||
* snapshot). It's a hint, not authoritative; the real total
|
||||
* appears when the draft is issued.
|
||||
*
|
||||
* Empty state shows a clear CTA so a fresh admin knows where to
|
||||
* start.
|
||||
*/
|
||||
export function DraftList({ drafts, orgNameMap }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const fmt = useFormatter();
|
||||
const router = useRouter();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
if (!confirm(t("draftDeleteConfirm"))) return;
|
||||
setBusyId(id);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/billing/invoice-drafts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `HTTP ${res.status}`);
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (drafts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-text-secondary mb-4">{t("draftsEmpty")}</p>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-4 py-2 rounded-md bg-accent text-white text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-end p-3 border-b border-border">
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="inline-block px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||
>
|
||||
{t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-text-muted text-left">
|
||||
<tr>
|
||||
<th className="pb-2 pl-3 pr-4">{t("draftOrgCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftIssueDateCol")}</th>
|
||||
<th className="pb-2 pr-4 text-center">{t("draftLinesCol")}</th>
|
||||
<th className="pb-2 pr-4 text-right">{t("draftSubtotalCol")}</th>
|
||||
<th className="pb-2 pr-4">{t("draftUpdatedCol")}</th>
|
||||
<th className="pb-2 pr-3 text-right">{t("draftActionsCol")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{drafts.map((d) => {
|
||||
const subtotal = d.payload.lines.reduce(
|
||||
(s, ln) =>
|
||||
s +
|
||||
Math.round(ln.quantity * ln.unitPriceChf * 100) / 100,
|
||||
0
|
||||
);
|
||||
return (
|
||||
<tr key={d.id} className="border-t border-border">
|
||||
<td className="py-2 pl-3 pr-4">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{orgNameMap[d.zitadelOrgId] ?? d.zitadelOrgId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs font-mono text-text-secondary whitespace-nowrap">
|
||||
{d.payload.issueDate}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-center text-xs">
|
||||
{d.payload.lines.length}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right font-mono text-xs whitespace-nowrap">
|
||||
CHF {subtotal.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs text-text-muted whitespace-nowrap">
|
||||
{fmt.dateTime(new Date(d.updatedAt), {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
<Link
|
||||
href={`/admin/billing/invoice-drafts/${d.id}`}
|
||||
className="text-accent hover:underline text-xs mr-3"
|
||||
>
|
||||
{t("editBtn")}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onDelete(d.id)}
|
||||
disabled={busyId === d.id}
|
||||
className="text-error hover:underline text-xs disabled:opacity-50"
|
||||
>
|
||||
{busyId === d.id ? t("deleting") : t("deleteBtn")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -203,10 +203,14 @@ export function InvoiceDetailView({ detail, creditNotes = [] }: Props) {
|
||||
</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-sm">
|
||||
<StatusPill status={invoice.status} />
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<>
|
||||
<span className="text-text-muted">
|
||||
{invoice.periodStart} → {invoice.periodEnd}
|
||||
</span>
|
||||
<span className="text-text-muted">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-text-muted">
|
||||
{t("dueOnLabel")}: {invoice.dueAt}
|
||||
</span>
|
||||
|
||||
@@ -100,6 +100,23 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
{t("loading")}
|
||||
</span>
|
||||
)}
|
||||
{/* Phase 8: shortcuts to the custom-invoice flow. The
|
||||
Drafts link is muted because most of the time it's
|
||||
empty; New invoice is the prominent CTA. */}
|
||||
<div className={`flex items-center gap-3 ${busy ? "" : "ml-auto"}`}>
|
||||
<Link
|
||||
href="/admin/billing/invoice-drafts"
|
||||
className="text-xs text-text-muted hover:underline"
|
||||
>
|
||||
{t("draftsLink")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/billing/invoices/new"
|
||||
className="px-3 py-1.5 rounded-md bg-accent text-white text-sm"
|
||||
>
|
||||
+ {t("newInvoiceBtn")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -142,7 +159,11 @@ export function InvoicesTable({ initialInvoices }: Props) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-xs font-mono">
|
||||
{inv.periodStart.slice(0, 7)}
|
||||
{inv.periodStart
|
||||
? inv.periodStart.slice(0, 7)
|
||||
: inv.source === "custom"
|
||||
? "—"
|
||||
: ""}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<StatusPill status={inv.status} />
|
||||
|
||||
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
166
src/components/admin/billing/new-invoice-form.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface OrgEntry {
|
||||
zitadelOrgId: string;
|
||||
companyName: string | null;
|
||||
country: string | null;
|
||||
hasBillingAddress: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orgs: OrgEntry[];
|
||||
}
|
||||
|
||||
const LOCALE_OPTIONS = [
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Step 1 of the custom-invoice flow: pick an org. Creating the
|
||||
* draft on the backend allocates an id we redirect to; the editor
|
||||
* page then loads the draft and lets the admin add lines.
|
||||
*
|
||||
* The dropdown shows the company name when known, falling back to
|
||||
* the raw org id. Orgs without a billing snapshot are visually
|
||||
* marked and warn the admin — they can still create the draft but
|
||||
* won't be able to issue until billing info is set.
|
||||
*
|
||||
* Default issue date = today; due date = today + 30 days. These
|
||||
* are sensible defaults the editor can override.
|
||||
*/
|
||||
export function NewInvoiceForm({ orgs }: Props) {
|
||||
const t = useTranslations("adminBilling");
|
||||
const router = useRouter();
|
||||
const [orgId, setOrgId] = useState(
|
||||
orgs.find((o) => o.hasBillingAddress)?.zitadelOrgId ??
|
||||
orgs[0]?.zitadelOrgId ??
|
||||
""
|
||||
);
|
||||
const [locale, setLocale] = useState<"de" | "en" | "fr" | "it">("de");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const selected = orgs.find((o) => o.zitadelOrgId === orgId);
|
||||
|
||||
// Pick a locale default from the org's country if admin hasn't
|
||||
// overridden — same heuristic the auto cron uses.
|
||||
const onOrgChange = (newOrgId: string) => {
|
||||
setOrgId(newOrgId);
|
||||
const o = orgs.find((x) => x.zitadelOrgId === newOrgId);
|
||||
const c = (o?.country ?? "").toUpperCase();
|
||||
if (["CH", "LI", "AT", "DE"].includes(c)) setLocale("de");
|
||||
else if (["FR", "BE", "LU"].includes(c)) setLocale("fr");
|
||||
else if (c === "IT") setLocale("it");
|
||||
else setLocale("en");
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!orgId) {
|
||||
setError(t("newInvoiceOrgRequired"));
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const due = new Date();
|
||||
due.setDate(due.getDate() + 30);
|
||||
const dueIso = due.toISOString().slice(0, 10);
|
||||
const res = await fetch("/api/admin/billing/invoice-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zitadelOrgId: orgId,
|
||||
payload: {
|
||||
issueDate: today,
|
||||
dueDate: dueIso,
|
||||
locale,
|
||||
paymentMethod: "invoice",
|
||||
lines: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
||||
router.push(`/admin/billing/invoice-drafts/${j.draft.id}`);
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceOrgLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={orgId}
|
||||
onChange={(e) => onOrgChange(e.target.value)}
|
||||
className="px-3 py-2 rounded-md border border-border bg-surface-2 text-sm"
|
||||
>
|
||||
<option value="">{t("newInvoiceOrgPlaceholder")}</option>
|
||||
{orgs.map((o) => (
|
||||
<option
|
||||
key={o.zitadelOrgId}
|
||||
value={o.zitadelOrgId}
|
||||
disabled={!o.hasBillingAddress}
|
||||
>
|
||||
{o.companyName ?? o.zitadelOrgId}
|
||||
{!o.hasBillingAddress
|
||||
? ` (${t("newInvoiceOrgNoBilling")})`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected && !selected.hasBillingAddress && (
|
||||
<p className="text-xs text-error mt-1">
|
||||
{t("newInvoiceOrgBillingMissing")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs uppercase tracking-wider text-text-muted">
|
||||
{t("newInvoiceLocaleLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) =>
|
||||
setLocale(e.target.value as "de" | "en" | "fr" | "it")
|
||||
}
|
||||
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>
|
||||
|
||||
{error && <div className="text-sm text-error">{error}</div>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={busy || !orgId || !selected?.hasBillingAddress}
|
||||
className="px-4 py-2 rounded-md bg-accent text-white text-sm disabled:opacity-50"
|
||||
>
|
||||
{busy ? t("creating") : t("newInvoiceContinueBtn")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -46,11 +46,17 @@ export function CustomerInvoiceDetail({ invoice, lines }: Props) {
|
||||
{t(`status.${invoice.status}` as any)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), { dateStyle: "long" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), { dateStyle: "long" })}
|
||||
</p>
|
||||
{invoice.periodStart && invoice.periodEnd && (
|
||||
<p className="text-sm text-text-secondary">
|
||||
{fmt.dateTime(new Date(invoice.periodStart), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(invoice.periodEnd), {
|
||||
dateStyle: "long",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 flex-wrap">
|
||||
{/* Phase 4: Pay-with-card available for open + overdue.
|
||||
|
||||
@@ -71,9 +71,19 @@ export function CustomerInvoiceList({ invoices }: Props) {
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.periodStart), { dateStyle: "medium" })}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), { dateStyle: "medium" })}
|
||||
{inv.periodStart && inv.periodEnd ? (
|
||||
<>
|
||||
{fmt.dateTime(new Date(inv.periodStart), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
<span className="text-text-muted mx-1">→</span>
|
||||
{fmt.dateTime(new Date(inv.periodEnd), {
|
||||
dateStyle: "medium",
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-text-secondary">
|
||||
{fmt.dateTime(new Date(inv.dueAt), { dateStyle: "medium" })}
|
||||
|
||||
@@ -125,9 +125,16 @@ export function RunningTotalWidget({ isOwner }: Props) {
|
||||
}
|
||||
// draft
|
||||
const draft = data.draft;
|
||||
const periodLabel = `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`;
|
||||
// Phase 8: InvoiceDraft.periodStart/End became nullable for the
|
||||
// custom-invoice flow. The running-total widget only renders the
|
||||
// auto-cron draft (always has a period), so the null branch is
|
||||
// defensive — if we ever did hit it the label just collapses.
|
||||
const periodLabel =
|
||||
draft.periodStart && draft.periodEnd
|
||||
? `${fmt.dateTime(new Date(draft.periodStart), {
|
||||
dateStyle: "long",
|
||||
})} → ${fmt.dateTime(new Date(draft.periodEnd), { dateStyle: "long" })}`
|
||||
: "";
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-3">
|
||||
|
||||
Reference in New Issue
Block a user