// src/pages/DashboardPage/BillingPage/InvoiceList.tsx // Phase 2 invoicing, Task 8: the org-wide invoice list that replaces the Billing stub. A // `` render-prop list (mirrors QuestionnairesPage.tsx's Paginator usage - // fetchFunction/config/context, `children(items, loading)`), rendered with Tailwind + ui/* rows (no new // @wordpress/components) to match Task 5/6's InvoiceEditor/InvoiceDetail. Row click opens InvoiceDetail in // a ui/dialog; the dialog's onChanged pushes the updated resource straight into the Paginator's own item // list via `actions.updateItem` - the same mechanism QuestionnairesPage already uses (there: `deleteItem` // after a delete) - so a status change (Issue/Mark-as-paid/Void) or an edited draft is reflected on the // row immediately, without waiting on a WebSocket round-trip. // // Live updates from OTHER staff members: this component subscribes directly to the invoice // updated/deleted WebSocket broadcasts and drives them straight into the Paginator's own `items` via // `actions.updateItem` / `actions.deleteItem` - the SAME array the rows render from, so there's a single // live source of truth (no separate overlay cache to go stale or fight with local optimistic updates). A // remote CREATE is intentionally not handled here - Paginator owns its own independently-fetched `items` // array (keyed by page), and deciding whether/where a brand-new invoice belongs on the currently-rendered // page requires sort/filter logic that's out of scope for this pass. A new invoice appears on next // re-fetch (search, pagination, or invalidatePaginatorContext('invoices'), which InvoiceEditor already // calls after every save) - an accepted v1 limitation, not something introduced here. import { useCallback, useEffect, useRef, useState } from 'react'; import { drillPeriodDateSearch, drillPeriodFrom } from '../../../utils/reportDrillPeriod'; import { OverlayFooter, OverlayFooterHost } from '../../../components/ui/overlay-actions'; import { useSearchParams } from 'react-router-dom'; import type { Invoice } from 'fhir/r4'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '../../../components/ui/dialog'; import { Paginator } from '../../../components'; import { useFhirServiceContext, useInvoiceServiceContext, useStaffPermissionServiceContext, usePaginatorServiceContext, useWebSocketServiceContext, } from '../../../services'; import { TellTheFrontDesk } from '../../../components/TellTheFrontDesk/TellTheFrontDesk'; import { formatDate } from '../../../utils/dateUtils'; import InvoiceDetail, { invoiceNumberOf, pastDue, invoicePatientLabel } from './InvoiceDetail'; import { invoiceSubjectId, useInvoicePatientNames } from './useInvoicePatientNames'; import { invoiceAmountText, invoiceState } from './invoiceDisplay'; import { InvoiceStatusMark } from './InvoiceStatusMark'; import './billingPricingChips.css'; import './billingInvoiceRow.css'; // The visible filter above the list. Draft/Sent/Paid/Void map straight to a server-side `status` query // (the datastore indexes Invoice by status, so those paginate server-side). "Past due" has no FHIR status // of its own - it fetches the ISSUED set server-side and refines it client-side to overdue rows (v2 // design §2c: FHIR Invoice search has no due-date param, so past-due can't be a clean server query). type InvoiceFilter = 'all' | 'draft' | 'sent' | 'past-due' | 'paid' | 'void'; const FILTERS: { value: InvoiceFilter; label: string }[] = [ { value: 'all', label: 'All' }, { value: 'draft', label: 'Draft' }, { value: 'sent', label: 'Sent' }, { value: 'past-due', label: 'Past due' }, { value: 'paid', label: 'Paid' }, { value: 'void', label: 'Void' }, ]; // filter the FHIR `status` sent to the server. "All" sends none; "Past due" fetches issued then refines. // Void asks for both statuses the list shows as Void (invoiceState), so neither kind of void goes missing. export const FILTER_SERVER_STATUS: Record = { all: undefined, draft: 'draft', sent: 'issued', 'past-due': 'issued', paid: 'balanced', void: 'cancelled,entered-in-error', }; // The Overview dashboard's stat cards deep-link here with `?filter=` to pre-select a filter; this // guards that value against the known set (an unknown/absent param leaves the default 'all' in place). const isInvoiceFilter = (value: string | null): value is InvoiceFilter => value !== null && FILTERS.some((option) => option.value === value); // Patient names come from useInvoicePatientNames: the session cache first, then one read per patient // the cache lacks. A read the server refuses leaves the fallback label. // An internal id is never the fallback: it tells a biller nothing and once reached a printed invoice. const patientLabel = (invoice: Invoice, names: Record): string => invoicePatientLabel(invoice, names[invoiceSubjectId(invoice) ?? '']); const InvoiceRows = ({ rows, onSelect }: { rows: Invoice[]; onSelect: (invoice: Invoice, patient: string) => void }) => { const names = useInvoicePatientNames(rows); // A flush card: the rows reach its edges, as the frame draws the list. return (
{rows.map((invoice) => { const overdue = pastDue(invoice); const patient = patientLabel(invoice, names); return ( ); })}
); }; const InvoiceList = () => { const { fetchInvoices } = useInvoiceServiceContext(); const { extractReferenceId } = useFhirServiceContext(); const { canManage } = useStaffPermissionServiceContext(); const { actions } = usePaginatorServiceContext(); const { subscribe, unsubscribe } = useWebSocketServiceContext(); const [searchParams] = useSearchParams(); const filterParam = searchParams.get('filter'); // A report's drill-down names the period its count came from; kept in state like the filter, since // the Paginator rewrites the query string. const [period, setPeriod] = useState(() => drillPeriodFrom(searchParams)); const [selectedInvoice, setSelectedInvoice] = useState(null); const [selectedPatient, setSelectedPatient] = useState(''); // Seed the filter from the deep-link param (Overview stat cards) so arriving here already shows the // right status; fall back to 'all'. The effect below follows a later param change (e.g. navigating in // from another card while this list is still mounted) but never resets to 'all' when the param is // dropped - the Paginator rewrites the query string, and we don't want that to clobber the user's // in-page filter clicks. const [filter, setFilter] = useState(isInvoiceFilter(filterParam) ? filterParam : 'all'); useEffect(() => { if (isInvoiceFilter(filterParam)) setFilter(filterParam); }, [filterParam]); const canManageBilling = canManage('billing'); const serverStatus = FILTER_SERVER_STATUS[filter]; // A per-filter Paginator context. The Paginator refetches whenever its `context` string changes // (PaginatorService.initializeFromUrl isDifferentContext), so switching the filter re-queries the // server with the new status. "All" keeps the original 'invoices' context so nothing changes for it. const paginatorContext = (filter === 'all' ? 'invoices' : `invoices-${filter}`) + (period ? `-${period.from}-${period.to}-${period.zone}` : ''); // Bind the filter's server status into a stable-per-filter fetchFunction. The search box still passes // its query through as the 3rd arg, so search keeps working within each filter. const fetchFiltered = useCallback( (pageSize: number, pageParam?: string, query?: string) => fetchInvoices(pageSize, pageParam, query, serverStatus, period ? drillPeriodDateSearch(period) : undefined), [fetchInvoices, serverStatus, period], ); // `actions` is a fresh object every render of the app-wide PaginatorServiceProvider (its // updateItem/deleteItem aren't memoized), so the subscribe effect below reads through this ref // instead of depending on `actions` directly - that keeps the WebSocket subscription stable // (subscribe once, unsubscribe on unmount) while always calling the current actions. const actionsRef = useRef(actions); actionsRef.current = actions; useEffect(() => { const handleInvoiceUpdated = (cloudEvent: any) => { const invoice: Invoice | undefined = cloudEvent?.data; // Ignore a malformed/partial broadcast payload (missing id or status) - a statusless invoice in // the rendered list would crash the status badge. The next real fetch carries the full resource. if (invoice?.id && invoice?.status) { actionsRef.current.updateItem(invoice); } }; const handleInvoiceDeleted = (cloudEvent: any) => { const invoice: Invoice = cloudEvent.data; if (invoice.id) { actionsRef.current.deleteItem(invoice.id); } }; subscribe('com.akashichealthrecords.fhir.invoice.updated', handleInvoiceUpdated); subscribe('com.akashichealthrecords.fhir.invoice.deleted', handleInvoiceDeleted); return () => { unsubscribe('com.akashichealthrecords.fhir.invoice.updated', handleInvoiceUpdated); unsubscribe('com.akashichealthrecords.fhir.invoice.deleted', handleInvoiceDeleted); }; }, [subscribe, unsubscribe]); const renderRows = (invoices: Invoice[]) => { // The server returned nothing for this filter return null so ONLY the Paginator's own // emptyMessage shows (don't stack a second "no past-due" message under "no invoices yet"). if (invoices.length === 0) { return null; } // "Past due" fetches the issued set server-side (status=issued), then refines it here to the rows // whose due date is actually before today - the accepted v2 client refinement (design §2c). const rows = filter === 'past-due' ? invoices.filter((invoice) => pastDue(invoice)) : invoices; if (rows.length === 0) { // Only reachable for the past-due filter: this page has issued invoices but none are overdue. return (

No past-due invoices on this page.

); } return ( { setSelectedInvoice(invoice); setSelectedPatient(patient); }} /> ); }; return ( <> {/* Status filter: one toggle button per state, pressed = primary fill. */}
{FILTERS.map((option) => ( ))}
{period && (

Issued {formatDate(period.from)} to {formatDate(period.to)}.{' '}

)} f.value === filter)?.label.toLowerCase() ?? ''} invoices.` } > {(invoices: Invoice[]) => renderRows(invoices)} { if (!open) setSelectedInvoice(null); }} > Invoice {selectedInvoice ? (invoiceNumberOf(selectedInvoice) ?? selectedInvoice.id) : ''} {selectedInvoice ? selectedPatient : ''}
{/* 🔔 CARD 389 — the front-desk door, HERE because this is where Spencer's use case starts: *"if billing recognized an issue with a patient's invoices/payments and wants the front desk to know right away about it before a patient checks in."* ⭐ The patient is already in hand (`subject.reference`), which matters more than it looks: billing holds NO patient scope, so this is one of the few places they can name the person the message is about — and under Spencer's (A) ruling the message names them. The button withholds itself where it could not complete. */} {selectedInvoice && ( )} {selectedInvoice && ( { setSelectedInvoice(updated); actions.updateItem(updated); }} /> )}
); }; export default InvoiceList;