163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslations, useFormatter } from "next-intl";
|
|
import { Link } from "@/i18n/navigation";
|
|
import { Card } from "@/components/ui/card";
|
|
import type { Invoice, InvoiceDraft } from "@/types";
|
|
|
|
type CurrentResponse =
|
|
| { issued: Invoice }
|
|
| { draft: InvoiceDraft }
|
|
| { error: string; code?: string };
|
|
|
|
/**
|
|
* Live running total for the current calendar month.
|
|
*
|
|
* Loads /api/billing/current on mount. Three result shapes:
|
|
*
|
|
* - { issued } — current-month invoice already exists; we
|
|
* link to it instead of showing a draft total.
|
|
* - { draft } — still accruing; show subtotal+VAT+total and
|
|
* a small line breakdown.
|
|
* - { error } — most likely the org has no billing config
|
|
* yet; show a friendly hint, not a stack trace.
|
|
*
|
|
* Client-side because the compute can take a second or two
|
|
* (LiteLLM + Threema HTTP calls) and we want a loading spinner.
|
|
* No polling — the page is static enough that an explicit
|
|
* "refresh" link is good enough if the user wants newer numbers.
|
|
*/
|
|
export function RunningTotalWidget() {
|
|
const t = useTranslations("customerBilling");
|
|
const fmt = useFormatter();
|
|
const [data, setData] = useState<CurrentResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
fetch("/api/billing/current")
|
|
.then(async (res) => {
|
|
const j = (await res.json()) as CurrentResponse;
|
|
if (!cancelled) setData(j);
|
|
})
|
|
.catch((e) => {
|
|
if (!cancelled) setData({ error: String(e), code: "FETCH_FAILED" });
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [refreshCounter]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<Card>
|
|
<p className="text-sm text-text-muted italic py-4">{t("computing")}</p>
|
|
</Card>
|
|
);
|
|
}
|
|
if (!data || "error" in data) {
|
|
return (
|
|
<Card>
|
|
<p className="text-sm text-text-secondary py-2">
|
|
{data && "code" in data && data.code === "COMPUTE_FAILED"
|
|
? t("noBillingConfig")
|
|
: t("currentPeriodError")}
|
|
</p>
|
|
</Card>
|
|
);
|
|
}
|
|
if ("issued" in data) {
|
|
const inv = data.issued;
|
|
return (
|
|
<Card>
|
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<p className="text-xs text-text-muted">{t("currentInvoiceIssued")}</p>
|
|
<Link
|
|
href={`/billing/${inv.invoiceNumber}`}
|
|
className="font-mono text-sm text-accent hover:underline"
|
|
>
|
|
{inv.invoiceNumber}
|
|
</Link>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs text-text-muted">{t("totalLabel")}</p>
|
|
<p className="font-mono text-lg font-semibold">
|
|
CHF {inv.totalChf.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
// draft
|
|
const draft = data.draft;
|
|
const periodLabel = `${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">
|
|
<div>
|
|
<p className="text-xs text-text-muted">{t("accruedSoFar")}</p>
|
|
<p className="text-xs text-text-secondary">{periodLabel}</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs text-text-muted">{t("estimatedTotal")}</p>
|
|
<p className="font-mono text-2xl font-semibold text-accent">
|
|
CHF {draft.totalChf.toFixed(2)}
|
|
</p>
|
|
<button
|
|
onClick={() => setRefreshCounter((n) => n + 1)}
|
|
className="text-[10px] text-text-muted hover:text-text-secondary underline mt-1 cursor-pointer"
|
|
>
|
|
{t("refresh")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{draft.lines.length > 0 && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer text-text-muted hover:text-text-secondary">
|
|
{t("breakdownToggle", { count: draft.lines.length })}
|
|
</summary>
|
|
<table className="w-full mt-2 text-xs">
|
|
<tbody>
|
|
{draft.lines.map((ln, i) => (
|
|
<tr key={i} className="border-t border-border">
|
|
<td className="py-1 pr-2">{ln.description}</td>
|
|
<td className="py-1 text-right font-mono">
|
|
{ln.amountChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
<tr className="border-t border-border">
|
|
<td className="py-1 pr-2 text-text-muted text-right">
|
|
{t("subtotalLabel")}
|
|
</td>
|
|
<td className="py-1 text-right font-mono">
|
|
{draft.subtotalChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="py-1 pr-2 text-text-muted text-right">
|
|
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })}
|
|
</td>
|
|
<td className="py-1 text-right font-mono">
|
|
{draft.vatAmountChf.toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</details>
|
|
)}
|
|
<p className="text-[10px] text-text-muted mt-3 italic">{t("draftNote")}</p>
|
|
</Card>
|
|
);
|
|
}
|