"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(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 (

{t("computing")}

); } if (!data || "error" in data) { const noConfig = data && "code" in data && data.code === "COMPUTE_FAILED"; return (

{noConfig ? t("noBillingConfig") : t("currentPeriodError")}

{/* 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 && ( {t("configureBillingCta")} )} {noConfig && !isOwner && (

{t("noBillingConfigNonOwner")}

)}
); } if ("issued" in data) { const inv = data.issued; return (

{t("currentInvoiceIssued")}

{inv.invoiceNumber}

{t("totalLabel")}

CHF {inv.totalChf.toFixed(2)}

); } // 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 (

{t("accruedSoFar")}

{periodLabel}

{t("estimatedTotal")}

CHF {draft.totalChf.toFixed(2)}

{draft.lines.length > 0 && (
{t("breakdownToggle", { count: draft.lines.length })} {draft.lines.map((ln, i) => ( ))}
{ln.description} {ln.amountChf.toFixed(2)}
{t("subtotalLabel")} {draft.subtotalChf.toFixed(2)}
{t("vatLabel", { rate: draft.vatRate.toFixed(2) })} {draft.vatAmountChf.toFixed(2)}
)}

{t("draftNote")}

); }