// src/pages/DashboardPage/BillingPage/BillingExportPage.tsx // (Billing-Aware Export), Task 10 - the CLIENT surface a biller uses to pull a date-ranged // export of completed visits and charges (CSV or JSON) for their own billing/claims workflow. This page // owns no fetch/query logic of its own: filters + validation + the browser-download step only. The // actual GET + blob fetch lives in Task 9's BillingExportService (useBillingExportServiceContext), // mounted by Task 11. Radix ui/* + Tailwind + lucide only - no @wordpress/components. import { useMemo, useRef, useState } from 'react'; import type { Practitioner } from 'fhir/r4'; import { Download, Loader2 } from 'lucide-react'; import { Input } from '../../../components/ui/input'; import { Label } from '../../../components/ui/label'; import { Alert, AlertDescription } from '../../../components/ui/alert'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select'; import SearchSelect, { type SearchSelectOption } from '../../../components/SearchSelect/SearchSelect'; import { useFhirPractitioners } from '../../../hooks'; import { useBillingExportServiceContext } from '../../../services/BillingExportService.tsx'; import { formatFhirHumanName } from '../../../utils/fhirHumanName.tsx'; import { downloadBlob } from '../../../utils/downloadBlob'; import { appointmentTypes } from '../../../config/appointmentTypes'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; import { useAuthServiceContext } from '../../../services/AuthService.tsx'; import { authSessionId } from '../../../services/authStorage'; import { SaveRetiredError, isSaveRetired, isSaveTimedOut, useSaveScope, type SaveOperation } from '../../../utils/saveOperation'; import './BillingExportPage.css'; // Sentinel for the encounter-type Select's "no filter" state. Radix forbids an empty-string // SelectItem value (that's reserved to mean "cleared / show placeholder"), so "all" stands in and is // translated back to `undefined` before it reaches BillingExportFilters - same convention UsersPage // already uses for its status filter. const ALL_ENCOUNTER_TYPES = 'all'; const BillingExportPage = () => { const { currentUser, wpOrganization, actingAsSessionRef } = useAuthServiceContext(); const { practitionersList } = useFhirPractitioners(); const { downloadBillingExport } = useBillingExportServiceContext(); const [from, setFrom] = useState(''); const [to, setTo] = useState(''); // Provider filter: once a practitioner is picked, the SearchSelect swaps for a chip (mirrors // InvoiceEditor's selected-patient pattern) with a "All providers" action that clears it - that IS // the "All providers option that clears the filter" the surface needs, adapted to how SearchSelect // actually behaves (a type-ahead that only shows results once the box has text, not a static // dropdown - see the Task 10 report for why a literal in-list "All providers" row doesn't fit it). const [provider, setProvider] = useState(''); const [providerLabel, setProviderLabel] = useState(''); const [providerQuery, setProviderQuery] = useState(''); const [encounterType, setEncounterType] = useState(ALL_ENCOUNTER_TYPES); const [downloadingCsv, setDownloadingCsv] = useState(false); const [downloadingJson, setDownloadingJson] = useState(false); const [error, setError] = useState(); const pending = useRef(); const organizationId = wpOrganization.current?.id; const sessionId = authSessionId(); const owner = JSON.stringify([ currentUser?.cognito?.sub, currentUser?.fhir?.resourceType, currentUser?.fhir?.id, organizationId, sessionId, actingAsSessionRef.current, from, to, provider, encounterType, ]); const downloads = useSaveScope(owner, () => { pending.current = undefined; setDownloadingCsv(false); setDownloadingJson(false); setError(undefined); }); // Provider options - a local filter over the already-loaded org roster (useFhirPractitioners // autoloads on mount), mirroring AppointmentsPage's practitioner SearchSelect: only populated once // the biller types something, multi-term substring match against the display name, capped to 10. const practitionerOptions: SearchSelectOption[] = practitionersList .map((entry) => entry.practitioner) .filter((practitioner): practitioner is Practitioner & { id: string } => Boolean(practitioner.id)) .map((practitioner) => ({ value: practitioner.id, label: formatFhirHumanName(practitioner.name, { emptyText: 'Unnamed practitioner' }), })); const normalizedProviderSearchTerms = useMemo( () => providerQuery.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean), [providerQuery], ); const providerSearchOptions = normalizedProviderSearchTerms.length === 0 ? [] : practitionerOptions .filter((option) => { const normalizedLabel = option.label.toLocaleLowerCase(); return normalizedProviderSearchTerms.every((term) => normalizedLabel.includes(term)); }) .sort((left, right) => left.label.localeCompare(right.label)) .slice(0, 10); const clearProvider = () => { setProvider(''); setProviderLabel(''); setProviderQuery(''); }; const bothDatesSet = from !== '' && to !== ''; const rangeInvalid = bothDatesSet && from > to; const busy = downloadingCsv || downloadingJson; const canDownload = bothDatesSet && !rangeInvalid && !busy; const handleDownload = async (format: 'csv' | 'json') => { if (!canDownload || pending.current) return; const operation = downloads.start(); pending.current = operation; const practice = organizationId; const session = sessionId; const viewerGeneration = actingAsSessionRef.current; const admit = () => { if (wpOrganization.current?.id !== practice || authSessionId() !== session || actingAsSessionRef.current !== viewerGeneration || pending.current !== operation) { operation.retire(); throw new SaveRetiredError(); } operation.admit(); }; const setDownloading = format === 'csv' ? setDownloadingCsv : setDownloadingJson; setDownloading(true); setError(undefined); try { const result = await operation.step(() => downloadBillingExport({ start: from, end: to, provider: provider || undefined, encounterType: encounterType === ALL_ENCOUNTER_TYPES ? undefined : encounterType, format, }, { admit, signal: operation.transport.signal, organizationId: practice })); admit(); downloadBlob(result.blob, result.filename); } catch (err) { if (!operation.belongs() || pending.current !== operation || isSaveRetired(err)) return; try { admit(); } catch (retired) { if (isSaveRetired(retired)) return; } setError(isSaveTimedOut(err) ? 'The billing export took too long. Please try again.' : relayServerFailure(err, 'Failed to download the export. Please try again.')); } finally { if (operation.belongs() && pending.current === operation) { pending.current = undefined; setDownloading(false); } operation.retire(); } }; return (

Export

Billing export

Exports visits that were attended, documented or charged, with their charges, in the selected range for your biller. Each visit keeps its own status. No claims are generated.

{error && ( {error} )}
setFrom(event.target.value)} required />
setTo(event.target.value)} required />
{rangeInvalid && (

End date must be on or after the start date.

)}
{provider ? (
{providerLabel}
) : ( { setProvider(option.value); setProviderLabel(option.label); setProviderQuery(''); }} /> )}
); }; export default BillingExportPage;