import React, { useState, useEffect, useMemo } from 'react'; import { TextareaControl } from "../ui/textarea-control"; import { Card, CardHeader, CardBody, CardDivider } from '../ui/wp-card'; import { Elevation } from '../ui/elevation'; import { InputControl } from '../ui/input-control'; import { Divider } from '../ui/divider'; import { DismissibleAlert } from '../ui/dismissible-alert'; import { Heading } from '../ui/heading'; import { Flex, FlexItem, FlexBlock } from '../ui/flex'; import { Spacer } from '../ui/spacer'; import { TabPanel } from '../ui/tab-panel'; import { useFormContext, Controller, useFieldArray, useWatch } from 'react-hook-form'; import { IValueSet, useValueSetServiceContext } from '../../services'; import { usePracticeTypeOptions } from '../../config/serviceCategories'; import { requiredText } from '../../utils/requiredText'; import { FhirAddress, FhirCode, FhirCodeableConcept, FhirContactPoint, FhirIdentifier, FhirOperationalHours, FhirReference } from '../../components'; import { useFhirLocationBroadcast, useFhirLocations } from '../../hooks'; import { PlusCircle, XCircle } from 'lucide-react'; import { onlySites, supportedPhysicalTypeOptions } from '../../utils/roomLocation'; // - WordPress's ToggleControl, swapped for the ui/* adapter. Same props, same JSX; // only `__nextHasNoMarginBottom` is dropped, which means nothing outside WordPress. import { ToggleControl } from '../ui/toggle-control'; import AddressTimeZoneSync from './AddressTimeZoneSync'; interface FhirLocationProps { elevated?: boolean; disabled?: boolean; enableInlineActions?: boolean; orgPracticeTypeCodes?: string[]; } export const FhirLocation: React.FC = ({ elevated = true, disabled = false, enableInlineActions = true, orgPracticeTypeCodes }) => { const { control, watch, getValues } = useFormContext(); const { fetchValueSet, fetchTimezoneOptions, valueSetUrls } = useValueSetServiceContext(); const [loading, setLoading] = useState(false); // value sets const [locationStatus, setLocationStatus] = useState([]); const [locationPhysicalType, setLocationPhysicalType] = useState([]); // The site's own clock. `http://hl7.org/fhir/ValueSet/timezones` is the IANA tz database, which is // exactly what the booking conversion needs (see utils/practiceTimezone.ts). const [timezones, setTimezones] = useState([]); const practiceTypeOptions = usePracticeTypeOptions(); const orderedPracticeTypeOptions = useMemo((): IValueSet[] => { const asValueSets: IValueSet[] = practiceTypeOptions; if (!orgPracticeTypeCodes?.length) return asValueSets; const orgSet = new Set(orgPracticeTypeCodes); return [ ...asValueSets.filter((v) => orgSet.has(v.code)), ...asValueSets.filter((v) => !orgSet.has(v.code)), ]; }, [orgPracticeTypeCodes, practiceTypeOptions]); const spacerMargin = '24px'; useFhirLocationBroadcast(); const { locationsList } = useFhirLocations(); useWatch({ control, name: 'alias' }); const isPartOf = watch('isPartOf'); const aliasFieldArray = useFieldArray({ control: control, name: 'alias' }); const addressFieldArray = useFieldArray({ control: control, name: 'address' }); const telecomFieldArray = useFieldArray({ control: control, name: 'telecom' }); const hoursOfOperationFieldArray = useFieldArray({ control: control, name: 'hoursOfOperation' }); const identifierFieldArray = useFieldArray({ control: control, name: 'identifier' }); useEffect(() => { const loadValueSets = async () => { setLoading(true); try { const [locationStatusValueSet, locationPhysicalTypeValueSet, timezonesValueSet] = await Promise.all([ fetchValueSet(valueSetUrls.locationStatus), fetchValueSet(valueSetUrls.locationPhysicalType), fetchTimezoneOptions(), ]); setLocationStatus(locationStatusValueSet); // NARROWED TO WHAT THE PRODUCT MODELS. The full HL7 `location-physical-type` value set // has fifteen codes and this dropdown offered all of them, while the app understands three // - so an admin naming their clinic a "Building" produced a Location that appeared in no // section of the Locations page and in no picker. Measured on staging: 5 of 9 real // Locations were in that state. `utils/roomLocation.ts` for the whole story. setLocationPhysicalType(supportedPhysicalTypeOptions(locationPhysicalTypeValueSet)); setTimezones(timezonesValueSet); } catch (err) { console.error('Error loading value sets:', err); } finally { setLoading(false); } }; loadValueSets(); }, [fetchValueSet, fetchTimezoneOptions, valueSetUrls]); const canAddAlias = () => { const aliases = getValues('alias'); const lastAlias = aliases[aliases.length - 1]; return lastAlias.trim() !== ''; }; const addAlias = () => { if (canAddAlias()) { aliasFieldArray.append(''); } }; const removeAlias = (index: number) => { aliasFieldArray.remove(index); }; return ( {elevated && } Location Information {/* name */} ( <> {fieldState.error && ( {/* The rule's own sentence when it has one. A name of only spaces answered "Location name is required" beside a box the user can see something in, which reads as our bug. */} {fieldState.error.message || 'Location name is required'} )} )}/> {/* status */} {/* 🕐 timezone — the clock this site's opening hours are written in. A schedule's hours are FHIR `time` values ("09:00") with no zone, so something has to say WHICH 09:00. Before this the answer was "whatever zone the person looking happens to be in", which booked a west-coast patient into an east-coast practice three hours off — see utils/practiceTimezone.ts. */} {/* 🕐 NOW REQUIRED, and `clearable` is gone with the optionality it existed for. Spencer ruled the model on 2026-09-06: store UTC, apply the zone at the edges. Every conversion between an instant and a practice's wall clock goes through THIS value, so a site without one has no answer to "what time is 2:00 PM here" — and the booking dialogs now refuse rather than falling back to the viewer's clock, which is what they used to do and what stored the wrong moment. ⚠️ THE ORGANIZATION FALLBACK IS NOT REMOVED and still serves an org-wide schedule block, which names no site. What goes is a SITE silently inheriting it: a practice with three locations now says the zone three times, and in exchange nobody has to reason about which of two resources answered. ⛔ The earlier reasoning (card 655) was that a practice must be able to take a zone back off. That is exactly what may no longer happen, so the control that allowed it is gone rather than left as a no-op — on a required field a selectable "unset" is card 241's defect. 🔻 There is no backfill: existing sites carry no zone and will fail their next save until one is chosen, which is intended and is safe because the datastore is wiped before launch. */} {/* TYPE-AHEAD, AND IT IS THE CONTROL ALREADY IN THIS FORM. The state field two inches above is a combobox - type "Iowa", get "Iowa (IA)" - and this was a plain select of 420 options, so one dialog behaved two ways. WHAT IT COST, measured in the dialog: the list is ordered by string LENGTH, then alphabetically within a length - "UTC" (3), then every 8-character name, then every 9. Nobody chose that; it is what you get sorting identifiers with no comparator. The first America/ entry was the 48th and America/Chicago the 204th of 420, so every US practice scrolled past Asia and Africa on a REQUIRED step, four screens into their first day. AND A WRONG PICK HERE IS NOT COSMETIC. This is the clock the site's opening hours, every appointment and every reminder are rendered against, so somebody who gives up scrolling and takes something close enough gets a practice whose times are an hour out - and the symptom shows up much later and somewhere else. The stored value is still the IANA identifier; `sortedTimezones` only changes the order they are offered in. */} {!disabled && } {/* Aliases */} Aliases {/* alias */} {aliasFieldArray.fields.map((field, index) => ( ( 1 ? index + 1 : ''}`}/> )}/> {index === aliasFieldArray.fields.length - 1 && ( )} {aliasFieldArray.fields.length > 1 && ( )} ))} {/* description */} ( )}/> {/* isPartOf */} ( )}/> {/* partOf */} {isPartOf && ( <> )} {(tab) => { // type if (tab.name === 'type') { return (
{/* type */} {/* physicalType */}
); } // address if (tab.name === 'address') { return (
{/* address */}
); } // contact if (tab.name === 'contact') { return (
{/* telecom */}
); } // hours if (tab.name === 'hours') { return (
{/* WHAT THESE HOURS ARE FOR, said on the screen where somebody types them. There are two places to set hours - here and on each practitioner's schedule block - and only the schedule decides what a patient can book. Measured: nothing in the booking chain reads a location's hours at all; they are rendered on the locations list, the reference display and history, and read by nothing else. An admin who sets 9 to 5 here believes they have said when the practice is open, and they have - but not when it can be booked. The first surprise is otherwise a patient taking a slot the practice thought was closed, or no slots at all, against a screen that looks complete. */} These hours describe when this location is open. They do not decide when appointments can be booked — that comes from each practitioner's schedule at this location, which is also where a daily break such as lunch is set.
); } // identifiers if (tab.name === 'identifiers') { return (
{/* identifier */}
); } }}
); }; export default FhirLocation;