From a80f166d63a8c84f3580bbdb6f23d46169d2c001 Mon Sep 17 00:00:00 2001 From: Veenu Punyani Date: Fri, 31 Jul 2026 16:16:48 -0700 Subject: [PATCH] Show location availability status on the locations step. also sort by available first and then disabled or no appointments --- .../app/api/service-locations.ts | 41 +++++++++--- appointment-booking/app/api/services.ts | 4 ++ .../app/components/BookingDetailCallout.tsx | 9 +++ .../app/components/LocationsTable.tsx | 5 +- .../app/routes/service-locations.tsx | 63 ++++++++++++++++--- 5 files changed, 106 insertions(+), 16 deletions(-) diff --git a/appointment-booking/app/api/service-locations.ts b/appointment-booking/app/api/service-locations.ts index 4c793e099..9d92d1f4c 100644 --- a/appointment-booking/app/api/service-locations.ts +++ b/appointment-booking/app/api/service-locations.ts @@ -1,6 +1,7 @@ import { getApiBaseUrl } from '../runtime-config' -// Bookable office for a selected service. nextAppointmentDate is for a later UI step. +// Mapped office for the booking locations step. +// appointmentsDisabled / isBookable are derived — the API does not send them. export type ServiceLocation = { id: number name: string @@ -9,9 +10,10 @@ export type ServiceLocation = { longitude: number | null appointmentMessage: string nextAppointmentDate: string | null + appointmentsDisabled: boolean + isBookable: boolean } -// API response row from GET /api/v1/offices?service_id=. type ApiOffice = { office_id: number office_name: string @@ -29,7 +31,24 @@ type ApiOfficesResponse = { offices: ApiOffice[] } -// Fetch bookable offices for a selected service. +// API sends "Status.SHOW" or "SHOW". HIDE (and unknown) are omitted from the list. +function readOnlineStatus(value: string | null): 'SHOW' | 'DISABLE' | null { + if (!value) { + return null + } + if (value === 'HIDE' || value.endsWith('.HIDE')) { + return null + } + if (value === 'SHOW' || value.endsWith('.SHOW')) { + return 'SHOW' + } + if (value === 'DISABLE' || value.endsWith('.DISABLE')) { + return 'DISABLE' + } + return null +} + +// Fetch offices for a selected service. Keeps SHOW and DISABLE; omits HIDE/deleted. // Throws on fetch/HTTP/parse failure so the page can tell error apart from empty []. export async function getServiceLocations(serviceId: number): Promise { const url = `${await getApiBaseUrl()}/offices?service_id=${serviceId}` @@ -48,12 +67,18 @@ export async function getServiceLocations(serviceId: number): Promise ) : null} + {selectedLocation?.appointmentsDisabled === true ? ( + + Appointments are not available at this location. Please select another location. + + ) : selectedLocation?.isBookable === false ? ( + + No appointments available. Select another location, or visit for walk-in service. + + ) : null} {selectedLocation?.appointmentMessage ? ( {selectedLocation.appointmentMessage} diff --git a/appointment-booking/app/components/LocationsTable.tsx b/appointment-booking/app/components/LocationsTable.tsx index 90cf38b32..93133d01b 100644 --- a/appointment-booking/app/components/LocationsTable.tsx +++ b/appointment-booking/app/components/LocationsTable.tsx @@ -70,7 +70,10 @@ export function LocationsTable({ const address = location.address || '—' return ( - + ([]) const [isLoading, setIsLoading] = useState(() => !!selectedService) const [loadError, setLoadError] = useState(false) @@ -81,32 +80,80 @@ export default function ServiceLocationsPage() { } }, [serviceId]) + // Knowledge-test bookings omit SHOW offices with no DLKT capacity / no slots. + // Disabled offices stay visible with an availability message. + const locationsForService = useMemo(() => { + if (!selectedService?.isDlkt) { + return locations + } + return locations.filter( + (location) => location.appointmentsDisabled || location.nextAppointmentDate !== null, + ) + }, [locations, selectedService?.isDlkt]) + + // Keep the booking selection aligned with the latest mapped availability for this service. + useEffect(() => { + if (isLoading || loadError || !selectedLocation) { + return + } + + const match = locationsForService.find((location) => location.id === selectedLocation.id) + if (!match) { + setSelectedLocation(null) + return + } + + if ( + match.isBookable !== selectedLocation.isBookable || + match.appointmentsDisabled !== selectedLocation.appointmentsDisabled || + match.nextAppointmentDate !== selectedLocation.nextAppointmentDate + ) { + setSelectedLocation(match) + } + }, [isLoading, loadError, locationsForService, selectedLocation, setSelectedLocation]) + const visibleLocations = useMemo(() => { const tokens = search.trim().toLowerCase().split(/\s+/).filter(Boolean) // Copy before sort — Array.sort mutates in place. const results = tokens.length - ? locations.filter((location) => { + ? locationsForService.filter((location) => { const haystack = `${location.name} ${location.address}`.toLowerCase() return tokens.every((token) => haystack.includes(token)) }) - : [...locations] + : [...locationsForService] + + // Bookable locations first; then name or nearest order within each group. + const byBookable = (a: ServiceLocation, b: ServiceLocation) => + Number(b.isBookable) - Number(a.isBookable) if (nearestSort && userLocation) { - results.sort((a, b) => kmFromUser(userLocation, a) - kmFromUser(userLocation, b)) + results.sort((a, b) => { + const bookableOrder = byBookable(a, b) + if (bookableOrder !== 0) { + return bookableOrder + } + return kmFromUser(userLocation, a) - kmFromUser(userLocation, b) + }) return results } results.sort((a, b) => { + const bookableOrder = byBookable(a, b) + if (bookableOrder !== 0) { + return bookableOrder + } const comparison = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) return sortDirection === 'asc' ? comparison : -comparison }) return results - }, [locations, search, sortDirection, nearestSort, userLocation]) + }, [locationsForService, search, sortDirection, nearestSort, userLocation]) - const canContinue = !!selectedService && !!selectedLocation + // Only a bookable location unlocks the next step; unavailable rows stay selectable for info. + const canContinue = !!selectedService && !!selectedLocation?.isBookable const showLoadError = !isLoading && loadError - const showUnavailable = !isLoading && !loadError && locations.length === 0 - const showNoResults = !isLoading && locations.length > 0 && visibleLocations.length === 0 + const showUnavailable = !isLoading && !loadError && locationsForService.length === 0 + const showNoResults = + !isLoading && locationsForService.length > 0 && visibleLocations.length === 0 return ( <>