@kenyaemr/esm-appointments-app 7.0.2-pre.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/dist/130.js +2 -0
  3. package/dist/130.js.LICENSE.txt +3 -0
  4. package/dist/130.js.map +1 -0
  5. package/dist/152.js +1 -0
  6. package/dist/152.js.map +1 -0
  7. package/dist/224.js +1 -0
  8. package/dist/224.js.map +1 -0
  9. package/dist/255.js +2 -0
  10. package/dist/255.js.LICENSE.txt +9 -0
  11. package/dist/255.js.map +1 -0
  12. package/dist/271.js +1 -0
  13. package/dist/303.js +1 -0
  14. package/dist/303.js.map +1 -0
  15. package/dist/309.js +1 -0
  16. package/dist/309.js.map +1 -0
  17. package/dist/319.js +1 -0
  18. package/dist/4.js +1 -0
  19. package/dist/4.js.map +1 -0
  20. package/dist/445.js +2 -0
  21. package/dist/445.js.LICENSE.txt +54 -0
  22. package/dist/445.js.map +1 -0
  23. package/dist/460.js +1 -0
  24. package/dist/501.js +1 -0
  25. package/dist/501.js.map +1 -0
  26. package/dist/574.js +1 -0
  27. package/dist/591.js +2 -0
  28. package/dist/591.js.LICENSE.txt +32 -0
  29. package/dist/591.js.map +1 -0
  30. package/dist/644.js +1 -0
  31. package/dist/729.js +1 -0
  32. package/dist/729.js.map +1 -0
  33. package/dist/757.js +1 -0
  34. package/dist/784.js +2 -0
  35. package/dist/784.js.LICENSE.txt +9 -0
  36. package/dist/784.js.map +1 -0
  37. package/dist/788.js +1 -0
  38. package/dist/807.js +1 -0
  39. package/dist/833.js +1 -0
  40. package/dist/857.js +2 -0
  41. package/dist/857.js.LICENSE.txt +5 -0
  42. package/dist/857.js.map +1 -0
  43. package/dist/904.js +1 -0
  44. package/dist/904.js.map +1 -0
  45. package/dist/kenyaemr-esm-appointments-app.js +1 -0
  46. package/dist/kenyaemr-esm-appointments-app.js.buildmanifest.json +699 -0
  47. package/dist/kenyaemr-esm-appointments-app.js.map +1 -0
  48. package/dist/main.js +2 -0
  49. package/dist/main.js.LICENSE.txt +64 -0
  50. package/dist/main.js.map +1 -0
  51. package/dist/routes.json +1 -0
  52. package/jest.config.js +3 -0
  53. package/package.json +57 -0
  54. package/src/admin/appointment-services/appointment-services-hook.ts +31 -0
  55. package/src/admin/appointment-services/appointment-services-validation.ts +17 -0
  56. package/src/admin/appointment-services/appointment-services.component.tsx +182 -0
  57. package/src/admin/appointment-services/appointment-services.scss +25 -0
  58. package/src/appointments/appointment-tabs.component.tsx +48 -0
  59. package/src/appointments/appointment-tabs.scss +53 -0
  60. package/src/appointments/appointment-tabs.test.tsx +55 -0
  61. package/src/appointments/common-components/appointments-actions.component.tsx +86 -0
  62. package/src/appointments/common-components/appointments-actions.scss +4 -0
  63. package/src/appointments/common-components/appointments-actions.test.tsx +201 -0
  64. package/src/appointments/common-components/appointments-table.component.tsx +277 -0
  65. package/src/appointments/common-components/appointments-table.scss +133 -0
  66. package/src/appointments/common-components/appointments-table.test.tsx +134 -0
  67. package/src/appointments/common-components/checkin-button.component.tsx +43 -0
  68. package/src/appointments/common-components/end-appointment-modal.component.tsx +104 -0
  69. package/src/appointments/common-components/end-appointment-modal.test.tsx +80 -0
  70. package/src/appointments/common-components/location-select-option.component.tsx +48 -0
  71. package/src/appointments/details/appointment-details.component.tsx +91 -0
  72. package/src/appointments/details/appointment-details.scss +81 -0
  73. package/src/appointments/details/appointment-details.test.tsx +103 -0
  74. package/src/appointments/scheduled/appointments-list.component.tsx +33 -0
  75. package/src/appointments/scheduled/early-appointments.component.tsx +32 -0
  76. package/src/appointments/scheduled/scheduled-appointments.component.tsx +215 -0
  77. package/src/appointments/scheduled/scheduled-appointments.scss +4 -0
  78. package/src/appointments/unscheduled/unscheduled-appointments.component.tsx +146 -0
  79. package/src/appointments/unscheduled/unscheduled-appointments.test.tsx +131 -0
  80. package/src/appointments/utils.tsx +80 -0
  81. package/src/appointments.component.tsx +44 -0
  82. package/src/appointments.test.tsx +15 -0
  83. package/src/calendar/appointments-calendar-view-view.scss +24 -0
  84. package/src/calendar/appointments-calendar-view.component.tsx +36 -0
  85. package/src/calendar/appointments-calendar-view.test.tsx +22 -0
  86. package/src/calendar/header/calendar-header.component.tsx +34 -0
  87. package/src/calendar/header/calendar-header.scss +32 -0
  88. package/src/calendar/monthly/days-of-week.component.tsx +16 -0
  89. package/src/calendar/monthly/days-of-week.scss +33 -0
  90. package/src/calendar/monthly/monthly-calendar-view.component.tsx +34 -0
  91. package/src/calendar/monthly/monthly-header.module.scss +14 -0
  92. package/src/calendar/monthly/monthly-header.module.tsx +40 -0
  93. package/src/calendar/monthly/monthly-view-workload.scss +188 -0
  94. package/src/calendar/monthly/monthly-workload-view-expanded.component.tsx +42 -0
  95. package/src/calendar/monthly/monthly-workload-view.component.tsx +109 -0
  96. package/src/config-schema.ts +151 -0
  97. package/src/constants.ts +55 -0
  98. package/src/createDashboardLink.component.tsx +39 -0
  99. package/src/dashboard.meta.ts +21 -0
  100. package/src/declarations.d.ts +4 -0
  101. package/src/empty-state/empty-data-illustration.component.tsx +39 -0
  102. package/src/empty-state/empty-state.component.tsx +32 -0
  103. package/src/empty-state/empty-state.scss +69 -0
  104. package/src/form/appointments-form.component.tsx +891 -0
  105. package/src/form/appointments-form.resource.ts +165 -0
  106. package/src/form/appointments-form.scss +113 -0
  107. package/src/form/appointments-form.test.tsx +212 -0
  108. package/src/header/appointments-header.component.tsx +79 -0
  109. package/src/header/appointments-header.scss +95 -0
  110. package/src/header/appointments-illustration.component.tsx +22 -0
  111. package/src/helpers/excel.ts +61 -0
  112. package/src/helpers/functions.ts +82 -0
  113. package/src/helpers/index.ts +2 -0
  114. package/src/helpers/time.tsx +15 -0
  115. package/src/home/home-appointments.component.tsx +22 -0
  116. package/src/home/home-appointments.scss +10 -0
  117. package/src/hooks/patientAppointmentContext.ts +15 -0
  118. package/src/hooks/selectedDateContext.ts +10 -0
  119. package/src/hooks/useAppointmentList.ts +48 -0
  120. package/src/hooks/useAppointmentService.ts +11 -0
  121. package/src/hooks/useAppointmentsCalendar.ts +68 -0
  122. package/src/hooks/useClinicalMetrics.ts +79 -0
  123. package/src/hooks/useDefaultLocation.ts +14 -0
  124. package/src/hooks/useOverlay.tsx +45 -0
  125. package/src/hooks/usePatientAppointmentHistory.ts +49 -0
  126. package/src/hooks/useProviders.ts +18 -0
  127. package/src/hooks/useTodaysVisits.ts +19 -0
  128. package/src/hooks/useUnscheduledAppointments.ts +45 -0
  129. package/src/index.ts +111 -0
  130. package/src/metrics/appointments-metrics.component.tsx +71 -0
  131. package/src/metrics/appointments-metrics.scss +15 -0
  132. package/src/metrics/appointments-metrics.test.tsx +49 -0
  133. package/src/metrics/metrics-card.component.tsx +76 -0
  134. package/src/metrics/metrics-card.scss +77 -0
  135. package/src/metrics/metrics-header.component.tsx +62 -0
  136. package/src/metrics/metrics-header.scss +33 -0
  137. package/src/past-visit/encounter-list.component.tsx +54 -0
  138. package/src/past-visit/past-visit.component.tsx +106 -0
  139. package/src/past-visit/past-visit.resource.ts +25 -0
  140. package/src/past-visit/past-visit.scss +106 -0
  141. package/src/patient-appointments/patient-appointments-action-menu.component.tsx +65 -0
  142. package/src/patient-appointments/patient-appointments-action-menu.scss +7 -0
  143. package/src/patient-appointments/patient-appointments-base.component.tsx +165 -0
  144. package/src/patient-appointments/patient-appointments-base.scss +85 -0
  145. package/src/patient-appointments/patient-appointments-base.test.tsx +91 -0
  146. package/src/patient-appointments/patient-appointments-cancel-modal.component.tsx +66 -0
  147. package/src/patient-appointments/patient-appointments-detailed-summary.component.tsx +15 -0
  148. package/src/patient-appointments/patient-appointments-header.scss +27 -0
  149. package/src/patient-appointments/patient-appointments-header.tsx +42 -0
  150. package/src/patient-appointments/patient-appointments-overview.component.tsx +35 -0
  151. package/src/patient-appointments/patient-appointments-overview.scss +7 -0
  152. package/src/patient-appointments/patient-appointments-table.scss +0 -0
  153. package/src/patient-appointments/patient-appointments-table.tsx +128 -0
  154. package/src/patient-appointments/patient-appointments.resource.ts +72 -0
  155. package/src/patient-appointments/patient-upcoming-appointments-card.component.tsx +122 -0
  156. package/src/patient-appointments/patient-upcoming-appointments-card.scss +46 -0
  157. package/src/patient-search/patient-search.component.tsx +34 -0
  158. package/src/patient-search/patient-search.scss +23 -0
  159. package/src/root.component.tsx +26 -0
  160. package/src/root.scss +50 -0
  161. package/src/routes.json +153 -0
  162. package/src/scheduled-appointments-config-schema.ts +169 -0
  163. package/src/types/index.ts +189 -0
  164. package/src/workload/monthly-view-workload/monthly-view.component.tsx +69 -0
  165. package/src/workload/monthly-view-workload/monthly-workload.scss +223 -0
  166. package/src/workload/monthly-view-workload/monthlyWorkCard.tsx +45 -0
  167. package/src/workload/workload-card.component.tsx +31 -0
  168. package/src/workload/workload.component.tsx +47 -0
  169. package/src/workload/workload.resource.ts +78 -0
  170. package/src/workload/workload.scss +92 -0
  171. package/translations/am.json +148 -0
  172. package/translations/ar.json +148 -0
  173. package/translations/en.json +159 -0
  174. package/translations/es.json +148 -0
  175. package/translations/fr.json +148 -0
  176. package/translations/he.json +148 -0
  177. package/translations/km.json +148 -0
  178. package/translations/zh.json +148 -0
  179. package/translations/zh_CN.json +148 -0
  180. package/tsconfig.json +5 -0
  181. package/webpack.config.js +1 -0
@@ -0,0 +1,891 @@
1
+ import React, { useContext, useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import dayjs from 'dayjs';
4
+ import {
5
+ Button,
6
+ ButtonSet,
7
+ DatePicker,
8
+ DatePickerInput,
9
+ Form,
10
+ InlineLoading,
11
+ MultiSelect,
12
+ NumberInput,
13
+ RadioButton,
14
+ RadioButtonGroup,
15
+ Select,
16
+ SelectItem,
17
+ Stack,
18
+ TextArea,
19
+ TimePicker,
20
+ TimePickerSelect,
21
+ Toggle,
22
+ } from '@carbon/react';
23
+ import { Controller, useController, useForm } from 'react-hook-form';
24
+ import { zodResolver } from '@hookform/resolvers/zod';
25
+ import { z } from 'zod';
26
+ import {
27
+ ExtensionSlot,
28
+ ResponsiveWrapper,
29
+ showSnackbar,
30
+ translateFrom,
31
+ useConfig,
32
+ useLayoutType,
33
+ useLocations,
34
+ usePatient,
35
+ useSession,
36
+ type FetchResponse,
37
+ } from '@openmrs/esm-framework';
38
+ import {
39
+ checkAppointmentConflict,
40
+ saveAppointment,
41
+ saveRecurringAppointments,
42
+ useAppointmentService,
43
+ useMutateAppointments,
44
+ } from './appointments-form.resource';
45
+ import { useProviders } from '../hooks/useProviders';
46
+ import Workload from '../workload/workload.component';
47
+ import type { Appointment, AppointmentPayload, RecurringPattern } from '../types';
48
+ import { type ConfigObject } from '../config-schema';
49
+ import {
50
+ appointmentLocationTagName,
51
+ dateFormat,
52
+ datePickerFormat,
53
+ datePickerPlaceHolder,
54
+ moduleName,
55
+ weekDays,
56
+ } from '../constants';
57
+ import styles from './appointments-form.scss';
58
+ import SelectedDateContext from '../hooks/selectedDateContext';
59
+ import uniqBy from 'lodash/uniqBy';
60
+
61
+ const time12HourFormatRegexPattern = '^(1[0-2]|0?[1-9]):[0-5][0-9]$';
62
+
63
+ function isValidTime(timeStr) {
64
+ return timeStr.match(new RegExp(time12HourFormatRegexPattern));
65
+ }
66
+
67
+ interface AppointmentsFormProps {
68
+ appointment?: Appointment;
69
+ recurringPattern?: RecurringPattern;
70
+ patientUuid?: string;
71
+ context: string;
72
+ closeWorkspace: () => void;
73
+ }
74
+
75
+ const AppointmentsForm: React.FC<AppointmentsFormProps> = ({
76
+ appointment,
77
+ recurringPattern,
78
+ patientUuid,
79
+ context,
80
+ closeWorkspace,
81
+ }) => {
82
+ const { patient } = usePatient(patientUuid);
83
+ const { mutateAppointments } = useMutateAppointments();
84
+ const editedAppointmentTimeFormat = new Date(appointment?.startDateTime).getHours() >= 12 ? 'PM' : 'AM';
85
+ const defaultTimeFormat = appointment?.startDateTime
86
+ ? editedAppointmentTimeFormat
87
+ : new Date().getHours() >= 12
88
+ ? 'PM'
89
+ : 'AM';
90
+ const { t } = useTranslation();
91
+ const isTablet = useLayoutType() === 'tablet';
92
+ const locations = useLocations(appointmentLocationTagName);
93
+ const providers = useProviders();
94
+ const session = useSession();
95
+ const { selectedDate } = useContext(SelectedDateContext);
96
+ const { data: services, isLoading } = useAppointmentService();
97
+ const { appointmentStatuses, appointmentTypes, allowAllDayAppointments } = useConfig<ConfigObject>();
98
+
99
+ const [isRecurringAppointment, setIsRecurringAppointment] = useState(false);
100
+ const [isAllDayAppointment, setIsAllDayAppointment] = useState(false);
101
+ const [isConflict, setIsConflict] = useState(false);
102
+ const defaultRecurringPatternType = recurringPattern?.type || 'DAY';
103
+ const defaultRecurringPatternPeriod = recurringPattern?.period || 1;
104
+ const defaultRecurringPatternDaysOfWeek = recurringPattern?.daysOfWeek || [];
105
+
106
+ const [isSubmitting, setIsSubmitting] = useState(false);
107
+
108
+ // TODO can we clean this all up to be more consistent between using Date and dayjs?
109
+ const defaultStartDate = appointment?.startDateTime
110
+ ? new Date(appointment?.startDateTime)
111
+ : selectedDate
112
+ ? new Date(selectedDate)
113
+ : new Date();
114
+ const defaultEndDate = recurringPattern?.endDate ? new Date(recurringPattern?.endDate) : null;
115
+ const defaultEndDateText = recurringPattern?.endDate
116
+ ? dayjs(new Date(recurringPattern.endDate)).format(dateFormat)
117
+ : '';
118
+ const defaultStartDateText = appointment?.startDateTime
119
+ ? dayjs(new Date(appointment.startDateTime)).format(dateFormat)
120
+ : selectedDate
121
+ ? dayjs(selectedDate).format(dateFormat)
122
+ : dayjs(new Date()).format(dateFormat);
123
+
124
+ const defaultAppointmentStartTime = appointment?.startDateTime
125
+ ? dayjs(new Date(appointment?.startDateTime)).format('hh:mm')
126
+ : dayjs(new Date()).format('hh:mm');
127
+
128
+ const defaultDuration =
129
+ appointment?.startDateTime && appointment?.endDateTime
130
+ ? dayjs(appointment.endDateTime).diff(dayjs(appointment.startDateTime), 'minutes')
131
+ : null;
132
+
133
+ // t('durationErrorMessage', 'Duration should be greater than zero')
134
+ const appointmentsFormSchema = z
135
+ .object({
136
+ duration: z
137
+ .number()
138
+ .nullable()
139
+ .refine((duration) => (isAllDayAppointment ? true : duration > 0), {
140
+ message: translateFrom(moduleName, 'durationErrorMessage', 'Duration should be greater than zero'),
141
+ }),
142
+ location: z.string().refine((value) => value !== ''),
143
+ provider: z.string().refine((value) => value !== ''),
144
+ appointmentStatus: z.string().optional(),
145
+ appointmentNote: z.string(),
146
+ appointmentType: z.string().refine((value) => value !== ''),
147
+ selectedService: z.string().refine((value) => value !== ''),
148
+ recurringPatternType: z.enum(['DAY', 'WEEK']),
149
+ recurringPatternPeriod: z.number(),
150
+ recurringPatternDaysOfWeek: z.array(z.string()),
151
+ selectedDaysOfWeekText: z.string().optional(),
152
+ startTime: z.string().refine((value) => isValidTime(value)),
153
+ timeFormat: z.enum(['AM', 'PM']),
154
+ appointmentDateTime: z.object({
155
+ startDate: z.date(),
156
+ startDateText: z.string(),
157
+ recurringPatternEndDate: z.date().nullable(),
158
+ recurringPatternEndDateText: z.string().nullable(),
159
+ }),
160
+ formIsRecurringAppointment: z.boolean(),
161
+ dateAppointmentScheduled: z.date().optional(),
162
+ })
163
+ .refine(
164
+ (formValues) => {
165
+ if (formValues.formIsRecurringAppointment === true) {
166
+ return z.date().safeParse(formValues.appointmentDateTime.recurringPatternEndDate).success;
167
+ }
168
+ return true;
169
+ },
170
+ {
171
+ path: ['appointmentDateTime.recurringPatternEndDate'],
172
+ message: 'A recurring appointment should have an end date',
173
+ },
174
+ );
175
+
176
+ type AppointmentFormData = z.infer<typeof appointmentsFormSchema>;
177
+
178
+ const defaultDateAppointmentScheduled = appointment?.dateAppointmentScheduled
179
+ ? new Date(appointment?.dateAppointmentScheduled)
180
+ : new Date();
181
+
182
+ const { control, getValues, setValue, watch, handleSubmit } = useForm<AppointmentFormData>({
183
+ mode: 'all',
184
+ resolver: zodResolver(appointmentsFormSchema),
185
+ defaultValues: {
186
+ location: appointment?.location?.uuid ?? session?.sessionLocation?.uuid ?? '',
187
+ provider:
188
+ appointment?.providers?.find((provider) => provider.response === 'ACCEPTED')?.uuid ??
189
+ session?.currentProvider?.uuid ??
190
+ '', // assumes only a single previously-scheduled provider with state "ACCEPTED", if multiple, just takes the first
191
+ appointmentNote: appointment?.comments || '',
192
+ appointmentStatus: appointment?.status || '',
193
+ appointmentType: appointment?.appointmentKind || '',
194
+ selectedService: appointment?.service?.name || '',
195
+ recurringPatternType: defaultRecurringPatternType,
196
+ recurringPatternPeriod: defaultRecurringPatternPeriod,
197
+ recurringPatternDaysOfWeek: defaultRecurringPatternDaysOfWeek,
198
+ startTime: defaultAppointmentStartTime,
199
+ duration: defaultDuration,
200
+ timeFormat: defaultTimeFormat,
201
+ appointmentDateTime: {
202
+ startDate: defaultStartDate,
203
+ startDateText: defaultStartDateText,
204
+ recurringPatternEndDate: defaultEndDate,
205
+ recurringPatternEndDateText: defaultEndDateText,
206
+ },
207
+ formIsRecurringAppointment: isRecurringAppointment,
208
+ dateAppointmentScheduled: defaultDateAppointmentScheduled,
209
+ },
210
+ });
211
+
212
+ useEffect(() => setValue('formIsRecurringAppointment', isRecurringAppointment), [isRecurringAppointment]);
213
+
214
+ // Retrive ref callback for appointmentDateTime (startDate & recurringPatternEndDate)
215
+ const {
216
+ field: { ref: startDateRef },
217
+ } = useController({ name: 'appointmentDateTime.startDate', control });
218
+ const {
219
+ field: { ref: endDateRef },
220
+ } = useController({ name: 'appointmentDateTime.recurringPatternEndDate', control });
221
+
222
+ // Manually call ref callback from 'react-hook-form' with the element(s) we want to be focused
223
+ useEffect(() => {
224
+ const startDateElement = document.getElementById('startDatePickerInput');
225
+ const endDateElement = document.getElementById('endDatePickerInput');
226
+ startDateRef(startDateElement);
227
+ endDateRef(endDateElement);
228
+ }, [startDateRef, endDateRef]);
229
+
230
+ const handleWorkloadDateChange = (date: Date) => {
231
+ const appointmentDate = getValues('appointmentDateTime');
232
+ setValue('appointmentDateTime', { ...appointmentDate, startDate: date });
233
+ };
234
+
235
+ const handleMultiselectChange = (e) => {
236
+ setValue(
237
+ 'selectedDaysOfWeekText',
238
+ (() => {
239
+ if (e?.selectedItems?.length < 1) {
240
+ return t('daysOfWeek', 'Days of the week');
241
+ } else {
242
+ return e.selectedItems
243
+ .map((weekDay) => {
244
+ return weekDay.label;
245
+ })
246
+ .join(', ');
247
+ }
248
+ })(),
249
+ );
250
+ setValue(
251
+ 'recurringPatternDaysOfWeek',
252
+ e.selectedItems.map((s) => {
253
+ return s.id;
254
+ }),
255
+ );
256
+ };
257
+
258
+ const defaultSelectedDaysOfWeekText: string = (() => {
259
+ if (getValues('recurringPatternDaysOfWeek')?.length < 1) {
260
+ return t('daysOfWeek', 'Days of the week');
261
+ } else {
262
+ return weekDays
263
+ .filter((weekDay) => getValues('recurringPatternDaysOfWeek').includes(weekDay.id))
264
+ .map((weekDay) => {
265
+ return weekDay.label;
266
+ })
267
+ .join(', ');
268
+ }
269
+ })();
270
+
271
+ // Same for creating and editing
272
+ const handleSaveAppointment = async (data: AppointmentFormData) => {
273
+ setIsSubmitting(true);
274
+ // Construct appointment payload
275
+ const appointmentPayload = constructAppointmentPayload(data);
276
+
277
+ // check if Duplicate Response Occurs
278
+ // const response: FetchResponse = await checkAppointmentConflict(appointmentPayload);
279
+ // let errorMessage = t('appointmentConflict', 'Appointment conflict');
280
+ // if (response?.data?.hasOwnProperty('SERVICE_UNAVAILABLE')) {
281
+ // errorMessage = t('serviceUnavailable', 'Appointment time is outside of service hours');
282
+ // } else if (response?.data?.hasOwnProperty('PATIENT_DOUBLE_BOOKING')) {
283
+ // errorMessage = t('patientDoubleBooking', 'Patient already booked for an appointment at this time');
284
+ // }
285
+ // if (response.status === 200) {
286
+ // setIsSubmitting(false);
287
+ // showSnackbar({
288
+ // isLowContrast: true,
289
+ // kind: 'error',
290
+ // title: errorMessage,
291
+ // });
292
+ // return;
293
+ // }
294
+
295
+ // Construct recurring pattern payload
296
+ const recurringAppointmentPayload = {
297
+ appointmentRequest: appointmentPayload,
298
+ recurringPattern: constructRecurringPattern(data),
299
+ };
300
+ const abortController = new AbortController();
301
+ (isRecurringAppointment
302
+ ? saveRecurringAppointments(recurringAppointmentPayload, abortController)
303
+ : saveAppointment(appointmentPayload, abortController)
304
+ ).then(
305
+ ({ status }) => {
306
+ if (status === 200) {
307
+ setIsSubmitting(false);
308
+ closeWorkspace();
309
+ mutateAppointments();
310
+ showSnackbar({
311
+ isLowContrast: true,
312
+ kind: 'success',
313
+ subtitle: t('appointmentNowVisible', 'It is now visible on the Appointments page'),
314
+ title:
315
+ context === 'editing'
316
+ ? t('appointmentEdited', 'Appointment edited')
317
+ : t('appointmentScheduled', 'Appointment scheduled'),
318
+ });
319
+ }
320
+ if (status === 204) {
321
+ setIsSubmitting(false);
322
+ showSnackbar({
323
+ title:
324
+ context === 'editing'
325
+ ? t('appointmentEditError', 'Error editing appointment')
326
+ : t('appointmentFormError', 'Error scheduling appointment'),
327
+ kind: 'error',
328
+ isLowContrast: false,
329
+ subtitle: t('noContent', 'No Content'),
330
+ });
331
+ }
332
+ },
333
+ (error) => {
334
+ setIsSubmitting(false);
335
+ showSnackbar({
336
+ title:
337
+ context === 'editing'
338
+ ? t('appointmentEditError', 'Error editing appointment')
339
+ : t('appointmentFormError', 'Error scheduling appointment'),
340
+ kind: 'error',
341
+ isLowContrast: false,
342
+ subtitle: error?.message,
343
+ });
344
+ },
345
+ );
346
+ };
347
+
348
+ const constructAppointmentPayload = (data: AppointmentFormData): AppointmentPayload => {
349
+ const {
350
+ selectedService,
351
+ startTime,
352
+ timeFormat,
353
+ appointmentDateTime: { startDate },
354
+ duration,
355
+ appointmentType: selectedAppointmentType,
356
+ location,
357
+ provider,
358
+ appointmentNote,
359
+ appointmentStatus,
360
+ dateAppointmentScheduled,
361
+ } = data;
362
+
363
+ const serviceUuid = services?.find((service) => service.name === selectedService)?.uuid;
364
+ const hoursAndMinutes = startTime.split(':').map((item) => parseInt(item, 10));
365
+ const hours = (hoursAndMinutes[0] % 12) + (timeFormat === 'PM' ? 12 : 0);
366
+ const minutes = hoursAndMinutes[1];
367
+ const startDatetime = startDate.setHours(hours, minutes);
368
+ const endDatetime = dayjs(startDatetime).add(duration, 'minutes').toDate();
369
+
370
+ return {
371
+ appointmentKind: selectedAppointmentType,
372
+ status: appointmentStatus,
373
+ serviceUuid: serviceUuid,
374
+ startDateTime: dayjs(startDatetime).format(),
375
+ endDateTime: dayjs(endDatetime).format(),
376
+ locationUuid: location,
377
+ providers: [{ uuid: provider }],
378
+ patientUuid: patientUuid,
379
+ comments: appointmentNote,
380
+ uuid: context === 'editing' ? appointment.uuid : undefined,
381
+ dateAppointmentScheduled: dayjs(dateAppointmentScheduled).format(),
382
+ };
383
+ };
384
+
385
+ const constructRecurringPattern = (data: AppointmentFormData): RecurringPattern => {
386
+ const {
387
+ appointmentDateTime: { recurringPatternEndDate },
388
+ recurringPatternType,
389
+ recurringPatternPeriod,
390
+ recurringPatternDaysOfWeek,
391
+ } = data;
392
+
393
+ const [hours, minutes] = [23, 59];
394
+ const endDate = recurringPatternEndDate?.setHours(hours, minutes);
395
+
396
+ return {
397
+ type: recurringPatternType,
398
+ period: recurringPatternPeriod,
399
+ endDate: endDate ? dayjs(endDate).format() : null,
400
+ daysOfWeek: recurringPatternDaysOfWeek,
401
+ };
402
+ };
403
+
404
+ const onError = (error) => console.error(error);
405
+
406
+ if (isLoading)
407
+ return (
408
+ <InlineLoading className={styles.loader} description={`${t('loading', 'Loading')} ...`} role="progressbar" />
409
+ );
410
+
411
+ return (
412
+ <Form onSubmit={handleSubmit(handleSaveAppointment, onError)}>
413
+ <Stack gap={4}>
414
+ {patient && (
415
+ <ExtensionSlot
416
+ name="patient-header-slot"
417
+ state={{
418
+ patient,
419
+ patientUuid: patientUuid,
420
+ hideActionsOverflow: true,
421
+ }}
422
+ />
423
+ )}
424
+ <section className={styles.formGroup}>
425
+ <span className={styles.heading}>{t('location', 'Location')}</span>
426
+ <ResponsiveWrapper>
427
+ <Controller
428
+ name="location"
429
+ control={control}
430
+ render={({ field: { onChange, value, onBlur, ref } }) => (
431
+ <Select
432
+ id="location"
433
+ invalidText="Required"
434
+ labelText={t('selectALocation', 'Select a location')}
435
+ onChange={onChange}
436
+ onBlur={onBlur}
437
+ value={value}
438
+ ref={ref}>
439
+ <SelectItem text={t('chooseLocation', 'Choose a location')} value="" />
440
+ {locations?.length > 0 &&
441
+ locations.map((location) => (
442
+ <SelectItem key={location.uuid} text={location.display} value={location.uuid}>
443
+ {location.display}
444
+ </SelectItem>
445
+ ))}
446
+ </Select>
447
+ )}
448
+ />
449
+ </ResponsiveWrapper>
450
+ </section>
451
+ <section className={styles.formGroup}>
452
+ <span className={styles.heading}>{t('dateScheduled', 'Date appointment issued')}</span>
453
+ <ResponsiveWrapper>
454
+ <Controller
455
+ name="dateAppointmentScheduled"
456
+ control={control}
457
+ render={({ field: { onChange, value, ref } }) => (
458
+ <DatePicker
459
+ datePickerType="single"
460
+ dateFormat={datePickerFormat}
461
+ value={value}
462
+ maxDate={new Date()}
463
+ onChange={([date]) => onChange(date)}>
464
+ <DatePickerInput
465
+ id="dateAppointmentScheduledPickerInput"
466
+ labelText={t('dateScheduledDetail', 'Date appointment issued')}
467
+ style={{ width: '100%' }}
468
+ placeholder={datePickerPlaceHolder}
469
+ ref={ref}
470
+ />
471
+ </DatePicker>
472
+ )}
473
+ />
474
+ </ResponsiveWrapper>
475
+ </section>
476
+ <section className={styles.formGroup}>
477
+ <span className={styles.heading}>{t('service', 'Service')}</span>
478
+ <ResponsiveWrapper>
479
+ <Controller
480
+ name="selectedService"
481
+ control={control}
482
+ render={({ field: { onBlur, onChange, value, ref } }) => (
483
+ <Select
484
+ id="service"
485
+ invalidText="Required"
486
+ labelText={t('selectService', 'Select a service')}
487
+ onChange={(event) => {
488
+ onChange(event);
489
+ setValue(
490
+ 'duration',
491
+ services?.find((service) => service.name === event.target.value)?.durationMins,
492
+ );
493
+ }}
494
+ onBlur={onBlur}
495
+ value={value}
496
+ ref={ref}>
497
+ <SelectItem text={t('chooseService', 'Select service')} value="" />
498
+ {services?.length > 0 &&
499
+ services.map((service) => (
500
+ <SelectItem key={service.uuid} text={service.name} value={service.name}>
501
+ {service.name}
502
+ </SelectItem>
503
+ ))}
504
+ </Select>
505
+ )}
506
+ />
507
+ </ResponsiveWrapper>
508
+ </section>
509
+
510
+ <section className={styles.formGroup}>
511
+ <span className={styles.heading}>{t('appointmentType_title', 'Appointment Type')}</span>
512
+ <ResponsiveWrapper>
513
+ <Controller
514
+ name="appointmentType"
515
+ control={control}
516
+ render={({ field: { onBlur, onChange, value, ref } }) => (
517
+ <Select
518
+ disabled={!appointmentTypes?.length}
519
+ id="appointmentType"
520
+ invalidText="Required"
521
+ labelText={t('selectAppointmentType', 'Select the type of appointment')}
522
+ onChange={onChange}
523
+ value={value}
524
+ ref={ref}
525
+ onBlur={onBlur}>
526
+ <SelectItem text={t('chooseAppointmentType', 'Choose appointment type')} value="" />
527
+ {appointmentTypes?.length > 0 &&
528
+ appointmentTypes.map((appointmentType, index) => (
529
+ <SelectItem key={index} text={appointmentType} value={appointmentType}>
530
+ {appointmentType}
531
+ </SelectItem>
532
+ ))}
533
+ </Select>
534
+ )}
535
+ />
536
+ </ResponsiveWrapper>
537
+ </section>
538
+
539
+ <section className={styles.formGroup}>
540
+ <span className={styles.heading}>{t('recurringAppointment', 'Recurring Appointment')}</span>
541
+ <Toggle
542
+ id="recurringToggle"
543
+ labelB={t('yes', 'Yes')}
544
+ labelA={t('no', 'No')}
545
+ labelText={t('isRecurringAppointment', 'Is this a recurring appointment?')}
546
+ onClick={() => setIsRecurringAppointment(!isRecurringAppointment)}
547
+ />
548
+ </section>
549
+
550
+ <section className={styles.formGroup}>
551
+ <span className={styles.heading}>{t('dateTime', 'Date & Time')}</span>
552
+ <div className={styles.dateTimeFields}>
553
+ {isRecurringAppointment && (
554
+ <div className={styles.inputContainer}>
555
+ {allowAllDayAppointments && (
556
+ <Toggle
557
+ id="allDayToggle"
558
+ labelB={t('yes', 'Yes')}
559
+ labelA={t('no', 'No')}
560
+ labelText={t('allDay', 'All day')}
561
+ onClick={() => setIsAllDayAppointment(!isAllDayAppointment)}
562
+ toggled={isAllDayAppointment}
563
+ />
564
+ )}
565
+ <ResponsiveWrapper>
566
+ <Controller
567
+ name="appointmentDateTime"
568
+ control={control}
569
+ render={({ field: { onChange, value } }) => (
570
+ <ResponsiveWrapper>
571
+ <DatePicker
572
+ datePickerType="range"
573
+ dateFormat={datePickerFormat}
574
+ value={[value.startDate, value.recurringPatternEndDate]}
575
+ onChange={([startDate, endDate]) => {
576
+ onChange({
577
+ startDate: new Date(startDate),
578
+ recurringPatternEndDate: new Date(endDate),
579
+ recurringPatternEndDateText: dayjs(new Date(endDate)).format(dateFormat),
580
+ startDateText: dayjs(new Date(startDate)).format(dateFormat),
581
+ });
582
+ }}>
583
+ <DatePickerInput
584
+ id="startDatePickerInput"
585
+ labelText={t('startDate', 'Start date')}
586
+ style={{ width: '100%' }}
587
+ value={watch('appointmentDateTime').startDateText}
588
+ />
589
+ <DatePickerInput
590
+ id="endDatePickerInput"
591
+ labelText={t('endDate', 'End date')}
592
+ style={{ width: '100%' }}
593
+ placeholder={datePickerPlaceHolder}
594
+ value={watch('appointmentDateTime').recurringPatternEndDateText}
595
+ />
596
+ </DatePicker>
597
+ </ResponsiveWrapper>
598
+ )}
599
+ />
600
+ </ResponsiveWrapper>
601
+
602
+ {!isAllDayAppointment && (
603
+ <TimeAndDuration isTablet={isTablet} control={control} services={services} watch={watch} t={t} />
604
+ )}
605
+
606
+ <ResponsiveWrapper>
607
+ <Controller
608
+ name="recurringPatternPeriod"
609
+ control={control}
610
+ render={({ field: { onBlur, onChange, value } }) => (
611
+ <NumberInput
612
+ hideSteppers
613
+ id="repeatNumber"
614
+ min={1}
615
+ max={356}
616
+ label={t('repeatEvery', 'Repeat every')}
617
+ invalidText={t('invalidNumber', 'Number is not valid')}
618
+ size="md"
619
+ value={value}
620
+ onBlur={onBlur}
621
+ onChange={(e) => {
622
+ onChange(Number(e.target.value));
623
+ }}
624
+ />
625
+ )}
626
+ />
627
+ </ResponsiveWrapper>
628
+
629
+ <ResponsiveWrapper>
630
+ <Controller
631
+ name="recurringPatternType"
632
+ control={control}
633
+ render={({ field: { onChange, value } }) => (
634
+ <RadioButtonGroup
635
+ legendText={t('period', 'Period')}
636
+ name="radio-button-group"
637
+ onChange={(type) => onChange(type)}
638
+ valueSelected={value}>
639
+ <RadioButton labelText={t('day', 'Day')} value="DAY" id="radioDay" />
640
+ <RadioButton labelText={t('week', 'Week')} value="WEEK" id="radioWeek" />
641
+ </RadioButtonGroup>
642
+ )}
643
+ />
644
+ </ResponsiveWrapper>
645
+
646
+ {watch('recurringPatternType') === 'WEEK' && (
647
+ <div>
648
+ <Controller
649
+ name="selectedDaysOfWeekText"
650
+ control={control}
651
+ defaultValue={defaultSelectedDaysOfWeekText}
652
+ render={({ field: { onChange } }) => (
653
+ <MultiSelect
654
+ className={styles.weekSelect}
655
+ label={getValues('selectedDaysOfWeekText')}
656
+ id="daysOfWeek"
657
+ items={weekDays}
658
+ itemToString={(item) => (item ? t(item.labelCode, item.label) : '')}
659
+ selectionFeedback="top-after-reopen"
660
+ sortItems={(items) => {
661
+ return items.sort((a, b) => a.order > b.order);
662
+ }}
663
+ initialSelectedItems={weekDays.filter((i) => {
664
+ return getValues('recurringPatternDaysOfWeek').includes(i.id);
665
+ })}
666
+ onChange={(e) => {
667
+ onChange(e);
668
+ handleMultiselectChange(e);
669
+ }}
670
+ />
671
+ )}
672
+ />
673
+ </div>
674
+ )}
675
+ </div>
676
+ )}
677
+
678
+ {!isRecurringAppointment && (
679
+ <div className={styles.inputContainer}>
680
+ {allowAllDayAppointments && (
681
+ <Toggle
682
+ id="allDayToggle"
683
+ labelB={t('yes', 'Yes')}
684
+ labelA={t('no', 'No')}
685
+ labelText={t('allDay', 'All day')}
686
+ onClick={() => setIsAllDayAppointment(!isAllDayAppointment)}
687
+ toggled={isAllDayAppointment}
688
+ />
689
+ )}
690
+ <ResponsiveWrapper>
691
+ <Controller
692
+ name="appointmentDateTime"
693
+ control={control}
694
+ render={({ field: { onChange, value, ref } }) => (
695
+ <DatePicker
696
+ datePickerType="single"
697
+ dateFormat={datePickerFormat}
698
+ value={value.startDate}
699
+ onChange={([date]) => {
700
+ if (date) {
701
+ onChange({ ...value, startDate: date });
702
+ }
703
+ }}>
704
+ <DatePickerInput
705
+ id="datePickerInput"
706
+ labelText={t('date', 'Date')}
707
+ style={{ width: '100%' }}
708
+ placeholder={datePickerPlaceHolder}
709
+ ref={ref}
710
+ />
711
+ </DatePicker>
712
+ )}
713
+ />
714
+ </ResponsiveWrapper>
715
+
716
+ {!isAllDayAppointment && (
717
+ <TimeAndDuration isTablet={isTablet} control={control} services={services} watch={watch} t={t} />
718
+ )}
719
+ </div>
720
+ )}
721
+ </div>
722
+ </section>
723
+
724
+ {getValues('selectedService') && (
725
+ <section className={styles.formGroup}>
726
+ <ResponsiveWrapper>
727
+ <Workload
728
+ selectedService={watch('selectedService')}
729
+ appointmentDate={watch('appointmentDateTime').startDate}
730
+ onWorkloadDateChange={handleWorkloadDateChange}
731
+ />
732
+ </ResponsiveWrapper>
733
+ </section>
734
+ )}
735
+
736
+ {context !== 'creating' ? (
737
+ <section className={styles.formGroup}>
738
+ <span className={styles.heading}>{t('appointmentStatus', 'Appointment Status')}</span>
739
+ <ResponsiveWrapper>
740
+ <Controller
741
+ name="appointmentStatus"
742
+ control={control}
743
+ render={({ field: { onBlur, onChange, value, ref } }) => (
744
+ <Select
745
+ id="appointmentStatus"
746
+ invalidText="Required"
747
+ labelText={t('selectAppointmentStatus', 'Select status')}
748
+ onChange={onChange}
749
+ value={value}
750
+ ref={ref}
751
+ onBlur={onBlur}>
752
+ <SelectItem text={t('selectAppointmentStatus', 'Select status')} value="" />
753
+ {appointmentStatuses?.length > 0 &&
754
+ appointmentStatuses.map((appointmentStatus, index) => (
755
+ <SelectItem key={index} text={appointmentStatus} value={appointmentStatus}>
756
+ {appointmentStatus}
757
+ </SelectItem>
758
+ ))}
759
+ </Select>
760
+ )}
761
+ />
762
+ </ResponsiveWrapper>
763
+ </section>
764
+ ) : null}
765
+
766
+ <section className={styles.formGroup}>
767
+ <span className={styles.heading}>{t('provider', 'Provider')}</span>
768
+ <ResponsiveWrapper>
769
+ <Controller
770
+ name="provider"
771
+ control={control}
772
+ render={({ field: { onChange, value, onBlur, ref } }) => (
773
+ <Select
774
+ id="provider"
775
+ invalidText="Required"
776
+ labelText={t('selectProvider', 'Select a provider')}
777
+ onChange={onChange}
778
+ onBlur={onBlur}
779
+ value={value}
780
+ ref={ref}>
781
+ <SelectItem text={t('chooseProvider', 'Choose a provider')} value="" />
782
+ {providers?.providers?.length > 0 &&
783
+ providers?.providers?.map((provider) => (
784
+ <SelectItem key={provider.uuid} text={provider.display} value={provider.uuid}>
785
+ {provider.display}
786
+ </SelectItem>
787
+ ))}
788
+ </Select>
789
+ )}
790
+ />
791
+ </ResponsiveWrapper>
792
+ </section>
793
+
794
+ <section className={styles.formGroup}>
795
+ <span className={styles.heading}>{t('note', 'Note')}</span>
796
+ <ResponsiveWrapper>
797
+ <Controller
798
+ name="appointmentNote"
799
+ control={control}
800
+ render={({ field: { onChange, onBlur, value, ref } }) => (
801
+ <TextArea
802
+ id="appointmentNote"
803
+ value={value}
804
+ labelText={t('appointmentNoteLabel', 'Write an additional note')}
805
+ placeholder={t('appointmentNotePlaceholder', 'Write any additional points here')}
806
+ onChange={onChange}
807
+ onBlur={onBlur}
808
+ ref={ref}
809
+ />
810
+ )}
811
+ />
812
+ </ResponsiveWrapper>
813
+ </section>
814
+ </Stack>
815
+ <ButtonSet className={isTablet ? styles.tablet : styles.desktop}>
816
+ <Button className={styles.button} onClick={closeWorkspace} kind="secondary">
817
+ {t('discard', 'Discard')}
818
+ </Button>
819
+ <Button className={styles.button} disabled={isSubmitting} type="submit">
820
+ {t('saveAndClose', 'Save and close')}
821
+ </Button>
822
+ </ButtonSet>
823
+ </Form>
824
+ );
825
+ };
826
+
827
+ function TimeAndDuration({ isTablet, t, watch, control, services }) {
828
+ const defaultDuration = services?.find((service) => service.name === watch('selectedService'))?.durationMins || null;
829
+
830
+ return (
831
+ <>
832
+ <ResponsiveWrapper>
833
+ <Controller
834
+ name="startTime"
835
+ control={control}
836
+ render={({ field: { onChange, value } }) => (
837
+ <TimePicker
838
+ id="time-picker"
839
+ pattern={time12HourFormatRegexPattern}
840
+ invalid={!isValidTime(value)}
841
+ invalidText={t('invalidTime', 'Invalid time')}
842
+ onChange={(event) => onChange(event.target.value)}
843
+ value={value}
844
+ style={{ marginLeft: '0.125rem', flex: 'none' }}
845
+ labelText={t('time', 'Time')}>
846
+ <Controller
847
+ name="timeFormat"
848
+ control={control}
849
+ render={({ field: { value, onChange } }) => (
850
+ <TimePickerSelect
851
+ id="time-picker-select-1"
852
+ onChange={(event) => onChange(event.target.value as 'AM' | 'PM')}
853
+ value={value}
854
+ aria-label={t('time', 'Time')}>
855
+ <SelectItem value="AM" text="AM" />
856
+ <SelectItem value="PM" text="PM" />
857
+ </TimePickerSelect>
858
+ )}
859
+ />
860
+ </TimePicker>
861
+ )}
862
+ />
863
+ </ResponsiveWrapper>
864
+ <ResponsiveWrapper>
865
+ <Controller
866
+ name="duration"
867
+ control={control}
868
+ defaultValue={defaultDuration}
869
+ render={({ field: { onChange, onBlur, value, ref } }) => (
870
+ <NumberInput
871
+ hideSteppers
872
+ disableWheel
873
+ id="duration"
874
+ min={0}
875
+ max={1440}
876
+ label={t('durationInMinutes', 'Duration (minutes)')}
877
+ invalidText={t('invalidNumber', 'Number is not valid')}
878
+ size="md"
879
+ onBlur={onBlur}
880
+ onChange={(event) => onChange(Number(event.target.value))}
881
+ value={value}
882
+ ref={ref}
883
+ />
884
+ )}
885
+ />
886
+ </ResponsiveWrapper>
887
+ </>
888
+ );
889
+ }
890
+
891
+ export default AppointmentsForm;