import { useState, useEffect, useCallback } from 'react'; import { Spinner } from "../../../../../components/ui/spinner"; import { Scrollable } from "../../../../../components/ui/scrollable"; import { InputControl } from "../../../../../components/ui/input-control"; import { useNavigate, useParams } from 'react-router-dom'; import { DismissibleAlert } from '../../../../../components/ui/dismissible-alert'; import { Flex, FlexItem } from '../../../../../components/ui/flex'; import { Text } from '../../../../../components/ui/text'; import { Spacer } from '../../../../../components/ui/spacer'; import { Modal } from '../../../../../components/ui/wp-modal-shim'; import { useForm, FormProvider } from 'react-hook-form'; import { HealthcareService, Organization, Attachment } from 'fhir/r4'; import { Check, FileText, Trash2 } from 'lucide-react'; import { useAuthServiceContext, useHealthcareServiceServiceContext, useValueSetServiceContext } from '../../../../../services'; import { getServicePriceCents, applyServicePrice } from '../../../../../config/servicePrice'; import { getServiceFutureAppointmentLimit, applyServiceFutureAppointmentLimit, } from '../../../../../config/serviceFutureAppointmentLimit'; import { preserveHealthcareServiceExtensions } from '../../../../../config/healthcareServiceExtensions'; import { parseDollarsToCents } from '../../../../../utils/money'; import { AppHeader, AppSpinner, HistoryNavigator, ProfileLayoutContainer, FhirHealthcareService } from '../../../../../components'; import { useFhirHealthcareServiceTargetedBroadcast, useRouteAuthorization } from '../../../../../hooks'; import { toast } from 'sonner'; import { describeWriteFailure } from '../../../../../utils/fetchFailureMessage'; import { defaultHoursRows } from '../../../../../utils/defaultHoursRows'; interface EditHealthcareServicePageProps { presentation?: 'page' | 'modal'; healthcareServiceId?: string; initialHealthcareService?: HealthcareService | null; onRequestClose?: () => void; onSaved?: (healthcareService: HealthcareService) => void; onDeleted?: (healthcareService: HealthcareService) => void; } const EditHealthcareServicePage = ({ presentation = 'page', healthcareServiceId: healthcareServiceIdProp, initialHealthcareService, onRequestClose, onSaved, onDeleted, }: EditHealthcareServicePageProps = {}) => { const { healthcareServiceId: routeHealthcareServiceId } = useParams<{ healthcareServiceId: string }>(); const healthcareServiceId = healthcareServiceIdProp ?? routeHealthcareServiceId ?? initialHealthcareService?.id ?? 'new'; const isModal = presentation === 'modal'; const navigate = useNavigate(); const { wpOrganization } = useAuthServiceContext(); const { fetchHealthcareService, transformToFhirHealthcareService, transformFromFhirHealthcareService, createHealthcareService, updateHealthcareService, deleteHealthcareService, checkHealthcareService, isDeleteUnconfirmed, healthcareServicesList, setHealthcareServicesList, } = useHealthcareServiceServiceContext(); const { daysOfWeek } = useValueSetServiceContext(); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(); const [error, setError] = useState(); const [photo, setPhoto] = useState(undefined); const [healthcareService, setHealthcareService] = useState( initialHealthcareService ?? undefined, ); // Optional per-service price, as dollars text (e.g. "49.99"); blank = unpriced. Kept as local state // (like `photo`) since it isn't a react-hook-form field - it round-trips through the service-price // extension via getServicePriceCents/applyServicePrice (Task 1), not through `transformTo/FromFhirHealthcareService`. const [priceInput, setPriceInput] = useState(''); // Blank is the DEFAULT and it means no limit, which is the whole of the 2026-09-06 ruling. // Held as a string rather than a number so an empty box and a zero stay different things. const [futureLimitInput, setFutureLimitInput] = useState(''); const [showDeleteModal, setShowDeleteModal] = useState(false); const [deleteUnconfirmed, setDeleteUnconfirmed] = useState(false); const [checkWords, setCheckWords] = useState(); const isNew = healthcareServiceId === 'new'; const spacerMargin = '24px'; // Same section + level as the route (`RequireStaffPermission section="organization" manage`) rather // than a fixed workspace list - see LocationPage for why the old [ADMIN, GENERIC] list silently broke // delegated `organization: manage` (; Spencer 2026-08-10). useRouteAuthorization({ section: 'organization', manage: true }); const methods = useForm({ defaultValues: { active: true, name: '', comment: '', extraDetails: '', category: [], type: [], specialty: [], location: [], coverageArea: [], serviceProvisionCode: [], eligibility: [], program: [], characteristic: [], communication: [], referralMethod: [], appointmentRequired: false, availabilityExceptions: '', availableTime: defaultHoursRows(daysOfWeek), notAvailable: [], telecom: [], endpoint: [], photo: null, identifier: [] } }); const { handleSubmit, reset } = methods; const init = useCallback((healthcareService: HealthcareService) => { const { photo: healthcareServicePhoto, formData } = transformFromFhirHealthcareService(healthcareService); reset(formData); setHealthcareService(healthcareService); setPhoto(healthcareServicePhoto); const priceCents = getServicePriceCents(healthcareService); setPriceInput(priceCents === undefined ? '' : (priceCents / 100).toFixed(2)); const futureLimit = getServiceFutureAppointmentLimit(healthcareService); setFutureLimitInput(futureLimit === undefined ? '' : String(futureLimit)); }, [transformFromFhirHealthcareService, reset]); useEffect(() => { if (initialHealthcareService) { init(initialHealthcareService); } }, [initialHealthcareService, init]); useFhirHealthcareServiceTargetedBroadcast({ healthcareService, healthcareServiceId, onUpdate: init, onDelete: () => { if (isModal) { if (healthcareService) { onDeleted?.(healthcareService); } onRequestClose?.(); return; } navigate('..', { relative: 'path' }); } }); useEffect(() => { const loadHealthcareService = async () => { if (isNew || healthcareService) return; const foundHealthcareService = healthcareServicesList?.find(hs => hs.id === healthcareServiceId); if (foundHealthcareService) { init(foundHealthcareService); return; } setLoading(true); try { const fetchedService = await fetchHealthcareService(healthcareServiceId as string); init(fetchedService); } catch (err) { console.error('Error loading healthcare service:', err); setError('Could not load healthcare service. Try again.'); } finally { setLoading(false); } }; loadHealthcareService(); }, [healthcareService, healthcareServicesList, healthcareServiceId, fetchHealthcareService, init]); const onSubmit = async (data: any) => { if (!wpOrganization.current) { setError('Organization information is missing. Please try again.'); return; } const trimmedPrice = priceInput.trim(); const priceCents = trimmedPrice === '' ? undefined : parseDollarsToCents(trimmedPrice); if (trimmedPrice !== '' && (priceCents === undefined || priceCents < 0)) { setError('Enter a valid price (e.g. 49.99), or leave it blank.'); return; } // A BLANK BOX IS AN ANSWER: no limit, and it is the default the ruling names. So an empty // string is not an error and is not zero - it is the practice saying they do not cap this // service. Anything typed has to be a whole number of at least one, because the reader // ignores everything else and an editor that stores what no reader honours is two screens // disagreeing about the same setting. const trimmedFutureLimit = futureLimitInput.trim(); const futureLimit = trimmedFutureLimit === '' ? undefined : Number(trimmedFutureLimit); if ( futureLimit !== undefined && (!Number.isInteger(futureLimit) || futureLimit < 1) ) { setError('Enter a whole number of one or more, or leave it blank for no limit.'); return; } setSubmitting(true); setSuccess(undefined); setError(undefined); try { const transformed = isNew ? transformToFhirHealthcareService( data, photo, wpOrganization.current as Organization, ) : { id: healthcareService?.id, ...transformToFhirHealthcareService(data, photo), }; // The transform maps form fields only; it emits no `extension`. Carry the existing // service's extensions (per-service duration, self-bookable default,...) through the // save so editing anything else here doesn't silently wipe them. const nextHealthcareService = preserveHealthcareServiceExtensions( transformed, isNew ? undefined : healthcareService, ); // Applied last so it only strips/replaces its own price extension, leaving the // preserved duration/self-bookable extensions untouched. const pricedHealthcareService = applyServicePrice(nextHealthcareService, priceCents); // Same reasoning as the price above: applied last so it strips and replaces only its // own extension and leaves every preserved one alone. const cappedHealthcareService = applyServiceFutureAppointmentLimit( pricedHealthcareService, futureLimit, ); const savedHealthcareService = isNew ? await createHealthcareService(cappedHealthcareService) : await updateHealthcareService(cappedHealthcareService); setHealthcareService(savedHealthcareService); setHealthcareServicesList((currentServices) => { const remainingServices = currentServices.filter( (currentService) => currentService.id !== savedHealthcareService.id, ); return [...remainingServices, savedHealthcareService]; }); const successMessage = `Healthcare service ${isNew ? 'created' : 'updated'} successfully.`; if (isModal) { toast.success(successMessage, { position: 'top-right' }); onSaved?.(savedHealthcareService); onRequestClose?.(); return; } if (isNew) { navigate('..', { relative: 'path' }); } setSuccess(successMessage); setTimeout(() => setSuccess(undefined), 3_000); } catch (err: any) { console.error(`Error ${isNew ? 'creating' : 'updating'} healthcare service:`, err); setError(describeWriteFailure(err, `Failed to ${isNew ? 'create' : 'update'} healthcare service`)); } finally { setSubmitting(false); } }; const handleDeleteHealthcareService = async () => { if (!healthcareService) return; setSubmitting(true); try { await deleteHealthcareService(healthcareService); setHealthcareServicesList((currentServices) => currentServices.filter( (currentService) => currentService.id !== healthcareService.id, ), ); if (isModal) { toast.success('Healthcare service deleted successfully.', { position: 'top-right' }); onDeleted?.(healthcareService); onRequestClose?.(); } } catch (error) { console.error('Failed to delete healthcare service:', error); if (isDeleteUnconfirmed?.(healthcareService)) { setDeleteUnconfirmed(true); setCheckWords(undefined); } else { setError('Failed to delete healthcare service. Please try again.'); } } finally { setSubmitting(false); setShowDeleteModal(false); } }; // Check: the exact read. Neither answer ends the earlier request, so the service stays off booking either way. const handleCheckDelete = async () => { if (!healthcareService?.id || !checkHealthcareService) return; setSubmitting(true); try { const found = await checkHealthcareService(healthcareService.id); setCheckWords(found === 'absent' ? 'It is no longer listed, but whether the earlier request finished is still not known.' : 'It is still listed, and the earlier request may still finish. You can delete it again.'); } catch { setCheckWords('We could not check just now. Try again.'); } finally { setSubmitting(false); } }; const handlePhotoChange = (newPhoto: Attachment | undefined) => { setPhoto(newPhoto); }; const modalTitle = isNew ? 'Add Healthcare Service' : 'Edit Healthcare Service'; if (loading) { if (isModal) { return ( onRequestClose?.()} size="large" >
); } return ; } const editorFields = ( setPriceInput(value ?? '')} disabled={submitting} /> setFutureLimitInput(value ?? '')} disabled={submitting} /> ); const editorNotices = ( <> {success && ( <> setSuccess(undefined)}> {success} )} {deleteUnconfirmed && ( <> Deleting “{healthcareService?.name ?? 'this service'}” could not be confirmed. It is not offered for booking until this is settled. {checkWords && <> {checkWords}} )} {error && ( <> setError(undefined)}>
{error}
)} ); const deleteConfirmation = showDeleteModal ? ( setShowDeleteModal(false)} > Are you sure you want to delete the healthcare service "{healthcareService?.name}"? This action cannot be undone. ) : null; return ( resourceType="HealthcareService" resourceId={healthcareServiceId || ''} > {(showHistory) => ( {isModal ? ( { if (!submitting) { onRequestClose?.(); } }} size="large" headerActions={ {!isNew && ( )} {!isNew && ( )} } >
{editorNotices} {editorFields}
{deleteConfirmation}
) : (
{!isNew && ( )} {!isNew && ( )} {editorNotices} {editorFields} {deleteConfirmation}
)}
)} ); }; export default EditHealthcareServicePage;