diff --git a/public/locale/en.json b/public/locale/en.json index ead37516fa7..a3d0f4ec52d 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -1215,6 +1215,7 @@ "choose_state": "Choose State", "choose_your_login_method_to_continue": "Choose your login method to continue", "chosen_encounter": "Chosen Encounter", + "chosen_overlapping_slots_warning": "You've chosen overlapping slots. You can still book both if the patient is okay with it.", "chronic_condition_one": "Chronic Condition", "chronic_condition_other": "Chronic Conditions", "claim__add_item": "Add Item", @@ -1249,6 +1250,7 @@ "claim__use__claim": "Claim", "claim__use__preauthorization": "Pre Authorization", "claims": "Claims", + "clash_new_slot_overlaps": "The new slot selected with {{resourceName}} at {{time}} overlaps slightly.", "class": "Class", "class_history": "Class History", "classification": "Classification", @@ -1536,6 +1538,7 @@ "contexts": "Contexts", "continue": "Continue", "continue_and_clear": "Continue and Clear", + "continue_anyway": "Continue anyway", "continue_watching": "Continue watching", "contribute_github": "Contribute on Github", "copied_to_clipboard": "Copied to clipboard!", @@ -3568,6 +3571,7 @@ "moving_camera": "Moving Camera", "mrp": "MRP", "multi_invoice": "Multi Invoice", + "multiple_appointment_alert": "Multiple Appointment Alert", "multiple_users_linked_to_phone": "Multiple users linked to this phone number", "multiple_users_linked_to_phone_hint": "Enter your username so we can reset the correct account.", "must_be_greater_than_value": "Must be greater than {{value}}", @@ -4242,6 +4246,7 @@ "patient__volunteer-contact": "Volunteer Contact", "patient_address": "Patient Address", "patient_age": "Age", + "patient_already_has_appointment_on": "This patient already has an appointment on", "patient_and_billing_details": "Patient and billing details", "patient_basics": "Patient Basics", "patient_birth_year_for_identity": "Please enter the patient's year of birth to verify their identity", @@ -4396,6 +4401,7 @@ "phone_number_validation_error": "Entered phone number is not valid", "phone_number_verified": "Phone Number Verified", "pick_a_date": "Pick a date", + "pick_another_slot": "Pick another slot", "pin": "PIN", "pin_page": "Pin/Add to Overview", "pin_page_already_pinned_description": "This page is already pinned to your dashboard. You can unpin it from the dashboard if you wish to pin a different page.", @@ -6140,6 +6146,7 @@ "time_of_death": "Time of death", "time_slot": "Time Slot", "time_slots_per_day": "Time slot per day", + "timing_clash_alert": "Timing clash alert!", "title": "Title", "title_is_required": "Title is required", "title_of_request": "Title of Request", @@ -6252,6 +6259,7 @@ "try_different_abha_linking_option": "Want to try a different linking option, here are some more:", "try_different_search": "Try a different search term", "try_different_search_terms": "Try different search terms", + "trying_to_book_another_slot_same_doctor": "You're trying to book another slot with the same doctor at:", "tube": "Tube", "two_factor_authentication": "Two Factor Authentication", "two_factor_authentication_active": "Two-factor authentication is currently active on your account.", @@ -6655,6 +6663,7 @@ "welcome_back": "Welcome back!", "welcome_back_to_hospital_dashboard": "Welcome back to the overview ", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", + "what_would_you_like_to_do": "What would you like to do?", "whatsapp_number": "Whatsapp Number", "whatsapp_number_same_as_phone_number": "WhatsApp number is same as phone number", "why_the_asset_is_not_working": "Why the asset is not working?", diff --git a/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx b/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx new file mode 100644 index 00000000000..1f50e5ca173 --- /dev/null +++ b/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx @@ -0,0 +1,168 @@ +import { format } from "date-fns"; +import { MessageCircleWarning, X } from "lucide-react"; +import { Trans, useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; + +import { ScheduleResourceIcon } from "@/components/Schedule/ScheduleResourceIcon"; +import { + Appointment, + formatScheduleResourceName, + SchedulableResourceType, + ScheduleResource, + TokenSlot, +} from "@/types/scheduling/schedule"; + +export type AppointmentConflictType = "duplicate" | "clash"; + +interface AppointmentConflictAlertProps { + type: AppointmentConflictType; + conflictingAppointment: Appointment; + newSlot: Pick; + newResource?: ScheduleResource; + onPickAnotherSlot: () => void; + onContinueAnyway: () => void; + onClose: () => void; +} + +export const AppointmentConflictAlert = ({ + type, + conflictingAppointment, + newSlot, + newResource, + onPickAnotherSlot, + onContinueAnyway, + onClose, +}: AppointmentConflictAlertProps) => { + const { t } = useTranslation(); + + const existingStart = new Date( + conflictingAppointment.token_slot.start_datetime, + ); + const newStart = new Date(newSlot.start_datetime); + + return ( +
+
+
+
+ +
+ + {type === "duplicate" + ? t("multiple_appointment_alert") + : t("timing_clash_alert")} + +
+ +
+ +
+ +

+ {t("patient_already_has_appointment_on")}{" "} + + {format(existingStart, "dd MMM yyyy")} ·{" "} + {format(existingStart, "hh:mm a")} + +

+ +
+
+ +
+ + {formatScheduleResourceName(conflictingAppointment)} + + {conflictingAppointment.resource_type === + SchedulableResourceType.Practitioner && ( + + {t(conflictingAppointment.resource.user_type)} + + )} +
+
+ {type === "duplicate" && ( + + {format(existingStart, "hh:mm a")} + + )} +
+ + {type === "duplicate" ? ( +

+ }} + />{" "} + + {format(newStart, "dd MMM yyyy")} · {format(newStart, "hh:mm a")} + +

+ ) : ( + <> +

+ }} + /> +

+ + {newResource && ( +
+ +
+ + {formatScheduleResourceName(newResource)} + + {newResource.resource_type === + SchedulableResourceType.Practitioner && ( + + {t(newResource.resource.user_type)} + + )} +
+
+ )} + + )} + +

+ {t("what_would_you_like_to_do")} +

+ + + + +
+ ); +}; diff --git a/src/pages/Appointments/BookAppointment/AppointmentSlotPicker.tsx b/src/pages/Appointments/BookAppointment/AppointmentSlotPicker.tsx index 3bf7d122cf5..fedcc23fe28 100644 --- a/src/pages/Appointments/BookAppointment/AppointmentSlotPicker.tsx +++ b/src/pages/Appointments/BookAppointment/AppointmentSlotPicker.tsx @@ -2,16 +2,34 @@ import { Appointment, GetSlotsForDayResponse, SchedulableResourceType, + ScheduleResource, TokenSlot, + UpcomingAppointmentStatuses, } from "@/types/scheduling/schedule"; -import { format, isWithinInterval } from "date-fns"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + areIntervalsOverlapping, + format, + isSameDay, + isWithinInterval, +} from "date-fns"; +import { Ref, useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import RadioInput from "@/components/ui/RadioInput"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import useBreakpoints from "@/hooks/useBreakpoints"; import { cn } from "@/lib/utils"; +import { + AppointmentConflictAlert, + AppointmentConflictType, +} from "@/pages/Appointments/BookAppointment/AppointmentConflictAlert"; import { getUniqueSchedulesFromSlots, groupSlotsByAvailability, @@ -32,6 +50,15 @@ interface AppointmentSlotPickerProps { currentAppointment?: Appointment; selectedDate: Date; resourceType: SchedulableResourceType; + patientId?: string; + newResource?: ScheduleResource; + onConflictAcknowledged?: () => void; +} + +interface ConflictAlertState { + slotId: string; + type: AppointmentConflictType; + appointment: Appointment; } export function AppointmentSlotPicker({ @@ -43,8 +70,16 @@ export function AppointmentSlotPicker({ currentAppointment, selectedDate, resourceType, + patientId, + newResource, + onConflictAcknowledged, }: AppointmentSlotPickerProps) { const { t } = useTranslation(); + const isMobile = useBreakpoints({ default: true, sm: false }); + + const [conflictAlert, setConflictAlert] = useState( + null, + ); const slotsQuery = useQuery({ queryKey: ["slots", facilityId, resourceId, dateQueryString(selectedDate)], @@ -67,6 +102,77 @@ export function AppointmentSlotPicker({ }, }); + // Fetch the patient's other active appointments to check for + // duplicate/clash conflicts when a slot is clicked. + const patientAppointmentsQuery = useQuery({ + queryKey: [ + "patient-active-appointments-for-conflict-check", + facilityId, + patientId, + ], + queryFn: query(scheduleApi.appointments.getAppointments, { + pathParams: { patientId: patientId ?? "" }, + queryParams: { + facility: facilityId, + status: UpcomingAppointmentStatuses.join(","), + limit: 100, + }, + }), + enabled: !!patientId, + }); + + const checkForConflict = useCallback( + ( + slot: Pick, + ): { type: AppointmentConflictType; appointment: Appointment } | null => { + const appointments = ( + patientAppointmentsQuery.data?.results ?? [] + ).filter((appointment) => appointment.id !== currentAppointment?.id); + + const newSlotInterval = { + start: new Date(slot.start_datetime), + end: new Date(slot.end_datetime), + }; + + const duplicate = appointments.find( + (appointment) => + appointment.resource_type === resourceType && + appointment.resource.id === resourceId && + isSameDay( + new Date(appointment.token_slot.start_datetime), + newSlotInterval.start, + ), + ); + if (duplicate) { + return { type: "duplicate", appointment: duplicate }; + } + + const clash = appointments.find((appointment) => { + if ( + appointment.resource_type !== SchedulableResourceType.Practitioner || + appointment.resource.id === resourceId + ) { + return false; + } + return areIntervalsOverlapping(newSlotInterval, { + start: new Date(appointment.token_slot.start_datetime), + end: new Date(appointment.token_slot.end_datetime), + }); + }); + if (clash) { + return { type: "clash", appointment: clash }; + } + + return null; + }, + [ + patientAppointmentsQuery.data, + currentAppointment, + resourceType, + resourceId, + ], + ); + // Update slot details when a slot is selected const handleSlotSelect = useCallback( (slotId: string | undefined) => { @@ -83,6 +189,28 @@ export function AppointmentSlotPicker({ [onSlotSelect, onSlotDetailsChange, slotsQuery.data], ); + // Runs the duplicate/clash check the moment a slot is clicked by the user. + const handleSlotClick = useCallback( + (slot: Pick) => { + const isDeselecting = selectedSlotId === slot.id; + handleSlotSelect(isDeselecting ? undefined : slot.id); + + if (isDeselecting || !patientId) { + setConflictAlert(null); + return; + } + + const conflict = checkForConflict(slot); + setConflictAlert(conflict ? { slotId: slot.id, ...conflict } : null); + }, + [selectedSlotId, handleSlotSelect, patientId, checkForConflict], + ); + + // Clear any stale conflict alert when the resource or date changes. + useEffect(() => { + setConflictAlert(null); + }, [resourceId, resourceType, selectedDate]); + const { slotGroups, availableSlots, uniqueSchedules } = useMemo(() => { const allSlots = slotsQuery.data || []; const uniqueSchedules = getUniqueSchedulesFromSlots(allSlots); @@ -228,19 +356,89 @@ export function AppointmentSlotPicker({
- {slots.map((slot) => ( - { - handleSlotSelect( - selectedSlotId === slot.id ? undefined : slot.id, - ); - }} - /> - ))} + {slots.map((slot) => { + const button = ( + handleSlotClick(slot)} + /> + ); + + if (conflictAlert?.slotId !== slot.id) { + return button; + } + + // Dismissing the alert (via the close button, Escape, or + // an outside click) must be treated the same as explicitly + // picking another slot: the conflicting slot is deselected + // rather than left silently selected without + // acknowledgment. + const dismissConflict = () => { + setConflictAlert(null); + handleSlotSelect(undefined); + }; + + const conflictAlertContent = ( + { + setConflictAlert(null); + onConflictAcknowledged?.(); + }} + /> + ); + + if (isMobile) { + return ( +
+ {button} + { + if (!open) dismissConflict(); + }} + > + + + {conflictAlert.type === "duplicate" + ? t("multiple_appointment_alert") + : t("timing_clash_alert")} + + {conflictAlertContent} + + +
+ ); + } + + return ( + { + if (!open) dismissConflict(); + }} + > + {button} + + {conflictAlertContent} + + + ); + })}
@@ -257,12 +455,14 @@ export const TokenSlotButton = ({ selectedSlotId, onClick, className, + ref, }: { slot: Omit; availability: TokenSlot["availability"]; selectedSlotId: string | undefined; onClick: () => void; className?: string; + ref?: Ref; }) => { const { t } = useTranslation(); @@ -275,6 +475,7 @@ export const TokenSlotButton = ({ return ( - +
+ {hasOverlapAcknowledged && ( +

+ {t("chosen_overlapping_slots_warning")} +

+ )} +
+ + +
)}