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 (
-
- {Object.values(DAYS_OF_WEEK).map((day) => {
- const isChecked = value.includes(day);
+
+ {[
+ "MONDAY",
+ "TUESDAY",
+ "WEDNESDAY",
+ "THURSDAY",
+ "FRIDAY",
+ "SATURDAY",
+ "SUNDAY",
+ ].map((day) => {
+ const dow = DayOfWeek[day as keyof typeof DayOfWeek];
+ const isSelected = value.includes(dow);
return (
-
-
-
- handleDayToggle(day)}
- />
-
-
-
+
);
})}
-
+
);
}
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 (
-
+
- {resourcesQuery.isFetching
+ {shedulableUsersQuery.isFetching
? t("searching")
: t("no_results")}
-
- setQParams({
- practitioner: undefined,
- slot: undefined,
- })
- }
- className="cursor-pointer"
- >
- {t("show_all")}
- {qParams.practitioner === undefined && (
-
- )}
-
- {resourcesQuery.data?.users.map((user) => (
+
setQParams({
- practitioner: user.id,
- slot: undefined,
+ ...qParams,
+ practitioner: null,
+ slot: null,
})
}
className="cursor-pointer"
>
-
-
-
{formatName(user)}
-
- {user.user_type}
-
-
- {qParams.practitioner === user.id && (
+ {t("show_all")}
+ {!qParams.practitioner && (
)}
+
+ {shedulableUsersQuery.data?.users.map((user) => (
+
+
+ setQParams({
+ ...qParams,
+ practitioner: user.username,
+ slot: null,
+ })
+ }
+ className="cursor-pointer"
+ >
+
+
+
{formatName(user)}
+
+ {user.user_type}
+
+
+ {qParams.practitioner === user.username && (
+
+ )}
+
+
))}
@@ -233,21 +402,136 @@ export default function AppointmentsPage(props: { facilityId?: string }) {
-
+
+
+
+
+
+
+
+
+
+
+
+ {
+ const today = new Date();
+ setQParams({
+ ...qParams,
+ date_from: dateQueryString(subDays(today, 14)),
+ date_to: dateQueryString(today),
+ slot: null,
+ });
+ }}
+ >
+ {t("last_fortnight_short")}
+
+
+ {
+ const today = new Date();
+ setQParams({
+ ...qParams,
+ date_from: dateQueryString(subDays(today, 7)),
+ date_to: dateQueryString(today),
+ slot: null,
+ });
+ }}
+ >
+ {t("last_week_short")}
+
+
+ {
+ const today = new Date();
+ setQParams({
+ ...qParams,
+ date_from: dateQueryString(today),
+ date_to: dateQueryString(today),
+ slot: null,
+ });
+ }}
+ >
+ {t("today")}
+
+
+ {
+ const today = new Date();
+ setQParams({
+ ...qParams,
+ date_from: dateQueryString(today),
+ date_to: dateQueryString(addDays(today, 7)),
+ slot: null,
+ });
+ }}
+ >
+ {t("next_week_short")}
+
+
+ {
+ const today = new Date();
+ setQParams({
+ ...qParams,
+ date_from: dateQueryString(today),
+ date_to: dateQueryString(addDays(today, 14)),
+ slot: null,
+ });
+ }}
+ >
+ {t("next_fortnight_short")}
+
+
+
+
+ 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,
})
}
/>
-
_setQParams({})}>
+
+ setQParams({
+ date_from: null,
+ date_to: null,
+ slot: null,
+ search: null,
+ practitioner: null,
+ })
+ }
+ >
{t("clear_all_filters")}
@@ -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 (
+
+ );
+};
+
+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}`)}
+
+
+
+
deleteAvailability()}
+ disabled={isDeleting}
+ className="text-red-600 hover:text-red-700 hover:bg-red-50"
+ >
+
+
+
+
+
+ {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