// src/pages/DashboardPage/BillingPage/FeesCatalog.tsx // Invoicing charge-catalog epic, Phase A / Task 7 (Feature B1a rework): the Catalog tab's second sub-tab // - named flat charges (e.g. "Late fee", "No-show fee") modeled as Fee ChargeItemDefinitions // (src/config/chargeItemDefinition.ts, Task 6). `billing:manage` staff (canManage('billing')) get full // CRUD (add/edit/delete); everyone else who can already see Billing (billing:view - gated one level up by // BillingPage/BillingCatalog, so this component never needs to check `canView` itself) gets a read-only // list. // // Feature B1a: the list is now server-side searchable + paginated via the shared `` (the same // render-prop list InvoiceList uses), scoped to the 'fee' category. The list items live in the Paginator's // own state, so CRUD refresh goes through the Paginator's `actions` (addItem/updateItem/deleteItem) from // usePaginatorServiceContext() - not local `items` state. // // NOTE (backfill dependency, do not "fix"): the per-category query only returns items carrying the Plan-1 // category identifier, which existing items gain via a re-save backfill at deploy time. On an // un-backfilled datastore the list can look empty - that's expected, not a bug. // // Unlike FeeSchedule (which edits an existing FHIR resource's price extension in place), a Fee IS its own // resource - create/update/delete go straight through ChargeItemDefinitionService, and // buildFeeChargeItemDefinition is the only place a draft becomes the wire resource (money.ts's // toFhirMoney is the sole cents -> decimal conversion point, reached only through that builder). import { useCallback, useState } from 'react'; import type { ChargeItemDefinition } from 'fhir/r4'; import { Pencil, Trash2 } from 'lucide-react'; import { Input } from '../../../components/ui/input'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../../components/ui/dialog'; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, } from '../../../components/ui/alert-dialog'; import { Paginator } from '../../../components'; import { useChargeItemDefinitionServiceContext, useStaffPermissionServiceContext, usePaginatorServiceContext, } from '../../../services'; import { buildFeeChargeItemDefinition, isFeeCid, readFee, type FeeDraft } from '../../../config/chargeItemDefinition'; import { formatMoney, parseDollarsToCents } from '../../../utils/money'; type ModalState = { mode: 'add' } | { mode: 'edit'; cid: ChargeItemDefinition } | undefined; const FeesCatalog = () => { const { canManage } = useStaffPermissionServiceContext(); const { fetchChargeItemDefinitions, createChargeItemDefinition, updateChargeItemDefinition, deleteChargeItemDefinition, } = useChargeItemDefinitionServiceContext(); const { actions } = usePaginatorServiceContext(); const canManageBilling = canManage('billing'); const [modal, setModal] = useState(); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(); const [deletingCid, setDeletingCid] = useState(); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(); // Memoized because the Paginator holds this in its init effect's deps - an inline function would // re-trigger the fetch every render (refetch loop). Scopes the server search to the 'fee' category. const fetchFees = useCallback( (pageSize: number, pageParam?: string, query?: string) => fetchChargeItemDefinitions(pageSize, pageParam, query, 'fee'), [fetchChargeItemDefinitions], ); const openAdd = () => { setFormError(undefined); setModal({ mode: 'add' }); }; const openEdit = (cid: ChargeItemDefinition) => { setFormError(undefined); setModal({ mode: 'edit', cid }); }; const closeModal = () => { if (saving) return; setModal(undefined); setFormError(undefined); }; // Validates (reject blank title / negative or non-numeric price - same rule FeeSchedule applies via // feeScheduleModel.ts) and dispatches to create or update depending on which fee (if any) is being // edited. On success, the saved resource is pushed into the Paginator's own item list via // actions.addItem (create) / actions.updateItem (edit) - the list now lives in the Paginator, not // local state, so there's no full refetch. const handleSave = async (title: string, priceText: string) => { const trimmedTitle = title.trim(); if (!trimmedTitle) { setFormError('Enter a name for this fee.'); return; } const priceCents = parseDollarsToCents(priceText); if (priceCents === undefined || priceCents < 0) { setFormError('Enter a valid, non-negative amount.'); return; } setSaving(true); setFormError(undefined); try { // Checked inline (rather than hoisted to an `isEdit` boolean) so TS's discriminated-union // narrowing on `modal.cid` applies directly at each usage. const saved = modal?.mode === 'edit' ? await updateChargeItemDefinition( buildFeeChargeItemDefinition({ id: modal.cid.id, url: modal.cid.url, title: trimmedTitle, priceCents }), ) : await createChargeItemDefinition(buildFeeChargeItemDefinition({ title: trimmedTitle, priceCents })); // `modal` doesn't change across the await (no setModal until here), so this mode check is the // same branch taken above - updateItem replaces the row by id, addItem prepends the new one. if (modal?.mode === 'edit') { actions.updateItem(saved); } else { actions.addItem(saved); } setModal(undefined); } catch (err) { console.error('Failed to save fee:', err); setFormError('Failed to save this fee. Please try again.'); } finally { setSaving(false); } }; const handleDelete = async () => { if (!deletingCid) return; setDeleting(true); setDeleteError(undefined); try { await deleteChargeItemDefinition(deletingCid); if (deletingCid.id) { actions.deleteItem(deletingCid.id); } setDeletingCid(undefined); } catch (err) { console.error('Failed to delete fee:', err); setDeleteError('Failed to delete this fee. Please try again.'); } finally { setDeleting(false); } }; // Condensed rows. The server already scopes the query to the 'fee' category, so `isFeeCid` here is a // cheap defensive filter (kept - it also keeps the import in use). Returns null when empty so only the // Paginator's own "No fees yet." notice shows, rather than an empty column header. const renderRows = (cids: ChargeItemDefinition[]) => { const rows = cids.filter(isFeeCid); if (rows.length === 0) { return null; } return (
{rows.map((cid) => { if (!cid.id) { return null; } const draft = readFee(cid); return ( ); })}
Name Price Actions
{draft.title || '—'} {formatMoney(draft.priceCents)} {canManageBilling && (
)}
); }; return (
{canManageBilling && (
)} {(items) => renderRows(items as ChargeItemDefinition[])} {modal && ( { if (!open) closeModal(); }} onSave={(title, priceText) => void handleSave(title, priceText)} /> )} { if (!open && !deleting) { setDeletingCid(undefined); setDeleteError(undefined); } }} > Delete “{deletingCid ? readFee(deletingCid).title || 'this fee' : ''}”? This removes the fee from the catalog. It can no longer be added to new invoices, but existing invoice lines are unaffected. This can't be undone. {deleteError &&
{deleteError}
} Keep the fee
); }; // The Add/Edit fee form: a name + `$`-prefixed price. Deliberately not reusing FeeSchedule's inline-row // editing (a Fee has no backing resource to edit in place until it's created) - mirrors AddServiceModal's // isEdit convention (one form for both create and edit) but built on ui/dialog (Radix) instead of the WP // , per the zero-WordPress-coupling rule for new code. Mounted/unmounted by the parent's `modal` // state (rather than toggled via an `open` prop), so its local field state always starts fresh - no stale // title/price to reset via an effect. The dialog's own X close button is the only "cancel" affordance // (no Cancel button next to Save), per the no-cancel-buttons-in-modals convention. const FeeFormDialog = ({ mode, initial, saving, error, onOpenChange, onSave, }: { mode: 'add' | 'edit'; initial?: FeeDraft; saving: boolean; error?: string; onOpenChange: (open: boolean) => void; onSave: (title: string, priceText: string) => void; }) => { const [title, setTitle] = useState(initial?.title ?? ''); const [priceText, setPriceText] = useState(initial ? (initial.priceCents / 100).toFixed(2) : ''); return ( {mode === 'edit' ? 'Edit fee' : 'Add fee'}
setTitle(event.target.value)} />
$ setPriceText(event.target.value)} />
{error &&
{error}
}
); }; export default FeesCatalog;