// 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 (
{/* 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. */}