import { Ban, Banknote, ChartPie, CircleAlert } from "lucide-react"; import { cn } from "../../../lib/utils"; /* * THE STATE OF A BILL, AS A MARK RATHER THAN A PILL. * * A STATUS IS NOT A CONTROL, AND A FILLED PILL SAYS IT IS. The row rendered these as solid shadcn * Badges - a red "Past due", a blue "Sent" - which are the same shape, weight and colour as this * product's buttons, so a patient read the state of their bill as something to press. * * AND BEING LAST IN THE ROW MADE IT THE PART THAT GOT CLIPPED. Measured in the mockup at 390px, a bill * carrying tax and a "Past due" pill needed 368px in a 361px row - clipped rather than reflowed, so the * bill that most needed attention was the one whose state went missing first. The pill is 64px of text * where the mark is 17px of icon. * * EVERY STATE CARRIES ITS OWN SILHOUETTE, NOT JUST ITS OWN COLOUR - the same rule `ui/status.tsx` * follows, for the same reason: colour alone fails for roughly one man in twelve, and a red dot and a * green dot are the same dot. Alert ring - banknote - banknote-with-tick - struck-through circle - * pie chart. Take the shape away and this stops doing the one thing it is for. * * It is separate from `ui/status.tsx` deliberately. That component speaks the FHIR CLINICAL vocabulary * and derives its tone from `fhirStatusVocabulary`; a bill's states are money words a patient reads about * their own account. Adding a variant prop there is explicitly ruled out, and rightly. */ export type { InvoiceState } from "./invoiceDisplay"; import type { InvoiceState } from "./invoiceDisplay"; /** * The banknote-with-tick is drawn rather than imported: the mockup uses it and the pinned lucide * version has no such icon, so the paths are lifted from the drawing verbatim. */ const BanknoteTick = ({ className }: { className?: string }) => ( ); type Presentation = { label: string; className: string; /** Both a lucide icon and a locally drawn one - they agree on the only prop used here. */ Icon: React.ComponentType<{ className?: string }>; }; /* * THE PATIENT'S WORDS, NOT THE BILLER'S - and the two were the same map until now. * * `STATUS_LABEL` in invoiceDisplay.ts says so in its own comment: its wording was chosen because * "Sent"/"Void" read better TO A BILLER. That is true, and this component is the one the patient * reads. On a screen whose whole job is what do I owe, to whom, by when, "Sent" answers a * different question - it says what the practice did, not what the patient owes. * * Taken from the approved drawing rather than chosen here: mockups/patient-portal/ * bills-and-payments.html shows every row ending in one of Past due / Due / Paid / Cancelled. * * The biller's map is left exactly as it was. Two audiences, two vocabularies, and the * separation is the point - the Billing workspace's own list still says Sent and Void, which is * correct there. */ const PRESENTATION: Readonly> = { // The one state that is about to cost the patient something. "past-due": { label: "Past due", className: "text-destructive", Icon: CircleAlert }, // Blue, the product's "this is live and normal" - not green, which would read as settled. sent: { label: "Due", className: "text-primary", Icon: Banknote }, paid: { label: "Paid", className: "text-success", Icon: BanknoteTick }, // Spencer 08-28: the chart-pie icon for partial payments; the word is the deck's. "part-paid": { label: "Partially paid", className: "text-warning", Icon: ChartPie }, void: { label: "Cancelled", className: "text-faint", Icon: Ban }, // Deliberately quiet: a draft is not a bill anybody owes yet. draft: { label: "Draft", className: "text-ink-muted", Icon: Banknote }, }; /** The biller's words where they differ; the Billing workspace says Sent and Void. */ const STAFF_LABEL: Readonly>> = { sent: "Sent", void: "Void", }; /** The billing badge per state as a square status pill; draft and void take the plain pill. */ const BADGE: Readonly> = { "past-due": "ak-pill--bad", sent: "ak-pill--info", paid: "ak-pill--ok", "part-paid": "ak-pill--warn", void: "", draft: "", }; export interface InvoiceStatusMarkProps { state: InvoiceState; /** Whose vocabulary the label uses; the patient's unless a biller is reading. */ audience?: "patient" | "staff"; /** * Drops the word and keeps the mark. The wide row uses this - the practice name has taken the * space under the invoice number, so the far right holds a mark rather than a phrase. The label * survives as the accessible name and the tooltip, so nothing is lost to a reader who needs it. */ iconOnly?: boolean; /** The billing deck's list badge: the word in a squared badge, no icon. */ bordered?: boolean; className?: string; } /** * The word this state shows a patient. * * Exported so a caller building an accessible name for a whole row says the SAME word the row * displays. They were derived separately before, from two different maps, so a row could read * "Due" and announce "Sent". */ export const invoiceStateLabel = (state: InvoiceState): string => PRESENTATION[state].label; export const InvoiceStatusMark = ({ state, audience = "patient", iconOnly = false, bordered = false, className }: InvoiceStatusMarkProps) => { const { className: tone, Icon } = PRESENTATION[state]; const label = (audience === "staff" ? STAFF_LABEL[state] : undefined) ?? PRESENTATION[state].label; return ( {!bordered && } {!iconOnly && label} ); };