From 32186b92dfaaf0f08f94c566feec20e73052da06 Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Tue, 21 Jul 2026 19:54:20 +0530 Subject: [PATCH 1/2] Added popup to indicate appointment clash/duplication --- public/locale/en.json | 10 + .../AppointmentConflictAlert.tsx | 167 +++++++++++++ .../BookAppointment/AppointmentSlotPicker.tsx | 220 ++++++++++++++++-- .../BookAppointmentDetails.tsx | 75 ++++-- 4 files changed, 435 insertions(+), 37 deletions(-) create mode 100644 src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx diff --git a/public/locale/en.json b/public/locale/en.json index 7a97ce914fa..78853c22dcd 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -1201,6 +1201,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", @@ -1235,6 +1236,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", @@ -1522,6 +1524,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!", @@ -3552,6 +3555,7 @@ "moving_camera": "Moving Camera", "mrp": "MRP", "multi_invoice": "Multi Invoice", + "multiple_appointment_alert": "Multiple Appointment Alert", "must_be_greater_than_value": "Must be greater than {{value}}", "my_dept": "My Dept.", "my_doctors": "My Doctors", @@ -4223,6 +4227,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", @@ -4377,6 +4382,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.", @@ -6120,6 +6126,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", @@ -6232,6 +6239,8 @@ "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:", + "trying_to_book_overlapping_slot": "You're trying to book a slot that overlaps with this appointment at:", "tube": "Tube", "two_factor_authentication": "Two Factor Authentication", "two_factor_authentication_active": "Two-factor authentication is currently active on your account.", @@ -6635,6 +6644,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..20a74abed58 --- /dev/null +++ b/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx @@ -0,0 +1,167 @@ +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..bb1f1e6c12c 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,73 @@ 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", 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 +185,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 +352,82 @@ 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; + } + + const conflictAlertContent = ( + setConflictAlert(null)} + onPickAnotherSlot={() => { + setConflictAlert(null); + handleSlotSelect(undefined); + }} + onContinueAnyway={() => { + setConflictAlert(null); + onConflictAcknowledged?.(); + }} + /> + ); + + if (isMobile) { + return ( +
+ {button} + { + if (!open) setConflictAlert(null); + }} + > + + + {conflictAlert.type === "duplicate" + ? t("multiple_appointment_alert") + : t("timing_clash_alert")} + + {conflictAlertContent} + + +
+ ); + } + + return ( + { + if (!open) setConflictAlert(null); + }} + > + {button} + + {conflictAlertContent} + + + ); + })}
@@ -257,12 +444,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 +464,7 @@ export const TokenSlotButton = ({ return ( - +
+ {hasOverlapAcknowledged && ( +

+ {t("chosen_overlapping_slots_warning")} +

+ )} +
+ + +
)} From d9997a0d5e9ad454a0803ebc59734e81cab1a56f Mon Sep 17 00:00:00 2001 From: Shivank Kacker Date: Tue, 21 Jul 2026 20:40:11 +0530 Subject: [PATCH 2/2] fixes --- public/locale/en.json | 1 - .../AppointmentConflictAlert.tsx | 1 + .../BookAppointment/AppointmentSlotPicker.tsx | 27 +++++++++++++------ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/public/locale/en.json b/public/locale/en.json index 78853c22dcd..98181090d6a 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -6240,7 +6240,6 @@ "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:", - "trying_to_book_overlapping_slot": "You're trying to book a slot that overlaps with this appointment at:", "tube": "Tube", "two_factor_authentication": "Two Factor Authentication", "two_factor_authentication_active": "Two-factor authentication is currently active on your account.", diff --git a/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx b/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx index 20a74abed58..1f50e5ca173 100644 --- a/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx +++ b/src/pages/Appointments/BookAppointment/AppointmentConflictAlert.tsx @@ -57,6 +57,7 @@ export const AppointmentConflictAlert = ({