diff --git a/public/locale/en.json b/public/locale/en.json index ab0afc7ca3c..e46b450d8e1 100644 --- a/public/locale/en.json +++ b/public/locale/en.json @@ -191,11 +191,14 @@ "ROUNDS_TYPE__NORMAL": "Brief Update", "ROUNDS_TYPE__TELEMEDICINE": "Tele-medicine Log", "ROUNDS_TYPE__VENTILATOR": "Detailed Update", + "SCHEDULE_AVAILABILITY_TYPE_DESCRIPTION__appointment": "Patients can be booked for slots in this session", + "SCHEDULE_AVAILABILITY_TYPE_DESCRIPTION__closed": "Inidcates the practitioner is not available for in this session's time", + "SCHEDULE_AVAILABILITY_TYPE_DESCRIPTION__open": "Indicates the practitioner is available in this session", + "SCHEDULE_AVAILABILITY_TYPE__appointment": "Appointment", + "SCHEDULE_AVAILABILITY_TYPE__closed": "Closed", + "SCHEDULE_AVAILABILITY_TYPE__open": "Open", "SCHEDULE_EXCEPTION_TYPE__MODIFY_SCHEDULE": "Modify Schedule", "SCHEDULE_EXCEPTION_TYPE__UNAVAILABLE": "Unavailable", - "SCHEDULE_SLOT_TYPE__appointment": "Appointment", - "SCHEDULE_SLOT_TYPE__closed": "Closed", - "SCHEDULE_SLOT_TYPE__open": "Open", "SLEEP__EXCESSIVE": "Excessive", "SLEEP__NO_SLEEP": "No sleep", "SLEEP__SATISFACTORY": "Satisfactory", @@ -313,6 +316,7 @@ "active_files": "Active Files", "active_prescriptions": "Active Prescriptions", "add": "Add", + "add_another_session": "Add another session", "add_as": "Add as", "add_attachments": "Add Attachments", "add_beds": "Add Bed(s)", @@ -434,6 +438,7 @@ "authorize_shift_delete": "Authorize shift delete", "auto_generated_for_care": "Auto Generated for Care", "autofilled_fields": "Autofilled Fields", + "availabilities": "Availabilities", "available_features": "Available Features", "available_in": "Available in", "available_time_slots": "Available Time Slots", @@ -650,11 +655,14 @@ "create_position_preset_description": "Creates a new position preset in Care from the current position of the camera for the given name", "create_preset_prerequisite": "To create presets for this bed, you'll need to link the camera to the bed first.", "create_resource_request": "Create Request", + "create_schedule_template": "Create Schedule Template", + "create_template": "Create Template", "create_user": "Create User", "created": "Created", "created_by": "Created By", "created_date": "Created Date", "created_on": "Created On", + "creating": "Creating...", "criticality": "Criticality", "csv_file_in_the_specified_format": "Select a CSV file in the specified format", "current_address": "Current Address", @@ -679,6 +687,7 @@ "date_of_result": "Covid confirmation date", "date_of_return": "Date of Return", "date_of_test": "Date of sample collection for Covid testing", + "date_range": "Date Range", "day": "Day", "death_report": "Death Report", "delete": "Delete", @@ -768,6 +777,7 @@ "edit_policy_description": "Add or edit patient's insurance details", "edit_prescriptions": "Edit Prescriptions", "edit_profile": "Edit Profile", + "edit_schedule_template": "Edit Schedule Template", "edit_role": "Edit Role", "edit_user_profile": "Edit Profile", "edit_user_role": "Edit User Role", @@ -888,6 +898,7 @@ "encounter_suggestion_edit_disallowed": "Not allowed to switch to this option in edit consultation", "encounters": "Encounters", "end_datetime": "End Date/Time", + "end_time": "End Time", "enter_aadhaar_number": "Enter a 12-digit Aadhaar ID", "enter_aadhaar_otp": "Enter OTP sent to the registered mobile with Aadhaar", "enter_abha_address": "Enter ABHA Address", @@ -917,6 +928,7 @@ "etiology_identified": "Etiology identified", "evening_slots": "Evening Slots", "events": "Events", + "exception_created": "Exception created successfully", "exception_deleted": "Exception deleted", "exceptions": "Exceptions", "expand_sidebar": "Expand Sidebar", @@ -1119,6 +1131,7 @@ "last_administered": "Last administered", "last_discharge_reason": "Last Discharge Reason", "last_edited": "Last Edited", + "last_fortnight_short": "Last 2wk", "last_login": "Last Login", "last_modified": "Last Modified", "last_modified_by": "Last Modified By", @@ -1128,6 +1141,7 @@ "last_serviced_on": "Last Serviced On", "last_updated_by": "Last updated by", "last_vaccinated_on": "Last Vaccinated on", + "last_week_short": "Last wk", "latitude_invalid": "Latitude must be between -90 and 90", "left": "Left", "length": "Length ({{unit}})", @@ -1176,6 +1190,7 @@ "map_acronym": "M.A.P.", "mark_all_as_read": "Mark all as Read", "mark_as_complete": "Mark as Complete", + "mark_as_entered_in_error": "Mark as entered in error", "mark_as_fulfilled": "Mark as Fullfilled", "mark_as_noshow": "Mark as no-show", "mark_as_read": "Mark as Read", @@ -1227,6 +1242,7 @@ "moving_camera": "Moving Camera", "my_doctors": "My Doctors", "my_profile": "My Profile", + "my_schedules": "My Schedules", "name": "Name", "name_of_hospital": "Name of Hospital", "name_of_shifting_approving_facility": "Name of shifting approving facility", @@ -1238,11 +1254,15 @@ "new_password_confirmation": "Confirm New Password", "new_password_same_as_old": "New password is same as old password, please enter a different new password.", "new_password_validation": "New password is not valid.", + "new_session": "New Session", + "next_fortnight_short": "Next 2wk", "next_sessions": "Next Sessions", + "next_week_short": "Next wk", "no": "No", "no_address_provided": "No address provided", "no_appointments": "No appointments found", "no_attachments_found": "This communication has no attachments.", + "no_availabilities_yet": "No availabilities yet", "no_bed_asset_linked_allocated": "No bed/asset linked allocated", "no_bed_types_found": "No Bed Types found", "no_beds_available": "No beds available", @@ -1287,6 +1307,7 @@ "no_resource_requests_found": "No requests found", "no_results": "No results", "no_results_found": "No Results Found", + "no_schedule_templates_found": "No schedule templates found for this month.", "no_scheduled_exceptions_found": "No scheduled exceptions found", "no_slots_available": "No slots available", "no_slots_available_for_this_date": "No slots available for this date", @@ -1310,6 +1331,7 @@ "notification_permission_denied": "Notification permission denied", "notification_permission_granted": "Notification permission granted", "notify": "Notify", + "number_min_error": "Must be greater than {{min}}", "number_of_aged_dependents": "Number of Aged Dependents (Above 60)", "number_of_beds": "Number of beds", "number_of_beds_out_of_range_error": "Number of beds cannot be greater than 100", @@ -1417,6 +1439,7 @@ "patient_update_error": "Could not update patient", "patient_update_success": "Patient Updated Sucessfully", "patients": "Patients", + "patients_per_slot": "Patients per Slot", "pending": "Pending", "permanent_address": "Permanent Address", "permission_denied": "You do not have permission to perform this action", @@ -1548,6 +1571,8 @@ "reject": "Reject", "rejected": "Rejected", "reload": "Reload", + "remarks": "Remarks", + "remarks_placeholder": "Enter remarks", "remove": "Remove", "remove_user": "Remove User", "remove_user_organization": "Remove User from Organization", @@ -1576,6 +1601,7 @@ "requested_by": "Requested By", "required": "Required", "required_quantity": "Required Quantity", + "reschedule": "Reschedule", "resend_otp": "Resend OTP", "reset": "Reset", "reset_password": "Reset Password", @@ -1618,13 +1644,35 @@ "save": "Save", "save_and_continue": "Save and Continue", "save_investigation": "Save Investigation", + "saving": "Saving...", "scan_asset_qr": "Scan Asset QR!", "schedule": "Schedule", "schedule_appointment": "Schedule Appointment", + "schedule_availability_created_successfully": "Availability created successfully", + "schedule_availability_deleted_successfully": "Schedule availability deleted successfully", "schedule_calendar": "Schedule Calendar", + "schedule_end_time": "End Time", + "schedule_for": "Scheduled for", "schedule_information": "Schedule Information", + "schedule_remarks": "Remarks", + "schedule_remarks_placeholder": "Any additional notes about this session", + "schedule_session_time": "Session Time", + "schedule_session_type": "Session Type", + "schedule_sessions": "Sessions", + "schedule_sessions_min_error": "Add at least one session", + "schedule_slot_size": "Slot Size", + "schedule_slot_size_label": "Slot size (mins.)", + "schedule_slots_allocation_callout": "Allocating {{slots}} slots in this session provides approximately {{token_duration}} mins. for each patient.", + "schedule_start_time": "Start Time", + "schedule_template": "Schedule Template", + "schedule_template_name": "Template Name", + "schedule_template_name_placeholder": "Regular OP Day", + "schedule_valid_from_till_range": "Valid from {{from_date}} till {{to_date}}", + "schedule_weekdays": "Weekdays", + "schedule_weekdays_description": "Select the weekdays applicable for the template", + "schedule_weekdays_min_error": "Select at least one weekday", "scheduled": "Scheduled", - "schedules": "Schedules", + "scheduled_for": "Schedule for:", "scribe__reviewing_field": "Reviewing field {{currentField}} / {{totalFields}}", "scribe_error": "Could not autofill fields", "search": "Search", @@ -1683,8 +1731,12 @@ "send_sample_to_collection_centre_title": "Send sample to collection centre", "serial_number": "Serial Number", "serviced_on": "Serviced on", + "session_capacity": "Session Capacity", "session_expired": "Session Expired", "session_expired_msg": "It appears that your session has expired. This could be due to inactivity. Please login again to continue.", + "session_title": "Session Title", + "session_title_placeholder": "IP Rounds", + "session_type": "Session Type", "set_average_weekly_working_hours_for": "Set Average weekly working hours for", "set_home_facility": "Set as home facility", "set_your_local_language": "Set your local language", @@ -1709,11 +1761,13 @@ "show_default_presets": "Show Default Presets", "show_patient_presets": "Show Patient Presets", "show_unread_notifications": "Show Unread", + "showing_all_appointments": "Showing all appointments", "sign_in": "Sign in", "sign_out": "Sign out", "skill_add_error": "Error while adding skill", "skill_added_successfully": "Skill added successfully", "skills": "Skills", + "slot_configuration": "Slot Configuration", "slots_left": "slots left", "social_profile": "Social Profile", "social_profile_detail": "Include occupation, ration card category, socioeconomic status, and domestic healthcare support for a complete profile.", @@ -1731,6 +1785,7 @@ "start_datetime": "Start Date/Time", "start_dosage": "Start Dosage", "start_review": "Start Review", + "start_time": "Start Time", "state": "State", "status": "Status", "stop": "Stop", @@ -1758,6 +1813,7 @@ "systolic": "Systolic", "tachycardia": "Tachycardia", "target_dosage": "Target Dosage", + "template_deleted": "Template has been deleted", "test_type": "Type of test done", "tested_on": "Tested on", "thank_you_for_choosing": "Thank you for choosing our care service", @@ -1774,9 +1830,11 @@ "today": "Today", "token": "Token", "token_no": "Token No.", + "tomorrow": "Tomorrow", "total_amount": "Total Amount", "total_beds": "Total Beds", "total_patients": "Total Patients", + "total_slots": "Total Slots", "total_staff": "Total Staff", "total_users": "Total Users", "transcribe_again": "Transcribe Again", @@ -1892,7 +1950,9 @@ "vacant": "Vacant", "vaccinated": "Vaccinated", "vaccine_name": "Vaccine name", + "valid_from": "Valid From", "valid_otp_found": "Valid OTP found, Navigating to Appointments", + "valid_to": "Valid Till", "valid_year_of_birth": "Please enter a valid year of birth (YYYY)", "vehicle_preference": "Vehicle preference", "vendor_name": "Vendor Name", @@ -1949,6 +2009,7 @@ "ward": "Ward", "warranty_amc_expiry": "Warranty / AMC Expiry", "we_ve_sent_you_a_code_to": "We've sent you a code to", + "weekly_schedule": "Weekly Schedule", "weekly_working_hours_error": "Average weekly working hours must be a number between 0 and 168", "what_facility_assign_the_patient_to": "What facility would you like to assign the patient to", "whatsapp_number": "Whatsapp Number", @@ -1963,6 +2024,7 @@ "years_of_experience": "Years of Experience", "years_of_experience_of_the_doctor": "Years of Experience of the Doctor", "yes": "Yes", + "yesterday": "Yesterday", "yet_to_be_decided": "Yet to be decided", "you_need_at_least_a_location_to_create_an_assest": "You need at least a location to create an assest.", "zoom_in": "Zoom In", diff --git a/src/CAREUI/display/Callout.tsx b/src/CAREUI/display/Callout.tsx index c1d4b05a88f..c14a471be7d 100644 --- a/src/CAREUI/display/Callout.tsx +++ b/src/CAREUI/display/Callout.tsx @@ -16,7 +16,7 @@ export default function Callout({ return (
{props.badge}
- {props.children} +
+ {props.children} +
); } diff --git a/src/CAREUI/interactive/WeekdayCheckbox.tsx b/src/CAREUI/interactive/WeekdayCheckbox.tsx index 1660d14ef02..55ebc304516 100644 --- a/src/CAREUI/interactive/WeekdayCheckbox.tsx +++ b/src/CAREUI/interactive/WeekdayCheckbox.tsx @@ -1,31 +1,32 @@ import { useTranslation } from "react-i18next"; -import { cn } from "@/lib/utils"; - -import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; // 0 is Monday, 6 is Sunday - Python's convention. -const DAYS_OF_WEEK = { - MONDAY: 0, - TUESDAY: 1, - WEDNESDAY: 2, - THURSDAY: 3, - FRIDAY: 4, - SATURDAY: 5, - SUNDAY: 6, -} as const; - -export type DayOfWeekValue = (typeof DAYS_OF_WEEK)[keyof typeof DAYS_OF_WEEK]; +export enum DayOfWeek { + MONDAY = 0, + TUESDAY = 1, + WEDNESDAY = 2, + THURSDAY = 3, + FRIDAY = 4, + SATURDAY = 5, + SUNDAY = 6, +} interface Props { - value?: DayOfWeekValue[]; - onChange?: (value: DayOfWeekValue[]) => void; + value?: DayOfWeek[]; + onChange?: (value: DayOfWeek[]) => void; + format?: "alphabet" | "short" | "long"; } -export default function WeekdayCheckbox({ value = [], onChange }: Props) { +export default function WeekdayCheckbox({ + value = [], + onChange, + format = "alphabet", +}: Props) { const { t } = useTranslation(); - const handleDayToggle = (day: DayOfWeekValue) => { + const handleDayToggle = (day: DayOfWeek) => { if (!onChange) return; if (value.includes(day)) { @@ -36,36 +37,35 @@ export default function WeekdayCheckbox({ value = [], onChange }: Props) { }; return ( - + ); } diff --git a/src/Utils/request/errorHandler.ts b/src/Utils/request/errorHandler.ts index ef2eba8bfe8..af5570ae29b 100644 --- a/src/Utils/request/errorHandler.ts +++ b/src/Utils/request/errorHandler.ts @@ -70,10 +70,10 @@ function isNotFound(error: HTTPError) { type PydanticError = { type: string; - loc: string[]; + loc?: string[]; msg: string; - input: unknown; - url: string; + input?: unknown; + url?: string; }; function isPydanticError(errors: unknown): errors is PydanticError[] { @@ -87,12 +87,15 @@ function isPydanticError(errors: unknown): errors is PydanticError[] { function handlePydanticErrors(errors: PydanticError[]) { errors.map(({ type, loc, msg }) => { - const title = type + if (!loc) { + toast.error(msg); + return; + } + type = type .replace("_", " ") .replace(/\b\w/g, (char) => char.toUpperCase()); - - toast.error(`${title}: '${loc.join(".")}'`, { - description: msg, + toast.error(msg, { + description: `${type}: '${loc.join(".")}'`, duration: 8000, }); }); diff --git a/src/Utils/request/utils.ts b/src/Utils/request/utils.ts index 86d9a51eebc..24c492633d3 100644 --- a/src/Utils/request/utils.ts +++ b/src/Utils/request/utils.ts @@ -1,8 +1,8 @@ import { Dispatch, SetStateAction } from "react"; +import { toast } from "sonner"; import { LocalStorageKeys } from "@/common/constants"; -import * as Notification from "@/Utils/Notifications"; import { QueryParams, RequestOptions } from "@/Utils/request/types"; export function makeUrl( @@ -43,6 +43,10 @@ const makeQueryParams = (query: QueryParams) => { return qParams.toString(); }; +/** + * TODO: consider replacing this with inferring the types from the route and using a generic + * to ensure that the path params are not missing. + */ const ensurePathNotMissingReplacements = (path: string) => { const missingParams = path.match(/\{.*\}/g); @@ -50,7 +54,7 @@ const ensurePathNotMissingReplacements = (path: string) => { const msg = `Missing path params: ${missingParams.join( ", ", )}. Path: ${path}`; - Notification.Error({ msg }); + toast.error(msg); throw new Error(msg); } }; diff --git a/src/Utils/types.ts b/src/Utils/types.ts index 22da8867b61..cdee60e1f2f 100644 --- a/src/Utils/types.ts +++ b/src/Utils/types.ts @@ -48,4 +48,4 @@ export type WritableOnly = T extends object type IfEquals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? A : B; -export type Time = `${number}:${number}`; +export type Time = `${number}:${number}` | `${number}:${number}:${number}`; diff --git a/src/components/Facility/FacilityUsers.tsx b/src/components/Facility/FacilityUsers.tsx index a4af2733d83..4326cc2d618 100644 --- a/src/components/Facility/FacilityUsers.tsx +++ b/src/components/Facility/FacilityUsers.tsx @@ -82,7 +82,7 @@ export default function FacilityUsers(props: { facilityId: number }) { } return ( - + & { }; /** - * A FormField to pick date. - * - * Example usage: - * - * ```jsx - * - * ``` + * @deprecated use shadcn/ui's date-picker instead */ const DateFormField = (props: Props) => { const field = useFormFieldPropsResolver(props); diff --git a/src/components/Form/FormFields/FormField.tsx b/src/components/Form/FormFields/FormField.tsx index 7f9c2699d64..f3ad0559e44 100644 --- a/src/components/Form/FormFields/FormField.tsx +++ b/src/components/Form/FormFields/FormField.tsx @@ -48,6 +48,9 @@ export const FieldErrorText = (props: ErrorProps) => { ); }; +/** + * @deprecated use shadcn/ui's solution for form fields instead along with react-hook-form + */ const FormField = ({ field, ...props diff --git a/src/components/Form/FormFields/RadioFormField.tsx b/src/components/Form/FormFields/RadioFormField.tsx index 79cdb64a579..ca205fcccad 100644 --- a/src/components/Form/FormFields/RadioFormField.tsx +++ b/src/components/Form/FormFields/RadioFormField.tsx @@ -17,6 +17,9 @@ type Props = FormFieldBaseProps & { layout?: "vertical" | "horizontal" | "grid" | "auto"; }; +/** + * @deprecated use shadcn/ui's radio-group instead + */ const RadioFormField = (props: Props) => { const field = useFormFieldPropsResolver(props); return ( diff --git a/src/components/Form/FormFields/SelectFormField.tsx b/src/components/Form/FormFields/SelectFormField.tsx index 5cf992d8bdd..aa712dc16a1 100644 --- a/src/components/Form/FormFields/SelectFormField.tsx +++ b/src/components/Form/FormFields/SelectFormField.tsx @@ -21,6 +21,9 @@ type SelectFormFieldProps = FormFieldBaseProps & { inputClassName?: string; }; +/** + * @deprecated use shadcn/ui's select instead + */ export const SelectFormField = (props: SelectFormFieldProps) => { const field = useFormFieldPropsResolver(props); return ( @@ -58,6 +61,9 @@ type MultiSelectFormFieldProps = FormFieldBaseProps & { optionDisabled?: OptionCallback; }; +/** + * @deprecated + */ export const MultiSelectFormField = ( props: MultiSelectFormFieldProps, ) => { diff --git a/src/components/Form/FormFields/TextAreaFormField.tsx b/src/components/Form/FormFields/TextAreaFormField.tsx index f26717810d4..b4e85e226ea 100644 --- a/src/components/Form/FormFields/TextAreaFormField.tsx +++ b/src/components/Form/FormFields/TextAreaFormField.tsx @@ -19,6 +19,9 @@ export type TextAreaFormFieldProps = FormFieldBaseProps & { onBlur?: (event: React.FocusEvent) => void; }; +/** + * @deprecated use shadcn/ui's textarea instead + */ const TextAreaFormField = forwardRef( ( { rows = 3, ...props }: TextAreaFormFieldProps, diff --git a/src/components/Form/FormFields/TextFormField.tsx b/src/components/Form/FormFields/TextFormField.tsx index c9662f83917..8f816c31a2a 100644 --- a/src/components/Form/FormFields/TextFormField.tsx +++ b/src/components/Form/FormFields/TextFormField.tsx @@ -32,6 +32,9 @@ export type TextFormFieldProps = FormFieldBaseProps & clearable?: boolean | undefined; }; +/** + * @deprecated use shadcn/ui's Input instead + */ const TextFormField = forwardRef((props: TextFormFieldProps, ref) => { const field = useFormFieldPropsResolver(props); const { leading, trailing } = props; diff --git a/src/components/Patient/PatientDetailsTab/Appointments.tsx b/src/components/Patient/PatientDetailsTab/Appointments.tsx index 70c0f6f081e..cf1da394da3 100644 --- a/src/components/Patient/PatientDetailsTab/Appointments.tsx +++ b/src/components/Patient/PatientDetailsTab/Appointments.tsx @@ -17,10 +17,10 @@ import { import { Avatar } from "@/components/Common/Avatar"; import { PatientProps } from "@/components/Patient/PatientDetailsTab"; -import { ScheduleAPIs } from "@/components/Schedule/api"; import query from "@/Utils/request/query"; import { formatDateTime, formatName } from "@/Utils/utils"; +import scheduleApis from "@/types/scheduling/scheduleApis"; export const Appointments = (props: PatientProps) => { const { patientData, facilityId, id } = props; @@ -28,7 +28,7 @@ export const Appointments = (props: PatientProps) => { const { data } = useQuery({ queryKey: ["patient-appointments", id], - queryFn: query(ScheduleAPIs.appointments.list, { + queryFn: query(scheduleApis.appointments.list, { pathParams: { facility_id: facilityId }, queryParams: { patient: id, limit: 100 }, }), diff --git a/src/components/Questionnaire/QuestionTypes/FollowUpAppointmentQuestion.tsx b/src/components/Questionnaire/QuestionTypes/AppointmentQuestion.tsx similarity index 91% rename from src/components/Questionnaire/QuestionTypes/FollowUpAppointmentQuestion.tsx rename to src/components/Questionnaire/QuestionTypes/AppointmentQuestion.tsx index f0f1cd3d79f..1b1296f1966 100644 --- a/src/components/Questionnaire/QuestionTypes/FollowUpAppointmentQuestion.tsx +++ b/src/components/Questionnaire/QuestionTypes/AppointmentQuestion.tsx @@ -16,8 +16,6 @@ import { Textarea } from "@/components/ui/textarea"; import { Avatar } from "@/components/Common/Avatar"; import { groupSlotsByAvailability } from "@/components/Schedule/Appointments/utils"; -import { ScheduleAPIs } from "@/components/Schedule/api"; -import { FollowUpAppointmentRequest } from "@/components/Schedule/types"; import useSlug from "@/hooks/useSlug"; @@ -28,6 +26,8 @@ import { ResponseValue, } from "@/types/questionnaire/form"; import { Question } from "@/types/questionnaire/question"; +import { CreateAppointmentQuestion } from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; import { UserBase } from "@/types/user/user"; interface FollowUpVisitQuestionProps { @@ -37,7 +37,7 @@ interface FollowUpVisitQuestionProps { disabled?: boolean; } -export function FollowUpAppointmentQuestion({ +export function AppointmentQuestion({ questionnaireResponse, updateQuestionnaireResponseCB, disabled, @@ -48,18 +48,18 @@ export function FollowUpAppointmentQuestion({ const values = (questionnaireResponse.values?.[0] - ?.value as unknown as FollowUpAppointmentRequest[]) || []; + ?.value as unknown as CreateAppointmentQuestion[]) || []; const value = values[0] ?? {}; - const handleUpdate = (updates: Partial) => { - const followUpAppointment = { ...value, ...updates }; + const handleUpdate = (updates: Partial) => { + const appointment = { ...value, ...updates }; updateQuestionnaireResponseCB({ ...questionnaireResponse, values: [ { - type: "follow_up_appointment", - value: [followUpAppointment] as unknown as ResponseValue["value"], + type: "appointment", + value: [appointment] as unknown as ResponseValue["value"], }, ], }); @@ -69,7 +69,7 @@ export function FollowUpAppointmentQuestion({ const resourcesQuery = useQuery({ queryKey: ["availableResources", facilityId], - queryFn: query(ScheduleAPIs.appointments.availableUsers, { + queryFn: query(scheduleApis.appointments.availableUsers, { pathParams: { facility_id: facilityId }, }), }); @@ -81,7 +81,7 @@ export function FollowUpAppointmentQuestion({ resource?.id, dateQueryString(selectedDate), ], - queryFn: query(ScheduleAPIs.slots.getSlotsForDay, { + queryFn: query(scheduleApis.slots.getSlotsForDay, { pathParams: { facility_id: facilityId }, body: { user: resource?.id, diff --git a/src/components/Questionnaire/QuestionTypes/QuestionInput.tsx b/src/components/Questionnaire/QuestionTypes/QuestionInput.tsx index e6249bbe491..e16f192f606 100644 --- a/src/components/Questionnaire/QuestionTypes/QuestionInput.tsx +++ b/src/components/Questionnaire/QuestionTypes/QuestionInput.tsx @@ -3,7 +3,7 @@ import CareIcon from "@/CAREUI/icons/CareIcon"; import { Button } from "@/components/ui/button"; import { QuestionLabel } from "@/components/Questionnaire/QuestionLabel"; -import { FollowUpAppointmentQuestion } from "@/components/Questionnaire/QuestionTypes/FollowUpAppointmentQuestion"; +import { AppointmentQuestion } from "@/components/Questionnaire/QuestionTypes/AppointmentQuestion"; import { QuestionValidationError } from "@/types/questionnaire/batch"; import type { @@ -118,8 +118,8 @@ export function QuestionInput({ return ; case "diagnosis": return ; - case "follow_up_appointment": - return ; + case "appointment": + return ; case "encounter": if (encounterId) { return ( diff --git a/src/components/Questionnaire/structured/handlers.ts b/src/components/Questionnaire/structured/handlers.ts index 5d11c1b7359..8e5fdba0078 100644 --- a/src/components/Questionnaire/structured/handlers.ts +++ b/src/components/Questionnaire/structured/handlers.ts @@ -147,9 +147,9 @@ const handlers: { }); }, }, - follow_up_appointment: { - getRequests: (followUpAppointment, { facilityId, patientId }) => { - const { reason_for_visit, slot_id } = followUpAppointment[0]; + appointment: { + getRequests: (appointment, { facilityId, patientId }) => { + const { reason_for_visit, slot_id } = appointment[0]; return [ { url: `/api/v1/facility/${facilityId}/slots/${slot_id}/create_appointment/`, @@ -158,7 +158,7 @@ const handlers: { reason_for_visit, patient: patientId, }, - reference_id: "follow_up_appointment", + reference_id: "appointment", }, ]; }, diff --git a/src/components/Questionnaire/structured/types.ts b/src/components/Questionnaire/structured/types.ts index 7f31f61ad4c..e871f8bdb6d 100644 --- a/src/components/Questionnaire/structured/types.ts +++ b/src/components/Questionnaire/structured/types.ts @@ -1,8 +1,3 @@ -import { - AppointmentCreate, - FollowUpAppointmentRequest, -} from "@/components/Schedule/types"; - import { AllergyIntoleranceRequest } from "@/types/emr/allergyIntolerance/allergyIntolerance"; import { Diagnosis, DiagnosisRequest } from "@/types/emr/diagnosis/diagnosis"; import { Encounter, EncounterEditRequest } from "@/types/emr/encounter"; @@ -10,6 +5,10 @@ import { MedicationRequest } from "@/types/emr/medicationRequest"; import { MedicationStatement } from "@/types/emr/medicationStatement"; import { Symptom, SymptomRequest } from "@/types/emr/symptom/symptom"; import { StructuredQuestionType } from "@/types/questionnaire/question"; +import { + AppointmentCreateRequest, + CreateAppointmentQuestion, +} from "@/types/scheduling/schedule"; // Map structured types to their data types export interface StructuredDataMap { @@ -19,7 +18,7 @@ export interface StructuredDataMap { symptom: Symptom; diagnosis: Diagnosis; encounter: Encounter; - follow_up_appointment: FollowUpAppointmentRequest; + appointment: CreateAppointmentQuestion; } // Map structured types to their request types @@ -30,7 +29,7 @@ export interface StructuredRequestMap { symptom: SymptomRequest; diagnosis: DiagnosisRequest; encounter: EncounterEditRequest; - follow_up_appointment: AppointmentCreate; + appointment: AppointmentCreateRequest; } export type RequestTypeFor = diff --git a/src/components/Schedule/Appointments/AppointmentCreatePage.tsx b/src/components/Schedule/Appointments/AppointmentCreatePage.tsx index 772e7b90361..4d29327d26d 100644 --- a/src/components/Schedule/Appointments/AppointmentCreatePage.tsx +++ b/src/components/Schedule/Appointments/AppointmentCreatePage.tsx @@ -28,13 +28,13 @@ import { groupSlotsByAvailability, useAvailabilityHeatmap, } from "@/components/Schedule/Appointments/utils"; -import { ScheduleAPIs } from "@/components/Schedule/api"; import useAppHistory from "@/hooks/useAppHistory"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; import { dateQueryString, formatDisplayName, formatName } from "@/Utils/utils"; +import scheduleApis from "@/types/scheduling/scheduleApis"; interface Props { facilityId: string; @@ -54,7 +54,7 @@ export default function AppointmentCreatePage(props: Props) { const resourcesQuery = useQuery({ queryKey: ["availableResources", props.facilityId], - queryFn: query(ScheduleAPIs.appointments.availableUsers, { + queryFn: query(scheduleApis.appointments.availableUsers, { pathParams: { facility_id: props.facilityId, }, @@ -75,7 +75,7 @@ export default function AppointmentCreatePage(props: Props) { resourceId, dateQueryString(selectedDate), ], - queryFn: query(ScheduleAPIs.slots.getSlotsForDay, { + queryFn: query(scheduleApis.slots.getSlotsForDay, { pathParams: { facility_id: props.facilityId }, body: { user: resourceId, @@ -86,7 +86,7 @@ export default function AppointmentCreatePage(props: Props) { }); const { mutateAsync: createAppointment } = useMutation({ - mutationFn: mutate(ScheduleAPIs.slots.createAppointment, { + mutationFn: mutate(scheduleApis.slots.createAppointment, { pathParams: { facility_id: props.facilityId, slot_id: selectedSlotId ?? "", diff --git a/src/components/Schedule/Appointments/AppointmentDetailsPage.tsx b/src/components/Schedule/Appointments/AppointmentDetailsPage.tsx index 55bba4d2862..078dc5ffccb 100644 --- a/src/components/Schedule/Appointments/AppointmentDetailsPage.tsx +++ b/src/components/Schedule/Appointments/AppointmentDetailsPage.tsx @@ -12,15 +12,13 @@ import { } from "@radix-ui/react-icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { differenceInYears, format, isSameDay } from "date-fns"; -import { PrinterIcon } from "lucide-react"; +import { BanIcon, PrinterIcon } from "lucide-react"; import { navigate } from "raviger"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import CareIcon from "@/CAREUI/icons/CareIcon"; - import { Badge, BadgeProps } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -42,8 +40,6 @@ import { formatAppointmentSlotTime, printAppointment, } from "@/components/Schedule/Appointments/utils"; -import { ScheduleAPIs } from "@/components/Schedule/api"; -import { Appointment, AppointmentStatuses } from "@/components/Schedule/types"; import routes from "@/Utils/request/api"; import mutate from "@/Utils/request/mutate"; @@ -53,6 +49,12 @@ import { getReadableDuration, saveElementAsImage, } from "@/Utils/utils"; +import { + Appointment, + AppointmentStatuses, + AppointmentUpdateRequest, +} from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; interface Props { facilityId: string; @@ -74,7 +76,7 @@ export default function AppointmentDetailsPage(props: Props) { const appointmentQuery = useQuery({ queryKey: ["appointment", props.appointmentId], - queryFn: query(ScheduleAPIs.appointments.retrieve, { + queryFn: query(scheduleApis.appointments.retrieve, { pathParams: { facility_id: props.facilityId, id: props.appointmentId, @@ -95,9 +97,9 @@ export default function AppointmentDetailsPage(props: Props) { const { mutate: updateAppointment, isPending } = useMutation< Appointment, unknown, - { status: Appointment["status"] } + AppointmentUpdateRequest >({ - mutationFn: mutate(ScheduleAPIs.appointments.update, { + mutationFn: mutate(scheduleApis.appointments.update, { pathParams: { facility_id: props.facilityId, id: props.appointmentId, @@ -175,6 +177,7 @@ export default function AppointmentDetailsPage(props: Props) {
updateAppointment({ status })} onViewPatient={redirectToPatientPage} @@ -216,7 +219,9 @@ const AppointmentDetails = ({ entered_in_error: "destructive", cancelled: "destructive", noshow: "destructive", - } as Record + } as Partial< + Record + > )[appointment.status] ?? "outline" } > @@ -357,20 +362,38 @@ const AppointmentDetails = ({ }; interface AppointmentActionsProps { + facilityId: string; appointment: Appointment; onChange: (status: Appointment["status"]) => void; onViewPatient: () => void; } const AppointmentActions = ({ + facilityId, appointment, onChange, onViewPatient, }: AppointmentActionsProps) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); + const currentStatus = appointment.status; const isToday = isSameDay(appointment.token_slot.start_datetime, new Date()); + const { mutate: cancelAppointment } = useMutation({ + mutationFn: mutate(scheduleApis.appointments.cancel, { + pathParams: { + facility_id: facilityId, + id: appointment.id, + }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["appointment", appointment.id], + }); + }, + }); + if (["fulfilled", "cancelled", "entered_in_error"].includes(currentStatus)) { return null; } @@ -449,10 +472,22 @@ const AppointmentActions = ({ )} - +
); }; diff --git a/src/components/Schedule/Appointments/AppointmentTokenCard.tsx b/src/components/Schedule/Appointments/AppointmentTokenCard.tsx index 338f37fa9e5..dbf07cd8182 100644 --- a/src/components/Schedule/Appointments/AppointmentTokenCard.tsx +++ b/src/components/Schedule/Appointments/AppointmentTokenCard.tsx @@ -7,9 +7,9 @@ import { Label } from "@/components/ui/label"; import { FacilityModel } from "@/components/Facility/models"; import { formatAppointmentSlotTime } from "@/components/Schedule/Appointments/utils"; import { getFakeTokenNumber } from "@/components/Schedule/helpers"; -import { Appointment } from "@/components/Schedule/types"; import { formatName, formatPatientAge } from "@/Utils/utils"; +import { Appointment } from "@/types/scheduling/schedule"; interface Props { id?: string; diff --git a/src/components/Schedule/Appointments/AppointmentsPage.tsx b/src/components/Schedule/Appointments/AppointmentsPage.tsx index bd12619aea3..6e350c900d9 100644 --- a/src/components/Schedule/Appointments/AppointmentsPage.tsx +++ b/src/components/Schedule/Appointments/AppointmentsPage.tsx @@ -1,8 +1,19 @@ import { CaretDownIcon, CheckIcon, ReloadIcon } from "@radix-ui/react-icons"; +import { PopoverClose } from "@radix-ui/react-popover"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { format, formatDate, isPast } from "date-fns"; +import { + addDays, + format, + formatDate, + isPast, + isToday, + isTomorrow, + isYesterday, + subDays, +} from "date-fns"; +import { Edit3Icon } from "lucide-react"; import { Link, navigate, useQueryParams } from "raviger"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; @@ -19,7 +30,7 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; -import { DatePicker } from "@/components/ui/date-picker"; +import { DateRangePicker } from "@/components/ui/date-range-picker"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -46,22 +57,16 @@ import { import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Avatar } from "@/components/Common/Avatar"; +import Loading from "@/components/Common/Loading"; import Page from "@/components/Common/Page"; import { formatSlotTimeRange, groupSlotsByAvailability, } from "@/components/Schedule/Appointments/utils"; -import { ScheduleAPIs } from "@/components/Schedule/api"; import { getFakeTokenNumber } from "@/components/Schedule/helpers"; -import { - Appointment, - AppointmentStatuses, - SlotAvailability, -} from "@/components/Schedule/types"; import useAuthUser from "@/hooks/useAuthUser"; -import FiltersCache from "@/Utils/FiltersCache"; import mutate from "@/Utils/request/mutate"; import query from "@/Utils/request/query"; import { @@ -70,54 +75,213 @@ import { formatName, formatPatientAge, } from "@/Utils/utils"; +import { + Appointment, + AppointmentStatuses, + TokenSlot, +} from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; interface QueryParams { - practitioner?: string; - slot?: string; - date?: string; - search?: string; + practitioner: string | null; + slot: string | null; + date_from: string | null; + date_to: string | null; + search: string | null; } -export default function AppointmentsPage(props: { facilityId?: string }) { +interface DateRangeDisplayProps { + dateFrom: string | null; + dateTo: string | null; +} + +function DateRangeDisplay({ dateFrom, dateTo }: DateRangeDisplayProps) { const { t } = useTranslation(); - const [qParams, _setQParams] = useQueryParams(); - const date = qParams.date ?? dateQueryString(new Date()); + if (!dateFrom && !dateTo) { + return ( + {t("showing_all_appointments")} + ); + } + + const today = new Date(); - const setQParams = (params: QueryParams) => { - params = FiltersCache.utils.clean({ ...qParams, ...params }); - _setQParams(params, { replace: true }); - }; + // Case 1: Today only + if ( + dateFrom === dateQueryString(today) && + dateTo === dateQueryString(today) + ) { + return ( + <> + {t("today")} + + ({formatDate(dateFrom, "dd MMM yyyy")}) + + + ); + } + + // Case 2: Pre-defined ranges + const ranges = [ + { + label: t("last_fortnight_short"), + from: subDays(today, 14), + to: today, + }, + { + label: t("last_week_short"), + from: subDays(today, 7), + to: today, + }, + { + label: t("next_week_short"), + from: today, + to: addDays(today, 7), + }, + { + label: t("next_fortnight_short"), + from: today, + to: addDays(today, 14), + }, + ]; + + const matchingRange = ranges.find( + (range) => + dateFrom && + dateTo && + dateQueryString(range.from) === dateFrom && + dateQueryString(range.to) === dateTo, + ); + + if (matchingRange && dateFrom && dateTo) { + return ( + <> + {matchingRange.label} + + ({formatDate(dateFrom, "dd MMM yyyy")} -{" "} + {formatDate(dateTo, "dd MMM yyyy")}) + + + ); + } + + // Case 3: Same date with relative labels + if (dateFrom && dateFrom === dateTo) { + const date = new Date(dateFrom); + let relativeDay = null; + + if (isToday(date)) { + relativeDay = t("today"); + } else if (isTomorrow(date)) { + relativeDay = t("tomorrow"); + } else if (isYesterday(date)) { + relativeDay = t("yesterday"); + } + + if (relativeDay) { + return ( + <> + {relativeDay} + + ({formatDate(dateFrom, "dd MMM yyyy")}) + + + ); + } + + return ( + {formatDate(dateFrom, "dd MMM yyyy")} + ); + } + + // Case 4: Date range or single date + return ( + + {formatDate(dateFrom!, "dd MMM yyyy")} + {dateTo && ( + <> + {" - "} + {formatDate(dateTo, "dd MMM yyyy")} + + )} + + ); +} +export default function AppointmentsPage(props: { facilityId?: string }) { + const { t } = useTranslation(); const authUser = useAuthUser(); + + const [qParams, setQParams] = useQueryParams(); + const facilityId = props.facilityId ?? authUser.home_facility!; const [viewMode, setViewMode] = useState<"board" | "list">("board"); - const resourcesQuery = useQuery({ - queryKey: ["appointments-resources", facilityId], - queryFn: query(ScheduleAPIs.appointments.availableUsers, { + const shedulableUsersQuery = useQuery({ + queryKey: ["schedulable-users", facilityId], + queryFn: query(scheduleApis.appointments.availableUsers, { pathParams: { facility_id: facilityId }, }), }); - const resources = resourcesQuery.data?.users; - const practitioner = resources?.find((r) => r.id === qParams.practitioner); + const resources = shedulableUsersQuery.data?.users; + const practitioner = qParams.practitioner + ? resources?.find((r) => r.username === qParams.practitioner) + : undefined; + + useEffect(() => { + const updates: Partial = {}; + + // Sets the practitioner filter to the current user if they are in the list of + // shedulable users and no practitioner was selected. + if ( + !shedulableUsersQuery.isLoading && + !qParams.practitioner && + shedulableUsersQuery.data?.users.some( + (r) => r.username === authUser.username, + ) + ) { + updates.practitioner = authUser.username; + } + + // Set today's date range if no dates are present + if (!qParams.date_from && !qParams.date_to) { + const today = new Date(); + updates.date_from = dateQueryString(today); + updates.date_to = dateQueryString(today); + } + + // Only update if there are changes + if (Object.keys(updates).length > 0) { + setQParams({ + ...qParams, + ...updates, + }); + } + }, [shedulableUsersQuery.isLoading]); + // We'll need to update the backend to get slots for a range of dates + // But it'd be better to get schedule and exceptions and compute slots on the frontend + // TODO: handle this properly const slotsQuery = useQuery({ - queryKey: ["slots", facilityId, qParams.practitioner, date], - queryFn: query(ScheduleAPIs.slots.getSlotsForDay, { + queryKey: ["slots", facilityId, qParams.practitioner, qParams.date_from], + queryFn: query(scheduleApis.slots.getSlotsForDay, { pathParams: { facility_id: facilityId }, body: { - user: qParams.practitioner ?? "", - day: date, + user: practitioner?.id ?? "", + day: qParams.date_from ?? "", }, }), - enabled: !!qParams.practitioner, + enabled: !!qParams.date_from && !!practitioner, }); const slots = slotsQuery.data?.results; const slot = slots?.find((s) => s.id === qParams.slot); + if (shedulableUsersQuery.isLoading) { + return ; + } + return ( - + + + +
+
+ + + + + + + + + +
+ + + setQParams({ + ...qParams, + date_from: date?.from + ? dateQueryString(date.from) + : null, + date_to: date?.to ? dateQueryString(date?.to) : null, + slot: null, + }) + } + /> +
+
+
+ { if (slot === "all") { - setQParams({ slot: undefined }); + setQParams({ ...qParams, slot: null }); } else { - setQParams({ slot }); + setQParams({ ...qParams, slot }); } }} /> @@ -258,8 +542,8 @@ export default function AppointmentsPage(props: { facilityId?: string }) { setQParams({ search: e.target.value })} + value={qParams.search ?? ""} + onChange={(e) => setQParams({ ...qParams, search: e.target.value })} /> @@ -271,18 +555,35 @@ export default function AppointmentsPage(props: { facilityId?: string }) {
- setQParams({ - date: dateQueryString(date), - slot: undefined, + ...qParams, + date_from: date?.from ? dateQueryString(date.from) : null, + date_to: date?.to ? dateQueryString(date?.to) : null, }) } />
- @@ -309,8 +610,9 @@ export default function AppointmentsPage(props: { facilityId?: string }) { status={status} facilityId={facilityId} slot={slot?.id} - practitioner={qParams.practitioner} - date={date} + practitioner={practitioner?.id ?? null} + date_from={qParams.date_from} + date_to={qParams.date_to} search={qParams.search?.toLowerCase()} /> ))} @@ -320,9 +622,10 @@ export default function AppointmentsPage(props: { facilityId?: string }) { ) : ( )} @@ -333,9 +636,10 @@ export default function AppointmentsPage(props: { facilityId?: string }) { function AppointmentColumn(props: { facilityId: string; status: Appointment["status"]; - practitioner?: string; - slot?: string; - date: string; + practitioner: string | null; + slot?: string | null; + date_from: string | null; + date_to: string | null; search?: string; }) { const { t } = useTranslation(); @@ -347,16 +651,18 @@ function AppointmentColumn(props: { props.status, props.practitioner, props.slot, - props.date, + props.date_from, + props.date_to, ], - queryFn: query(ScheduleAPIs.appointments.list, { + queryFn: query(scheduleApis.appointments.list, { pathParams: { facility_id: props.facilityId }, queryParams: { status: props.status, limit: 100, slot: props.slot, - user: props.practitioner, - date: props.date, + user: props.practitioner ?? undefined, + date_after: props.date_from, + date_before: props.date_to, }, }), }); @@ -438,9 +744,10 @@ function AppointmentCard({ appointment }: { appointment: Appointment }) { } function AppointmentRow(props: { facilityId: string; - practitioner?: string; - slot?: string; - date: string; + practitioner: string | null; + slot: string | null; + date_from: string | null; + date_to: string | null; search?: string; }) { const { t } = useTranslation(); @@ -453,16 +760,18 @@ function AppointmentRow(props: { status, props.practitioner, props.slot, - props.date, + props.date_from, + props.date_to, ], - queryFn: query(ScheduleAPIs.appointments.list, { + queryFn: query(scheduleApis.appointments.list, { pathParams: { facility_id: props.facilityId }, queryParams: { status: status, limit: 100, slot: props.slot, - user: props.practitioner, - date: props.date, + user: props.practitioner ?? undefined, + date_after: props.date_from, + date_before: props.date_to, }, }), }); @@ -614,7 +923,7 @@ const AppointmentStatusDropdown = ({ const hasStarted = isPast(appointment.token_slot.start_datetime); const { mutate: updateAppointment } = useMutation({ - mutationFn: mutate(ScheduleAPIs.appointments.update, { + mutationFn: mutate(scheduleApis.appointments.update, { pathParams: { facility_id: facilityId, id: appointment.id, @@ -682,10 +991,10 @@ const AppointmentStatusDropdown = ({ }; interface SlotFilterProps { - slots: SlotAvailability[]; + slots: TokenSlot[]; disableInline?: boolean; disabled?: boolean; - selectedSlot: string | undefined; + selectedSlot: string | null; onSelect: (slot: string) => void; } @@ -799,22 +1108,4 @@ export const SlotFilter = ({ ); - - return ( - - ); }; diff --git a/src/components/Schedule/Appointments/utils.ts b/src/components/Schedule/Appointments/utils.ts index 6460b568391..6456b6cb451 100644 --- a/src/components/Schedule/Appointments/utils.ts +++ b/src/components/Schedule/Appointments/utils.ts @@ -11,13 +11,7 @@ import { TFunction } from "i18next"; import { toast } from "sonner"; import { FacilityModel } from "@/components/Facility/models"; -import { ScheduleAPIs } from "@/components/Schedule/api"; import { getFakeTokenNumber } from "@/components/Schedule/helpers"; -import { - Appointment, - AvailabilityHeatmap, - SlotAvailability, -} from "@/components/Schedule/types"; import query from "@/Utils/request/query"; import { @@ -26,11 +20,17 @@ import { formatPatientAge, getMonthStartAndEnd, } from "@/Utils/utils"; +import { + Appointment, + AvailabilityHeatmapResponse, + TokenSlot, +} from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; -export const groupSlotsByAvailability = (slots: SlotAvailability[]) => { +export const groupSlotsByAvailability = (slots: TokenSlot[]) => { const result: { - availability: SlotAvailability["availability"]; - slots: Omit[]; + availability: TokenSlot["availability"]; + slots: Omit[]; }[] = []; for (const slot of slots) { @@ -76,7 +76,7 @@ export const useAvailabilityHeatmap = ({ const fromDate = dateQueryString(max([start, startOfToday()])); const toDate = dateQueryString(end); - let queryFn = query(ScheduleAPIs.slots.availabilityHeatmap, { + let queryFn = query(scheduleApis.slots.availabilityStats, { pathParams: { facility_id: facilityId }, body: { user: userId, @@ -105,7 +105,7 @@ const getInfiniteAvailabilityHeatmap = ({ }) => { const dates = eachDayOfInterval({ start: fromDate, end: toDate }); - const result: AvailabilityHeatmap = {}; + const result: AvailabilityHeatmapResponse = {}; for (const date of dates) { result[dateQueryString(date)] = { total_slots: Infinity, booked_slots: 0 }; diff --git a/src/components/Schedule/ScheduleExceptionForm.tsx b/src/components/Schedule/ScheduleExceptionForm.tsx index 26fded409da..2aa00d7a01c 100644 --- a/src/components/Schedule/ScheduleExceptionForm.tsx +++ b/src/components/Schedule/ScheduleExceptionForm.tsx @@ -1,7 +1,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import * as z from "zod"; @@ -28,14 +29,10 @@ import { SheetTrigger, } from "@/components/ui/sheet"; -import { ScheduleAPIs } from "@/components/Schedule/api"; - -import useSlug from "@/hooks/useSlug"; - import mutate from "@/Utils/request/mutate"; import { Time } from "@/Utils/types"; import { dateQueryString } from "@/Utils/utils"; -import { UserBase } from "@/types/user/user"; +import scheduleApis from "@/types/scheduling/scheduleApis"; const formSchema = z.object({ reason: z.string().min(1, "Reason is required"), @@ -53,13 +50,15 @@ const formSchema = z.object({ type FormValues = z.infer; interface Props { - onRefresh?: () => void; - user: UserBase; + facilityId: string; + userId: string; } -export default function ScheduleExceptionForm({ user, onRefresh }: Props) { +export default function ScheduleExceptionForm({ facilityId, userId }: Props) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); - const facilityId = useSlug("facility"); const form = useForm({ resolver: zodResolver(formSchema), @@ -73,14 +72,18 @@ export default function ScheduleExceptionForm({ user, onRefresh }: Props) { }, }); - const { - mutate: createException, - isPending, - isSuccess, - } = useMutation({ - mutationFn: mutate(ScheduleAPIs.exceptions.create, { + const { mutate: createException, isPending } = useMutation({ + mutationFn: mutate(scheduleApis.exceptions.create, { pathParams: { facility_id: facilityId }, }), + onSuccess: () => { + toast.success(t("exception_created")); + setOpen(false); + form.reset(); + queryClient.invalidateQueries({ + queryKey: ["user-schedule-exceptions", { facilityId, userId }], + }); + }, }); const unavailableAllDay = form.watch("unavailable_all_day"); @@ -95,23 +98,14 @@ export default function ScheduleExceptionForm({ user, onRefresh }: Props) { } }, [unavailableAllDay]); - useEffect(() => { - if (isSuccess) { - toast.success("Exception created successfully"); - setOpen(false); - form.reset(); - onRefresh?.(); - } - }, [isSuccess]); - - async function onSubmit(data: FormValues) { + function onSubmit(data: FormValues) { createException({ reason: data.reason, valid_from: dateQueryString(data.valid_from), valid_to: dateQueryString(data.valid_to), start_time: data.start_time, end_time: data.end_time, - user: user.id, + user: userId, }); } diff --git a/src/components/Schedule/ScheduleExceptionsList.tsx b/src/components/Schedule/ScheduleExceptionsList.tsx index 5b9290a45bd..9e66bddc9dc 100644 --- a/src/components/Schedule/ScheduleExceptionsList.tsx +++ b/src/components/Schedule/ScheduleExceptionsList.tsx @@ -11,19 +11,23 @@ import CareIcon from "@/CAREUI/icons/CareIcon"; import { Button } from "@/components/ui/button"; import Loading from "@/components/Common/Loading"; -import { ScheduleAPIs } from "@/components/Schedule/api"; -import { ScheduleException } from "@/components/Schedule/types"; - -import useSlug from "@/hooks/useSlug"; import mutate from "@/Utils/request/mutate"; import { formatTimeShort } from "@/Utils/utils"; +import { ScheduleException } from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; interface Props { items?: ScheduleException[]; + facilityId: string; + userId: string; } -export default function ScheduleExceptionsList({ items }: Props) { +export default function ScheduleExceptionsList({ + items, + facilityId, + userId, +}: Props) { const { t } = useTranslation(); if (items == null) { @@ -43,29 +47,37 @@ export default function ScheduleExceptionsList({ items }: Props) {
    {items.map((exception) => (
  • - +
  • ))}
); } -const ScheduleExceptionItem = (props: ScheduleException) => { +const ScheduleExceptionItem = ( + props: ScheduleException & { facilityId: string; userId: string }, +) => { const { t } = useTranslation(); - const facilityId = useSlug("facility"); const queryClient = useQueryClient(); const { mutate: deleteException, isPending } = useMutation({ - mutationFn: mutate(ScheduleAPIs.exceptions.delete, { + mutationFn: mutate(scheduleApis.exceptions.delete, { pathParams: { id: props.id, - facility_id: facilityId, + facility_id: props.facilityId, }, }), onSuccess: () => { toast.success(t("exception_deleted")); queryClient.invalidateQueries({ - queryKey: ["user-availability-exceptions", props.user], + queryKey: [ + "user-schedule-exceptions", + { facilityId: props.facilityId, userId: props.userId }, + ], }); }, }); diff --git a/src/components/Schedule/ScheduleTemplateEditForm.tsx b/src/components/Schedule/ScheduleTemplateEditForm.tsx new file mode 100644 index 00000000000..a2e952fe3cc --- /dev/null +++ b/src/components/Schedule/ScheduleTemplateEditForm.tsx @@ -0,0 +1,722 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ArrowRightIcon } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Trans } from "react-i18next"; +import { toast } from "sonner"; +import * as z from "zod"; + +import Callout from "@/CAREUI/display/Callout"; +import CareIcon from "@/CAREUI/icons/CareIcon"; +import WeekdayCheckbox, { + DayOfWeek, +} from "@/CAREUI/interactive/WeekdayCheckbox"; + +import { Button } from "@/components/ui/button"; +import { DatePicker } from "@/components/ui/date-picker"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; + +import { + getSlotsPerSession, + getTokenDuration, +} from "@/components/Schedule/helpers"; +import { formatAvailabilityTime } from "@/components/Users/UserAvailabilityTab"; + +import mutate from "@/Utils/request/mutate"; +import { Time } from "@/Utils/types"; +import { dateQueryString } from "@/Utils/utils"; +import { + AvailabilityDateTime, + ScheduleAvailability, + ScheduleAvailabilityCreateRequest, + ScheduleTemplate, +} from "@/types/scheduling/schedule"; +import scheduleApis from "@/types/scheduling/scheduleApis"; + +export default function ScheduleTemplateEditForm({ + template, + facilityId, + userId, +}: { + template: ScheduleTemplate; + facilityId: string; + userId: string; +}) { + const { t } = useTranslation(); + + return ( +
+ + +
+

{t("availabilities")}

+
+ + {template.availabilities.length === 0 && ( +
+

{t("no_availabilities_yet")}

+
+ )} + + {template.availabilities.map((availability) => ( + + ))} + + +
+ ); +} + +const ScheduleTemplateEditor = ({ + template, + facilityId, + userId, +}: { + template: ScheduleTemplate; + facilityId: string; + userId: string; +}) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const templateFormSchema = z.object({ + name: z.string().min(1, t("field_required")), + valid_from: z.date({ + required_error: t("field_required"), + }), + valid_to: z.date({ + required_error: t("field_required"), + }), + }); + + const form = useForm>({ + resolver: zodResolver(templateFormSchema), + defaultValues: { + name: template.name, + valid_from: new Date(template.valid_from), + valid_to: new Date(template.valid_to), + }, + }); + + const { mutate: updateTemplate, isPending } = useMutation({ + mutationFn: mutate(scheduleApis.templates.update, { + pathParams: { + facility_id: facilityId, + id: template.id, + }, + }), + onSuccess: () => { + toast.success("Schedule template updated successfully"); + queryClient.invalidateQueries({ + queryKey: ["user-schedule-templates", { facilityId, userId }], + }); + }, + }); + + function onSubmit(values: z.infer) { + updateTemplate({ + name: values.name, + valid_from: dateQueryString(values.valid_from), + valid_to: dateQueryString(values.valid_to), + }); + } + + return ( +
+
+ + ( + + {t("schedule_template_name")} + + + + + + )} + /> + +
+ ( + + {t("valid_from")} + field.onChange(date)} + /> + + + )} + /> + + ( + + {t("valid_to")} + field.onChange(date)} + /> + + + )} + /> +
+ +
+ +
+ + +
+ ); +}; + +const AvailabilityEditor = ({ + availability, + scheduleId, + facilityId, + userId, +}: { + availability: ScheduleAvailability; + scheduleId: string; + facilityId: string; + userId: string; +}) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const { mutate: deleteAvailability, isPending: isDeleting } = useMutation({ + mutationFn: mutate(scheduleApis.templates.availabilities.delete, { + pathParams: { + facility_id: facilityId, + schedule_id: scheduleId, + id: availability.id, + }, + }), + onSuccess: () => { + toast.success(t("schedule_availability_deleted_successfully")); + queryClient.invalidateQueries({ + queryKey: ["user-schedule-templates", { facilityId, userId }], + }); + }, + }); + + // Group availabilities by day of week + const availabilitiesByDay = availability.availability.reduce( + (acc, curr) => { + const day = curr.day_of_week; + if (!acc[day]) { + acc[day] = []; + } + acc[day].push(curr); + return acc; + }, + {} as Record, + ); + + // Calculate slots and duration for appointment type + const { totalSlots, tokenDuration } = (() => { + if (availability.slot_type !== "appointment") + return { totalSlots: null, tokenDuration: null }; + + const slots = Math.floor( + getSlotsPerSession( + availability.availability[0].start_time, + availability.availability[0].end_time, + availability.slot_size_in_minutes, + ) ?? 0, + ); + + const duration = getTokenDuration( + availability.slot_size_in_minutes, + availability.tokens_per_slot, + ); + + return { totalSlots: slots, tokenDuration: duration }; + })(); + + return ( +
+
+
+ + {availability.name} + + {t(`SCHEDULE_AVAILABILITY_TYPE__${availability.slot_type}`)} + +
+ + +
+ +
+ {availability.slot_type === "appointment" && ( +
+
+ + {t("slot_configuration")} + +
+ + {availability.slot_size_in_minutes} + + min + × + + {availability.tokens_per_slot} + + + patients + +
+ + ≈ {tokenDuration?.toFixed(1).replace(".0", "")} min per patient + +
+ +
+ + {t("session_capacity")} + +
+ + {totalSlots} + + slots + × + + {availability.tokens_per_slot} + + + patients + +
+ + = {totalSlots ? totalSlots * availability.tokens_per_slot : 0}{" "} + total patients + +
+
+ )} + +
+ + {t("remarks")} + +

+ {availability.reason || t("no_remarks")} +

+
+ +
+ + {t("schedule")} + +
+ {Object.entries(availabilitiesByDay).map(([day, times]) => ( +

+ + {DayOfWeek[parseInt(day)].charAt(0) + + DayOfWeek[parseInt(day)].slice(1).toLowerCase()} + + + {times + .map((time) => formatAvailabilityTime([time])) + .join(", ")} + +

+ ))} +
+
+
+
+ ); +}; + +const NewAvailabilityCard = ({ + scheduleId, + facilityId, + userId, +}: { + scheduleId: string; + facilityId: string; + userId: string; +}) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [isExpanded, setIsExpanded] = useState(false); + + const formSchema = z.object({ + name: z.string().min(1, t("field_required")), + slot_type: z.enum(["appointment", "open", "closed"]), + start_time: z + .string() + .min(1, t("field_required")) as unknown as z.ZodType