import { useCallback, useEffect, useMemo, useState } from 'react'; import { SelectControl } from "../../../components/ui/select-control"; import { TextControl } from "../../../components/ui/text-control"; import { FormTokenField } from "../../../components/ui/form-token-field"; import { Spinner } from "../../../components/ui/spinner"; import { InputControl } from "../../../components/ui/input-control"; import { DismissibleAlert } from '../../../components/ui/dismissible-alert'; import { Flex, FlexItem } from '../../../components/ui/flex'; import { Spacer } from '../../../components/ui/spacer'; import { Modal } from '../../../components/ui/wp-modal-shim'; import { HealthcareService, Location } from 'fhir/r4'; import { toast } from 'sonner'; import { IValueSet, useAuthServiceContext, useHealthcareServiceServiceContext, } from '../../../services'; import { useFhirLocations } from '../../../hooks'; import type { ServiceCategory } from '../../../config/serviceCategories'; import { MIN_SNOMED_SEARCH_CHARS, SNOMED_SYSTEM, usePracticeServiceCategories, useScopedServiceTypeSearch, } from '../../../components/ServiceTypeSearch/useScopedServiceTypeSearch'; import { SERVICE_DEFAULT_DURATION_EXTENSION_URL, DURATION_QUICK_PICK_MINUTES, getServiceDefaultDurationMinutes, formatDurationLabel, } from '../../../config/appointmentDuration'; import { SERVICE_PATIENT_SELF_BOOKABLE_EXTENSION_URL, getServiceSelfBookableDefault, } from '../../../config/serviceSelfBookable'; import { getServicePriceCents, applyServicePrice } from '../../../config/servicePrice'; import { getServiceFutureAppointmentLimit, applyServiceFutureAppointmentLimit, } from '../../../config/serviceFutureAppointmentLimit'; import { getServiceTelehealthEligibleDefault, applyServiceTelehealthEligible, } from '../../../config/serviceTelehealthEligible'; import { applyServicePracticeCategory, replaceStandardCategory, servicePracticeCategoryOf } from '../../../config/servicePracticeCategory'; import { parseDollarsToCents } from '../../../utils/money'; import { onlySites } from '../../../utils/roomLocation'; import { widenerCopy } from '../../../utils/booking/bookabilityCopy'; // - WordPress's ToggleControl, swapped for the ui/* adapter. Same props, same JSX; // only `__nextHasNoMarginBottom` is dropped, which means nothing outside WordPress. import { ToggleControl } from '../../../components/ui/toggle-control'; import { relayServerFailure } from '../../../utils/fetchFailureMessage'; // The slim "Add a service" editor (design §4). Persists a FHIR HealthcareService whose `type` is a SNOMED // concept picked via live, category-scoped search (design §3) - the standard code is invisible plumbing. Every // other HealthcareService field stays unset (still valid FHIR); the full editor remains the advanced/edit path. interface AddServiceModalProps { onRequestClose: () => void; onSaved?: (service: HealthcareService) => void; // When provided, the modal edits this existing service (same slim convention) instead of creating a new one. service?: HealthcareService | null; onDeleted?: () => void; // Opens with this practice category chosen, as when adding a service under it. initialCategoryCode?: string; } const AddServiceModal = ({ onRequestClose, onSaved, service, onDeleted, initialCategoryCode }: AddServiceModalProps) => { const { categories, scopedCategories, loading: categoriesLoading, error: categoriesError } = usePracticeServiceCategories(); const { createHealthcareService, updateHealthcareService, deleteHealthcareService, checkHealthcareService, isDeleteUnconfirmed, setHealthcareServicesList, } = useHealthcareServiceServiceContext(); const { wpOrganization } = useAuthServiceContext(); const { locationsList } = useFhirLocations(); const isEdit = Boolean(service?.id); const [categoryCode, setCategoryCode] = useState(initialCategoryCode ?? servicePracticeCategoryOf(service) ?? ''); const [query, setQuery] = useState(''); const [selectedConcept, setSelectedConcept] = useState(() => { const coding = service?.type?.[0]?.coding?.[0]; return coding?.code ? { system: coding.system ?? '', code: coding.code, display: coding.display ?? service?.name ?? coding.code } : undefined; }); const [name, setName] = useState(() => service?.name ?? ''); const [locationIds, setLocationIds] = useState(() => (service?.location ?? []) .map((ref) => ref.reference?.split('/').pop()) .filter((id): id is string => Boolean(id)), ); const [active, setActive] = useState(() => service?.active !== false); const [selfBookable, setSelfBookable] = useState(() => getServiceSelfBookableDefault(service)); const [saving, setSaving] = useState(false); const [error, setError] = useState(); const [confirmingDelete, setConfirmingDelete] = useState(false); const [deleteUnconfirmed, setDeleteUnconfirmed] = useState(false); const [checkWords, setCheckWords] = useState(); // Per-service duration override. '' = inherit the org default; a number string = a quick-pick; 'custom' reveals // a free minutes input (uncapped - e.g. a multi-day hospital stay). const [durationValue, setDurationValue] = useState(() => { const existing = getServiceDefaultDurationMinutes(service); if (!existing) return ''; return DURATION_QUICK_PICK_MINUTES.includes(existing) ? String(existing) : 'custom'; }); const [customDurationMinutes, setCustomDurationMinutes] = useState(() => { const existing = getServiceDefaultDurationMinutes(service); return existing && !DURATION_QUICK_PICK_MINUTES.includes(existing) ? String(existing) : ''; }); // Optional list price, as dollars text (e.g. "49.99"); blank = unpriced. const [priceInput, setPriceInput] = useState(() => { const cents = getServicePriceCents(service); return cents === undefined ? '' : (cents / 100).toFixed(2); }); // Whether this service may be delivered by video at all. Absent resolves to false, and the default // is deliberately OFF - you can do a consultation by video and you cannot perform surgery by video, // and nothing about a HealthcareService says which it is. const [telehealthEligible, setTelehealthEligible] = useState(() => getServiceTelehealthEligibleDefault(service), ); // Optional cap on how many upcoming appointments a patient may hold for this service; blank = no cap. const [futureLimitInput, setFutureLimitInput] = useState(() => { const limit = getServiceFutureAppointmentLimit(service); return limit === undefined ? '' : String(limit); }); const selectedCategory = useMemo( () => categories.find((category) => category.code === categoryCode), [categories, categoryCode], ); // A new service defaults to the first practice type; an edit keeps what it had, even no category. useEffect(() => { if (!isEdit && !categoryCode && scopedCategories.length > 0) { setCategoryCode(scopedCategories[0].code); } }, [isEdit, scopedCategories, categoryCode]); // The same scoped search the full service editor uses; it rests while a concept is chosen. const { results, searching } = useScopedServiceTypeSearch(selectedCategory, query, Boolean(selectedConcept)); /* * THE NAME IS SEEDED FROM THE CONCEPT, NOT OWNED BY IT, and the difference is the whole point of the * field. Its own help text says "What your practice calls it - patients and staff see this label", so * a practice offering a first consultation and a follow-up that share one SNOMED concept has to be * able to tell them apart. Overwriting a name they already typed takes that away silently, and it is * what happened: picking a type unconditionally assigned the concept's display, so a service saved as * "Walk945 Index Check" came back as "Consultation", indistinguishable from an existing row on the * list the booking funnel reads from. * * SO THE SEED ONLY APPLIES TO A NAME THE PRACTICE HAS NOT WRITTEN. An empty field takes the display - * which is the convenience this was for - and so does one still holding the PREVIOUS concept's * display, because that text was ours rather than theirs and leaving it stale would be worse. Anything * else is theirs and is left alone. */ const nameIsOursToReplace = useCallback( () => name.trim() === '' || name === selectedConcept?.display, [name, selectedConcept], ); const resetTypeSelection = useCallback(() => { // Clearing the type must not destroy a name the practice typed either - the usual reason to clear // it is to look for a better-fitting concept for the service they have already named. if (nameIsOursToReplace()) setName(''); setSelectedConcept(undefined); setQuery(''); }, [nameIsOursToReplace]); const pickConcept = (concept: IValueSet) => { if (nameIsOursToReplace()) setName(concept.display); setSelectedConcept(concept); }; const locationOptions = useMemo( () => onlySites(locationsList) .filter((location) => location.id) .map((location) => ({ id: location.id as string, label: location.name || (location.id as string) })), [locationsList], ); const handleSave = async () => { const organization = wpOrganization.current; if (!organization?.id) { setError('Organization information is missing. Please try again.'); return; } if (!selectedConcept) { setError('Search and select a service type.'); return; } if (!name.trim()) { setError('Enter a service name.'); 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; } // Word for word the other editor's rule, because a value one editor stores and the other refuses // is two screens disagreeing about the same setting. Zero is refused rather than coerced: it // would be a closure, not a cap, and `getServiceFutureAppointmentLimit` ignores it anyway. 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; } setSaving(true); setError(undefined); try { const selectedLocations = locationIds .map((id) => locationsList.find((location) => location.id === id)) .filter((location): location is Location => Boolean(location)) .map((location) => ({ reference: `Location/${location.id}`, display: location.name })); // The interoperable key: a SNOMED concept. `text` carries the practice's friendly label. const typeConcept = { coding: [ { system: SNOMED_SYSTEM, code: selectedConcept.code, display: selectedConcept.display }, ], text: name.trim(), }; // Edit preserves every other field on the existing service (photo, category, insurances,...) and only // overrides the slim fields; create builds a fresh minimal-but-valid HealthcareService. // A changed practice category swaps only the taxonomy standard coding; unrelated codings stay. const withCategoryChange = (edited: HealthcareService): HealthcareService => categoryCode === (servicePracticeCategoryOf(service) ?? '') ? edited : replaceStandardCategory(edited, categories, selectedCategory?.standardCategory); const healthcareService: HealthcareService = isEdit && service ? withCategoryChange({ ...service, active, name: name.trim(), type: [typeConcept] }) : { resourceType: 'HealthcareService', active, name: name.trim(), providedBy: { reference: `Organization/${organization.id}`, display: organization.name, }, type: [typeConcept], // Persist a STANDARD category coding only when the taxonomy supplies one - never our slug (§5). ...(selectedCategory?.standardCategory ? { category: [{ coding: [selectedCategory.standardCategory] }] } : {}), }; // Location: set the chosen sites, or "all locations" (omit - HealthLake rejects empty arrays). if (selectedLocations.length > 0) { healthcareService.location = selectedLocations; } else { delete healthcareService.location; } // Per-service duration override: set the chosen length, else inherit the org default (drop the ext). const durationMinutes = durationValue === '' ? undefined : durationValue === 'custom' ? parseInt(customDurationMinutes, 10) || undefined : parseInt(durationValue, 10); const baseExtensions = (healthcareService.extension ?? []).filter( (e) => e.url !== SERVICE_DEFAULT_DURATION_EXTENSION_URL && e.url !== SERVICE_PATIENT_SELF_BOOKABLE_EXTENSION_URL, ); const nextExtensions = [...baseExtensions]; if (durationMinutes && durationMinutes > 0) { nextExtensions.push({ url: SERVICE_DEFAULT_DURATION_EXTENSION_URL, valueInteger: durationMinutes }); } // Always persist the org self-bookable default explicitly (absent defaults to true, but storing it // keeps the org's intent unambiguous). nextExtensions.push({ url: SERVICE_PATIENT_SELF_BOOKABLE_EXTENSION_URL, valueBoolean: selfBookable }); if (nextExtensions.length > 0) { healthcareService.extension = nextExtensions; } else { delete healthcareService.extension; } // Applied last, on the fully-built service, so it only strips/replaces its own // extension and preserves the duration/self-bookable extensions set above. const pricedHealthcareService = applyServicePrice(healthcareService, priceCents); // Same contract as the price above and applied the same way round: it strips and replaces // only its own extension, so the duration and self-bookable ones set earlier survive. const cappedHealthcareService = applyServiceFutureAppointmentLimit( pricedHealthcareService, futureLimit, ); // Last again, and for the third time the same reason: it replaces only its own extension, so // the duration, self-bookable, price and cap set above all survive. const videoHealthcareService = applyServiceTelehealthEligible( cappedHealthcareService, telehealthEligible, ); const categorisedService = applyServicePracticeCategory(videoHealthcareService, categoryCode || undefined); const saved = isEdit ? await updateHealthcareService(categorisedService) : await createHealthcareService(categorisedService); setHealthcareServicesList((current) => [ ...current.filter((existing) => existing.id !== saved.id), saved, ]); toast.success(isEdit ? 'Service updated.' : 'Service added.', { position: 'top-right' }); onSaved?.(saved); onRequestClose(); } catch (saveError) { setError(relayServerFailure(saveError, 'Failed to save service. Please try again.')); } finally { setSaving(false); } }; const handleDelete = async () => { if (!service?.id) return; setSaving(true); setError(undefined); try { await deleteHealthcareService(service); setHealthcareServicesList((current) => current.filter((existing) => existing.id !== service.id)); toast.success('Service deleted.', { position: 'top-right' }); onDeleted?.(); onRequestClose(); } catch (deleteError) { setSaving(false); if (isDeleteUnconfirmed?.(service)) { setConfirmingDelete(false); setDeleteUnconfirmed(true); setCheckWords(undefined); return; } setError(relayServerFailure(deleteError, 'Failed to delete service. Please try again.')); } }; // Check: the exact read. Neither answer ends the earlier request, so the service stays off booking either way. const handleCheckDelete = async () => { if (!service?.id || !checkHealthcareService) return; setSaving(true); try { const found = await checkHealthcareService(service.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 { setSaving(false); } }; return ( {deleteUnconfirmed && ( <> Deleting “{name.trim() || 'this service'}” could not be confirmed. It is not offered for booking until this is settled. {checkWords && <> {checkWords}} )} {(error || categoriesError) && ( <> {error || categoriesError} )} ({ label: category.display, value: category.code })), ]} onChange={(value) => { setCategoryCode(value); resetTypeSelection(); }} />

Showing categories for your practice types. To add a practice type, open Identity on the Practice Dashboard.

{selectedConcept ? (
{selectedConcept.display}
SNOMED {selectedConcept.code}
) : ( <> {selectedCategory && query.trim().length > 0 && query.trim().length < MIN_SNOMED_SEARCH_CHARS && (
Type at least {MIN_SNOMED_SEARCH_CHARS} characters to search.
)} {searching && (
Searching…
)} {!searching && results.length > 0 && (
    {results.map((result) => (
  • ))}
)} )} locationOptions.find((option) => option.id === id)?.label ?? id, )} suggestions={locationOptions.map((option) => option.label)} onChange={(tokens) => { const ids = (tokens as string[]) .map((label) => locationOptions.find((option) => option.label === label)?.id) .filter((id): id is string => Boolean(id)); setLocationIds(ids); }} __experimentalExpandOnFocus /> {/* Informational, NOT a warning — leaving this empty is a normal, valid choice for a single-site practice. Only shown when there is more than one location to widen to. */} {locationIds.length === 0 && locationOptions.length > 1 && (

{widenerCopy('WIDENED_ALL_LOCATIONS', { orgCatalogSize: 0, orgLocationCount: locationOptions.length, })}

)} ({ label: formatDurationLabel(minutes), value: String(minutes), })), { label: 'Custom…', value: 'custom' }, ]} onChange={setDurationValue} /> {durationValue === 'custom' && ( <> )} setPriceInput(value ?? '')} disabled={saving} /> {/* THE SETTING EXISTED AND THIS EDITOR DID NOT OFFER IT. Two editors write one HealthcareService: this modal, which is the only one an administrator can click to, and the healthcare-services route, which has the control but nothing links to it. So the cap was a setting nobody could reach, and the wording below is the other editor's word for word - one setting described two ways is how two screens come to mean different things by the same field. */} {/* THE THIRD TIER THE POLICY COPY ALREADY PROMISES, and it had no control anywhere. The appointment policy tells the administrator that "each service and schedule still has to allow video separately". The practice tier is a real box and the schedule tier is a real block, and the service tier was an extension that four call sites read, one writer could set only at the practitioner level, and no screen showed. It was not merely unsettable. At the practice this was found on it was already SET, on one service out of seven, and it is what decides whether a patient sees a location to book at - so four services read as Active, self-bookable and correct on every field this editor showed, and dead-ended the patient three steps later. THE HELP SAYS WHAT IT DOES, not just what it is. "Off" here is not a quiet default: it strips the virtual location from this service's options, which is correct behaviour and is the reason a video-only schedule leaves such a service bookable nowhere. */} setFutureLimitInput(value ?? '')} disabled={saving} /> {confirmingDelete ? ( <> Delete “{name.trim() || 'this service'}”? This can't be undone. {/* This Cancel STAYS: it exits the inline delete-confirmation state, not the Modal. The Modal's own X calls onRequestClose, which is a different action, so the redundancy rule does not apply. */} ) : ( {isEdit && ( )} )}
); }; export default AddServiceModal;