// src/pages/DashboardPage/BillingPage/FeeSchedule.tsx // Invoicing charge-catalog epic, Phase A / Task 2: the Catalog tab's first sub-tab - a flat OR timed, // per-service fee schedule. Staff who can manage billing set a default price directly on each // HealthcareService, either a flat price (the `service-price` extension, src/config/servicePrice.ts) or a // per-time-unit rate (the `service-time-rate` / `service-time-unit` extensions, // src/config/serviceTimeRate.ts) - never both. Everyone else who can see Billing gets a read-only view // (current pricing as text, no inputs, no save action). // // *** CRITICAL SAVE PATH *** - a row saves through saveServicePrice, billing's price-only route: it sends // just the flat cents, the rate and unit, or no price, and the server applies that to the stored service. // Setting one mode clears the other, and every other field and extension survives untouched. The whole- // service PUT needs organization manage, which a billing manager need not hold. // // Feature B (B1b): the list is server-side searched + paginated (Paginator, context "catalog-services", // pageSize 25) via `searchHealthcareServices` - deliberately NOT the shared `useFhirHealthcareServices` // fetch-all list (which booking/scheduling still own); this screen never touches that cache. Editing is // PER-ROW: each row keeps its own local edit (feeScheduleModel.ts is the pure, unit-tested part), and a // dirty+valid row shows a green check save action that saves ONLY that row via the price-only save above. // On success the row's edit is cleared and the saved resource is pushed into the Paginator's own item list // (actions.updateItem), so the row re-renders with the new stored price without a refetch. On failure the // row stays dirty and flags "Failed to save - try again" inline so it can be retried. There is no batch // "Save changes" button - with pagination it could only ever see the current page, silently dropping // cross-page edits. import { useCallback, useState } from 'react'; import type { HealthcareService } from 'fhir/r4'; import { Check, Loader2, Pencil, X } from 'lucide-react'; import { Input } from '../../../components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../components/ui/select'; import { Paginator } from '../../../components'; import { useHealthcareServiceServiceContext, useStaffPermissionServiceContext, usePaginatorServiceContext } from '../../../services'; import { getServicePriceCents } from '../../../config/servicePrice'; import { getServiceTimeRate, type TimeUnit } from '../../../config/serviceTimeRate'; import { formatMoney } from '../../../utils/money'; import { formatFhirCodeableConcept } from '../../../utils/fhirCodeableConcept'; import { buildFeeScheduleSavePlan, defaultRowEdit, type FeeScheduleRowEdit, type PricingMode, type SavePlanEntry } from './feeScheduleModel'; import './billingFeeSchedule.css'; const categoryTypeLabel = (service: HealthcareService): string => { const category = service.category?.[0] ? formatFhirCodeableConcept(service.category[0]) : ''; const type = service.type?.[0] ? formatFhirCodeableConcept(service.type[0]) : ''; return [category, type].filter(Boolean).join(' | '); }; const TIME_UNIT_OPTIONS: { value: TimeUnit; label: string }[] = [ { value: 'hour', label: 'Hour' }, { value: '30min', label: '30 min' }, { value: '15min', label: '15 min' }, { value: 'session', label: 'Session' }, ]; const TIME_UNIT_LABELS: Record = { hour: 'hour', '30min': '30 min', '15min': '15 min', session: 'session', }; // Read-only pricing display: "$250" for a flat-priced service, "$150 / hour" for a timed one, "-" if // neither is set. const formatPricingDisplay = (service: HealthcareService): string => { const timeRate = getServiceTimeRate(service); if (timeRate) { return `${formatMoney(timeRate.rateCents)} / ${TIME_UNIT_LABELS[timeRate.unit]}`; } const cents = getServicePriceCents(service); return cents !== undefined ? formatMoney(cents) : '—'; }; const FeeSchedule = () => { const { canManage } = useStaffPermissionServiceContext(); const { saveServicePrice, searchHealthcareServices } = useHealthcareServiceServiceContext(); const { actions } = usePaginatorServiceContext(); const [edits, setEdits] = useState>({}); const [savingIds, setSavingIds] = useState([]); const [failedIds, setFailedIds] = useState([]); // A row reads as text until its pencil opens the editor, as the frame draws the list. const [openIds, setOpenIds] = useState([]); const canManageBilling = canManage('billing'); // Server-side search + pagination source for the Paginator. MUST be memoized - it's in the Paginator's // init `useEffect` deps; an inline function would re-run initialize every render (refetch loop). const fetchServices = useCallback( (pageSize: number, pageParam?: string, query?: string) => searchHealthcareServices(pageSize, pageParam, query), [searchHealthcareServices], ); // Applies a partial patch to one row's edit state, seeding it from the service's current pricing (via // `defaultRowEdit`) the first time the row is touched. Used by the mode toggle and every input so any // one of them clears that row's failed-save flag. const updateRow = (serviceId: string, service: HealthcareService, patch: Partial) => { setEdits((prev) => ({ ...prev, [serviceId]: { ...(prev[serviceId] ?? defaultRowEdit(service)), ...patch }, })); setFailedIds((prev) => prev.filter((id) => id !== serviceId)); }; // Saves ONE row through the price-only route (see CRITICAL SAVE PATH above). // On success clears the row's edit and reflects the saved resource in the Paginator's own list; on // failure leaves the row dirty and flags it so the user can retry just that row. const closeRow = (serviceId: string) => { setEdits((prev) => { const next = { ...prev }; delete next[serviceId]; return next; }); setFailedIds((prev) => prev.filter((id) => id !== serviceId)); setOpenIds((prev) => prev.filter((id) => id !== serviceId)); }; const handleSaveRow = async (entry: SavePlanEntry) => { const id = entry.service.id; if (!id || !canManageBilling || savingIds.includes(id)) { return; } setSavingIds((prev) => [...prev, id]); setFailedIds((prev) => prev.filter((x) => x !== id)); try { const saved = entry.mode === 'flat' ? await saveServicePrice(id, entry.cents === undefined ? { mode: 'none' } : { mode: 'flat', cents: entry.cents }) : await saveServicePrice( id, entry.rateCents === undefined ? { mode: 'none' } : { mode: 'timed', rateCents: entry.rateCents, unit: entry.unit }, ); closeRow(id); actions.updateItem(saved); } catch (err) { console.error('Failed to save price for service', id, err); setFailedIds((prev) => (prev.includes(id) ? prev : [...prev, id])); } finally { setSavingIds((prev) => prev.filter((x) => x !== id)); } }; // Header + row grid share one column template so the columns line up. The save-action column exists // only for managers (read-only viewers get the 3-column layout with a text price). const renderRows = (services: HealthcareService[]) => { // The per-page save plan: validates each visible row's typed amount and yields the save-worthy // entries. Pagination means it only ever sees the current page - which is exactly why save is // per-row (a batch button couldn't see edits on other pages). const plan = buildFeeScheduleSavePlan(services, edits); return (
{canManageBilling && } {services.map((service) => { const id = service.id; if (!id) { return null; } const edit = edits[id] ?? defaultRowEdit(service); const isInvalid = plan.invalid.includes(id); const saveEntry = plan.toSave.find((entry) => entry.service.id === id); const isSavingRow = savingIds.includes(id); const didFail = failedIds.includes(id); const isOpen = canManageBilling && (openIds.includes(id) || id in edits); return ( {canManageBilling && ( )} ); })}
Name Category / type PricingEdit
{service.name ?? id} {categoryTypeLabel(service) || '—'} {isOpen ? (
{edit.mode === 'flat' ? (
$ updateRow(id, service, { dollars: event.target.value }) } />
) : (
$ updateRow(id, service, { rateDollars: event.target.value }) } />
/ {/* Fixed-width wrapper: a plain div reliably holds its width as a flex item, so the unit dropdown can't grow and squash the rate input (the SelectTrigger's own width class was being ignored). */}
)} {isInvalid ? (

Enter a valid, non-negative amount

) : didFail ? (

Failed to save — try again

) : null}
) : ( {formatPricingDisplay(service)} )}
{!isOpen && ( )} {isOpen && saveEntry && ( )} {isOpen && ( )}
); }; return (
{(services: HealthcareService[]) => renderRows(services as HealthcareService[])}
); }; export default FeeSchedule;