Files
pieced-portal/src/components/billing/running-total-widget.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

197 lines
6.9 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 };
interface Props {
/**
* Whether the viewing user has org-owner role. Drives the
* "complete your billing details" CTA — only owners can edit
* billing settings, so non-owners see a softer message asking
* them to contact their org owner instead. The flag is computed
* server-side and passed in to avoid a second API round-trip.
*/
isOwner: boolean;
}
/**
* 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({ isOwner }: Props) {
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) {
const noConfig =
data && "code" in data && data.code === "COMPUTE_FAILED";
return (
<Card>
<p className="text-sm text-text-secondary py-2">
{noConfig ? t("noBillingConfig") : t("currentPeriodError")}
</p>
{/* Phase 6: owner-only CTA. Non-owners can't edit billing
settings, so we show them a "contact owner" hint instead
— that's gentler than a button that 404s on click. */}
{noConfig && isOwner && (
<Link
href="/settings/billing"
className="inline-block mt-2 px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors"
>
{t("configureBillingCta")}
</Link>
)}
{noConfig && !isOwner && (
<p className="text-xs text-text-muted italic mt-2">
{t("noBillingConfigNonOwner")}
</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;
// 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">
<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>
);
}