diff --git a/public/locale/en.json b/public/locale/en.json index f7dfa3bb5b7..2d3c4b061c5 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -395,9 +395,12 @@ "app_settings": "App Settings", "apply": "Apply", "appointment_booking_success": "Your appointment has been successfully booked!", + "appointment_cancelled": "Appointment has been cancelled!", "appointment_created_success": "Appointment created successfully", "appointment_details": "Appointment Details", "appointment_not_found": "Appointment not found", + "appointment_rescheduled": "Appointment has been rescheduled!", + "appointment_rescheduled_successfully": "Appointment rescheduled successfully", "appointment_type": "Appointment Type", "appointments": "Appointments", "approve": "Approve", @@ -1746,6 +1749,9 @@ "required": "Required", "required_quantity": "Required Quantity", "reschedule": "Reschedule", + "reschedule_appointment": "Reschedule Appointment", + "rescheduled": "Rescheduled", + "rescheduling": "Rescheduling...", "resend_otp": "Resend OTP", "reset": "Reset", "reset_password": "Reset Password", diff --git a/src/pages/Appointments/AppointmentDetail.tsx b/src/pages/Appointments/AppointmentDetail.tsx index cfcec8c2bef..b9ca6168e9e 100644 --- a/src/pages/Appointments/AppointmentDetail.tsx +++ b/src/pages/Appointments/AppointmentDetail.tsx @@ -14,6 +14,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { differenceInYears, format, isSameDay } from "date-fns"; import { BanIcon, PrinterIcon } from "lucide-react"; import { navigate } from "raviger"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -43,6 +44,13 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; @@ -63,11 +71,14 @@ import { import { FacilityData } from "@/types/facility/facility"; import { Appointment, + AppointmentFinalStatuses, AppointmentStatuses, AppointmentUpdateRequest, } from "@/types/scheduling/schedule"; import scheduleApis from "@/types/scheduling/scheduleApis"; +import { AppointmentSlotPicker } from "./components/AppointmentSlotPicker"; + interface Props { facilityId: string; appointmentId: string; @@ -230,6 +241,7 @@ const AppointmentDetails = ({ fulfilled: "primary", entered_in_error: "destructive", cancelled: "destructive", + rescheduled: "secondary", noshow: "destructive", } as Partial< Record @@ -388,6 +400,8 @@ const AppointmentActions = ({ }: AppointmentActionsProps) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const [isRescheduleOpen, setIsRescheduleOpen] = useState(false); + const [selectedSlotId, setSelectedSlotId] = useState(); const currentStatus = appointment.status; const isToday = isSameDay(appointment.token_slot.start_datetime, new Date()); @@ -400,13 +414,35 @@ const AppointmentActions = ({ }, }), onSuccess: () => { + toast.success(t("appointment_cancelled")); queryClient.invalidateQueries({ queryKey: ["appointment", appointment.id], }); }, }); - if (["fulfilled", "cancelled", "entered_in_error"].includes(currentStatus)) { + const { mutate: rescheduleAppointment, isPending: isRescheduling } = + useMutation({ + mutationFn: mutate(scheduleApis.appointments.reschedule, { + pathParams: { + facility_id: facilityId, + id: appointment.id, + }, + }), + onSuccess: (newAppointment: Appointment) => { + toast.success(t("appointment_rescheduled")); + queryClient.invalidateQueries({ + queryKey: ["appointment", appointment.id], + }); + setIsRescheduleOpen(false); + setSelectedSlotId(undefined); + navigate( + `/facility/${facilityId}/patient/${appointment.patient.id}/appointments/${newAppointment.id}`, + ); + }, + }); + + if (AppointmentFinalStatuses.includes(currentStatus)) { return null; } @@ -437,6 +473,53 @@ const AppointmentActions = ({ {t("view_patient")} + + + + + + + + {t("reschedule_appointment")} + + +
+ + +
+ + +
+
+
+
+ {currentStatus === "booked" && ( <> - ); - } - - const { booked_slots, total_slots } = availability; - const bookedPercentage = booked_slots / total_slots; - const tokensLeft = total_slots - booked_slots; - const isFullyBooked = tokensLeft <= 0; - - return ( - - ); - }; - const handleSubmit = async () => { if (!resourceId) { toast.error("Please select a practitioner"); @@ -268,103 +151,12 @@ export default function BookAppointment(props: Props) { !resourceId && "opacity-50 pointer-events-none", )} > -
- { - setSelectedMonth(month); - setSelectedSlotId(undefined); - }} - renderDay={renderDay} - className="mb-6" - highlightToday={false} - /> -
- -
-
-

{t("available_time_slots")}

-
- -
- {slotsQuery.data == null && ( -
-

- {t("to_view_available_slots_select_resource_and_date")} -

-
- )} - {slotsQuery.data?.results.length === 0 && ( -
-

- {t("no_slots_available_for_this_date")} -

-
- )} - {!!slotsQuery.data?.results.length && - groupSlotsByAvailability(slotsQuery.data.results).map( - ({ availability, slots }) => ( -
-

- {availability.name} -

-
- {slots.map((slot) => { - const percentage = - slot.allocated / availability.tokens_per_slot; - - return ( - - ); - })} -
- -
- ), - )} -
-
-
+
diff --git a/src/pages/Appointments/components/AppointmentSlotPicker.tsx b/src/pages/Appointments/components/AppointmentSlotPicker.tsx new file mode 100644 index 00000000000..f2feb48d6ca --- /dev/null +++ b/src/pages/Appointments/components/AppointmentSlotPicker.tsx @@ -0,0 +1,240 @@ +import { useQuery } from "@tanstack/react-query"; +import { format, isBefore, isSameDay, startOfToday } from "date-fns"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; + +import Calendar from "@/CAREUI/interactive/Calendar"; + +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; + +import query from "@/Utils/request/query"; +import { dateQueryString } from "@/Utils/utils"; +import scheduleApis from "@/types/scheduling/scheduleApis"; + +import { groupSlotsByAvailability, useAvailabilityHeatmap } from "../utils"; + +interface AppointmentSlotPickerProps { + facilityId: string; + resourceId?: string; + onSlotSelect: (slotId: string | undefined) => void; + selectedSlotId?: string; +} + +export function AppointmentSlotPicker({ + facilityId, + resourceId, + onSlotSelect, + selectedSlotId, +}: AppointmentSlotPickerProps) { + const { t } = useTranslation(); + const [selectedMonth, setSelectedMonth] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(new Date()); + + const heatmapQuery = useAvailabilityHeatmap({ + facilityId, + userId: resourceId, + month: selectedMonth, + }); + + const slotsQuery = useQuery({ + queryKey: ["slots", facilityId, resourceId, dateQueryString(selectedDate)], + queryFn: query(scheduleApis.slots.getSlotsForDay, { + pathParams: { facility_id: facilityId }, + body: { + user: resourceId ?? "", + day: dateQueryString(selectedDate), + }, + }), + enabled: !!resourceId && !!selectedDate, + }); + + const renderDay = (date: Date) => { + const isSelected = isSameDay(date, selectedDate); + const isBeforeToday = isBefore(date, startOfToday()); + const availability = heatmapQuery.data?.[dateQueryString(date)]; + + if ( + heatmapQuery.isFetching || + !availability || + availability.total_slots === 0 || + isBeforeToday + ) { + return ( + + ); + } + + const { booked_slots, total_slots } = availability; + const bookedPercentage = booked_slots / total_slots; + const tokensLeft = total_slots - booked_slots; + const isFullyBooked = tokensLeft <= 0; + + return ( + + ); + }; + + return ( + <> +
+ { + setSelectedMonth(month); + onSlotSelect(undefined); + }} + renderDay={renderDay} + className="mb-6" + highlightToday={false} + /> +
+ +
+
+

{t("available_time_slots")}

+
+ +
+ {slotsQuery.data == null && ( +
+

+ {t("to_view_available_slots_select_resource_and_date")} +

+
+ )} + {slotsQuery.data?.results.length === 0 && ( +
+

+ {t("no_slots_available_for_this_date")} +

+
+ )} + {!!slotsQuery.data?.results.length && + groupSlotsByAvailability(slotsQuery.data.results).map( + ({ availability, slots }) => ( +
+

+ {availability.name} +

+
+ {slots.map((slot) => { + const percentage = + slot.allocated / availability.tokens_per_slot; + const isPastSlot = + isSameDay(selectedDate, new Date()) && + isBefore(slot.start_datetime, new Date()); + + return ( + + ); + })} +
+ +
+ ), + )} +
+
+
+ + ); +} diff --git a/src/pages/Patient/components/AppointmentDialog.tsx b/src/pages/Patient/components/AppointmentDialog.tsx index 1c3e432c4a9..b8fbf004fa6 100644 --- a/src/pages/Patient/components/AppointmentDialog.tsx +++ b/src/pages/Patient/components/AppointmentDialog.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Dispatch, SetStateAction } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -18,7 +19,10 @@ import mutate from "@/Utils/request/mutate"; import { formatName, formatPatientAge } from "@/Utils/utils"; import { formatAppointmentSlotTime } from "@/pages/Appointments/utils"; import PublicAppointmentApi from "@/types/scheduling/PublicAppointmentApi"; -import { Appointment } from "@/types/scheduling/schedule"; +import { + Appointment, + AppointmentFinalStatuses, +} from "@/types/scheduling/schedule"; function AppointmentDialog(props: { appointment: Appointment | undefined; @@ -41,6 +45,7 @@ function AppointmentDialog(props: { queryKey: ["appointment", tokenData?.phoneNumber], }); props.setAppointmentDialogOpen(false); + toast.success(t("appointment_cancelled")); }, }); const { appointment, open, onOpenChange } = props; @@ -80,23 +85,25 @@ function AppointmentDialog(props: { {t(appointment.status)} - - - - + {!AppointmentFinalStatuses.includes(appointment.status) && ( + + + + + )} diff --git a/src/types/facility/facility.ts b/src/types/facility/facility.ts index 33157fd50e5..bd0374c0bd4 100644 --- a/src/types/facility/facility.ts +++ b/src/types/facility/facility.ts @@ -1,5 +1,10 @@ import { Organization } from "@/types/organization/organization"; +export interface FacilityBareMinimum { + id: string; + name: string; +} + export interface BaseFacility { id: string; name: string; diff --git a/src/types/scheduling/schedule.ts b/src/types/scheduling/schedule.ts index e97e1a1b2a8..62fe2793a3c 100644 --- a/src/types/scheduling/schedule.ts +++ b/src/types/scheduling/schedule.ts @@ -2,6 +2,7 @@ import { DayOfWeek } from "@/CAREUI/interactive/WeekdayCheckbox"; import { Time } from "@/Utils/types"; import { AppointmentPatient } from "@/pages/Patient/Utils"; +import { FacilityBareMinimum } from "@/types/facility/facility"; import { UserBase } from "@/types/user/user"; export type ScheduleSlotType = "appointment" | "open" | "closed"; @@ -113,6 +114,7 @@ export const AppointmentNonCancelledStatuses = [ export const AppointmentCancelledStatuses = [ "cancelled", "entered_in_error", + "rescheduled", ] as const; export const AppointmentStatuses = [ @@ -120,6 +122,13 @@ export const AppointmentStatuses = [ ...AppointmentCancelledStatuses, ] as const; +export const AppointmentFinalStatuses: AppointmentStatus[] = [ + "fulfilled", + "cancelled", + "entered_in_error", + "rescheduled", +]; + export type AppointmentNonCancelledStatus = (typeof AppointmentNonCancelledStatuses)[number]; @@ -137,6 +146,7 @@ export interface Appointment { reason_for_visit: string; user: UserBase; booked_by: UserBase | null; // This is null if the appointment was booked by the patient itself. + facility: FacilityBareMinimum; } export interface AppointmentCreateRequest { diff --git a/src/types/scheduling/scheduleApis.ts b/src/types/scheduling/scheduleApis.ts index e24c73ea2e0..bf768663c8d 100644 --- a/src/types/scheduling/scheduleApis.ts +++ b/src/types/scheduling/scheduleApis.ts @@ -143,6 +143,12 @@ export default { TBody: Type<{ reason: "cancelled" | "entered_in_error" }>(), TRes: Type(), }, + reschedule: { + path: "/api/v1/facility/{facility_id}/appointments/{id}/reschedule/", + method: HttpMethod.POST, + TBody: Type<{ new_slot: string }>(), + TRes: Type(), + }, /** * Lists schedulable users for a facility */