// src/pages/DashboardPage/BillingPage/BillingOverviewPage.tsx // Billing workspace v2: the Overview dashboard + default billing landing (redirect target for // /dashboard/billing). Status-count cards (Draft/Sent/Past due/Paid), then a second row pairing the // recent-invoices list (3/4) with the total-outstanding figure (1/4). Org-wide billing settings (sales tax // + default payment terms) live on the Pricing options page's Settings tab (BillingSettingsTab.tsx), NOT // here. All reads go through useInvoiceServiceContext(); Radix ui/* + Tailwind, no @wordpress/components. import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import type { Invoice } from 'fhir/r4'; import { useInvoiceServiceContext } from '../../../services/InvoiceService.tsx'; import { formatMoney } from '../../../utils/money'; import { formatDate } from '../../../utils/dateUtils'; import { invoiceAmountText, invoiceNumberOf, pastDue, invoicePatientLabel, invoiceState } from './invoiceDisplay'; import { invoiceSubjectId, useInvoicePatientNames } from './useInvoicePatientNames'; import { InvoiceStatusMark } from './InvoiceStatusMark'; import './billingInvoiceRow.css'; import './billingOverview.css'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { needsLedger, summarizeOutstanding, type Ledgers, type OutstandingSummary } from './billingOverviewModel'; // Unpack a Bundle's resources - the server org-scopes the search, so every entry is an in-scope Invoice. const unpack = (bundle: { entry?: { resource?: Invoice }[] }): Invoice[] => (bundle.entry ?? []).map((e) => e.resource).filter((r): r is Invoice => Boolean(r)); const invoiceWhen = (invoice: Invoice) => invoice.date ?? invoice.meta?.lastUpdated ?? ''; // A stat card is a button: clicking it lands on the Invoices list with that status pre-selected in the // filter (via ?filter=; InvoiceList reads it on mount). w-full so the button fills its grid cell. const StatCard = ({ label, value, accent, onClick }: { label: string; value: number | null; accent?: boolean; onClick: () => void }) => ( ); const BillingOverviewPage = () => { const navigate = useNavigate(); const { countInvoices, fetchInvoices, fetchInvoicePayments } = useInvoiceServiceContext(); const [loading, setLoading] = useState(true); const [error, setError] = useState(); const [draftCount, setDraftCount] = useState(0); const [paidCount, setPaidCount] = useState(0); const [owed, setOwed] = useState({ sent: 0, pastDue: 0, outstandingCents: 0 }); const [recent, setRecent] = useState([]); const patientNames = useInvoicePatientNames(recent); useEffect(() => { let active = true; const load = async () => { setLoading(true); setError(undefined); try { // Draft/Paid are cheap server-side `_summary=count` calls. The issued (= sent/unpaid) set is // pulled IN FULL - paginated to exhaustion - because both the past-due count and the // outstanding-dollar total are derived from the actual resources (FHIR has no server-side // sum, and "past due" keys off a due-date extension that isn't a searchable param), so a // capped fetch would silently undercount BOTH. Sent count = the size of that full set. At // practice scale the unpaid set is small (invoices get paid or voided); the page ceiling in // fetchAllIssued is only a runaway guard. Recent is a plain last-10 fetch. All fired together. const fetchAllIssued = async (): Promise<{ all: Invoice[]; complete: boolean }> => { const all: Invoice[] = []; let complete = false; let pageParam: string | undefined; // 50 pages * 100 = 5000 issued invoices - no beta-scale practice approaches this. If it // ever trips, that's the signal to move this aggregation server-side (an API endpoint), // not to raise the cap. for (let page = 0; page < 50; page++) { const bundle = await fetchInvoices(100, pageParam, undefined, 'issued'); all.push(...unpack(bundle)); const nextUrl = bundle.link?.find((l) => l.relation === 'next')?.url; const match = nextUrl?.match(/page=([^&]*)/); pageParam = match ? match[1] : undefined; if (!pageParam) { complete = true; break; } } return { all, complete }; }; const readLedgers = async (invoices: Invoice[]): Promise => { const ledgers: Ledgers = new Map(); const owing = invoices.filter(needsLedger); for (let from = 0; from < owing.length; from += 5) { await Promise.all(owing.slice(from, from + 5).map(async (invoice) => { try { ledgers.set(invoice.id as string, await fetchInvoicePayments(invoice.id as string)); } catch { ledgers.set(invoice.id as string, 'unreadable'); } })); } return ledgers; }; const [draft, paid, issued, recentBundle] = await Promise.all([ countInvoices('draft'), countInvoices('balanced'), fetchAllIssued(), fetchInvoices(10), ]); const ledgers = await readLedgers(issued.all); if (!active) return; setDraftCount(draft); setPaidCount(paid); setOwed(summarizeOutstanding(issued.all, ledgers, issued.complete)); setRecent(unpack(recentBundle)); } catch (err) { if (!active) return; console.error('Error loading billing overview:', err); setError(relayServerFailure(err, 'Failed to load billing overview.')); } finally { if (active) setLoading(false); } }; void load(); return () => { active = false; }; }, [countInvoices, fetchInvoices, fetchInvoicePayments]); return (

Overview

{loading ? (

Loading billing overview…

) : error ? (

{error}

) : ( <>
navigate('/dashboard/billing/invoices?filter=draft')} /> navigate('/dashboard/billing/invoices?filter=sent')} /> navigate('/dashboard/billing/invoices?filter=past-due')} /> navigate('/dashboard/billing/invoices?filter=paid')} />
{/* Second row: recent invoices (3/4) beside the total-outstanding figure (1/4). On a narrow screen they stack (grid-cols-1); at lg they split 3:1. */}

Recent invoices

{recent.length === 0 ? (

No invoices yet.

) : (
{recent.map((invoice) => { const overdue = pastDue(invoice); return ( ); })}
)}

Total outstanding

Unpaid sent invoices.

Sent {owed.sent === null ? 'Could not be read' : owed.sent}
Past due 0 ? ' ak-row__state--bad' : ''}`}> {owed.pastDue === null ? 'Could not be read' : owed.pastDue}
Total {owed.outstandingCents === null ? ( Could not be read ) : ( {formatMoney(owed.outstandingCents)} )}
)}
); }; export default BillingOverviewPage;