@palladium-ethiopia/esm-appointments-admin-app 5.4.2-pre.243
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.
- package/.turbo/turbo-build.log +7 -0
- package/dist/117.js +1 -0
- package/dist/145.js +1 -0
- package/dist/145.js.map +1 -0
- package/dist/15.js +1 -0
- package/dist/15.js.map +1 -0
- package/dist/152.js +1 -0
- package/dist/152.js.map +1 -0
- package/dist/184.js +1 -0
- package/dist/184.js.map +1 -0
- package/dist/204.js +43 -0
- package/dist/204.js.map +1 -0
- package/dist/209.js +1 -0
- package/dist/209.js.map +1 -0
- package/dist/357.js +1 -0
- package/dist/357.js.map +1 -0
- package/dist/442.js +1 -0
- package/dist/442.js.map +1 -0
- package/dist/466.js +1 -0
- package/dist/466.js.map +1 -0
- package/dist/61.js +1 -0
- package/dist/61.js.map +1 -0
- package/dist/611.js +1 -0
- package/dist/611.js.map +1 -0
- package/dist/689.js +1 -0
- package/dist/689.js.map +1 -0
- package/dist/697.js +1 -0
- package/dist/697.js.map +1 -0
- package/dist/711.js +1 -0
- package/dist/711.js.map +1 -0
- package/dist/712.js +1 -0
- package/dist/712.js.map +1 -0
- package/dist/771.js +1 -0
- package/dist/771.js.map +1 -0
- package/dist/789.js +1 -0
- package/dist/789.js.map +1 -0
- package/dist/806.js +1 -0
- package/dist/868.js +1 -0
- package/dist/868.js.map +1 -0
- package/dist/878.js +1 -0
- package/dist/878.js.map +1 -0
- package/dist/88.js +15 -0
- package/dist/88.js.map +1 -0
- package/dist/ethiopia-esm-appointments-admin-app.js +6 -0
- package/dist/ethiopia-esm-appointments-admin-app.js.buildmanifest.json +701 -0
- package/dist/ethiopia-esm-appointments-admin-app.js.map +1 -0
- package/dist/main.js +6 -0
- package/dist/main.js.map +1 -0
- package/dist/routes.json +1 -0
- package/jest.config.js +6 -0
- package/package.json +52 -0
- package/rspack.config.js +1 -0
- package/src/api/appointment-service.resource.ts +46 -0
- package/src/components/nav-tile-link.component.tsx +26 -0
- package/src/components/nav-tile-link.scss +29 -0
- package/src/config-schema.ts +20 -0
- package/src/constants.ts +21 -0
- package/src/declarations.d.ts +2 -0
- package/src/extensions/appointment-service-admin-nav-link.extension.tsx +29 -0
- package/src/home/appointment-services-table.component.tsx +126 -0
- package/src/home/appointment-services-table.scss +15 -0
- package/src/home/dashboard.component.tsx +17 -0
- package/src/home/home.component.tsx +25 -0
- package/src/home/home.scss +14 -0
- package/src/index.ts +33 -0
- package/src/routes.json +35 -0
- package/src/types/index.ts +63 -0
- package/src/workspace/appointment-service-admin.workspace.scss +81 -0
- package/src/workspace/appointment-service-admin.workspace.tsx +335 -0
- package/src/workspace/appointment-service-form.helper.ts +112 -0
- package/src/workspace/copy-day-blocks-modal.component.tsx +136 -0
- package/src/workspace/copy-day-blocks-modal.scss +13 -0
- package/src/workspace/copy-day-blocks.helper.ts +48 -0
- package/translations/am.json +55 -0
- package/translations/en.json +55 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import { Button, ButtonSet, Form, InlineLoading, NumberInput, Stack, TextInput } from '@carbon/react';
|
|
5
|
+
import { Add, Copy, TrashCan } from '@carbon/react/icons';
|
|
6
|
+
import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
|
7
|
+
import { type DefaultWorkspaceProps, ResponsiveWrapper, showSnackbar, useLayoutType } from '@openmrs/esm-framework';
|
|
8
|
+
import { revalidateAppointmentServices, saveAppointmentService } from '../api/appointment-service.resource';
|
|
9
|
+
import { DAYS_OF_WEEK, type DayOfWeek } from '../constants';
|
|
10
|
+
import type { AppointmentService, AppointmentServiceFormValues } from '../types';
|
|
11
|
+
import {
|
|
12
|
+
getDayBlockTotal,
|
|
13
|
+
getErrorMessage,
|
|
14
|
+
mapFormValuesToSavePayload,
|
|
15
|
+
mapServiceToFormValues,
|
|
16
|
+
} from './appointment-service-form.helper';
|
|
17
|
+
import CopyDayBlocksModal from './copy-day-blocks-modal.component';
|
|
18
|
+
import { copyDayBlocksToDays, type CopyDayBlocksMode } from './copy-day-blocks.helper';
|
|
19
|
+
import styles from './appointment-service-admin.workspace.scss';
|
|
20
|
+
|
|
21
|
+
type AppointmentServiceAdminWorkspaceProps = DefaultWorkspaceProps & {
|
|
22
|
+
appointmentService?: AppointmentService;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const defaultBlockTimes = {
|
|
26
|
+
startTime: '09:00',
|
|
27
|
+
endTime: '17:00',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const AppointmentServiceAdminWorkspace: React.FC<AppointmentServiceAdminWorkspaceProps> = ({
|
|
31
|
+
appointmentService,
|
|
32
|
+
closeWorkspace,
|
|
33
|
+
closeWorkspaceWithSavedChanges,
|
|
34
|
+
promptBeforeClosing,
|
|
35
|
+
}) => {
|
|
36
|
+
const { t } = useTranslation();
|
|
37
|
+
const isTablet = useLayoutType() === 'tablet';
|
|
38
|
+
const [copySourceDay, setCopySourceDay] = useState<DayOfWeek | null>(null);
|
|
39
|
+
const defaultValues = useMemo(
|
|
40
|
+
() =>
|
|
41
|
+
appointmentService ? mapServiceToFormValues(appointmentService) : { maxAppointmentsLimit: null, blocks: [] },
|
|
42
|
+
[appointmentService],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const {
|
|
46
|
+
control,
|
|
47
|
+
handleSubmit,
|
|
48
|
+
watch,
|
|
49
|
+
reset,
|
|
50
|
+
setValue,
|
|
51
|
+
formState: { isDirty, isSubmitting },
|
|
52
|
+
} = useForm<AppointmentServiceFormValues>({
|
|
53
|
+
defaultValues,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const { fields, append, update, remove } = useFieldArray({
|
|
57
|
+
control,
|
|
58
|
+
name: 'blocks',
|
|
59
|
+
keyName: 'clientId',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const blocks = watch('blocks');
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (appointmentService) {
|
|
66
|
+
reset(mapServiceToFormValues(appointmentService));
|
|
67
|
+
}
|
|
68
|
+
}, [appointmentService, reset]);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
promptBeforeClosing(() => isDirty);
|
|
72
|
+
}, [isDirty, promptBeforeClosing]);
|
|
73
|
+
|
|
74
|
+
const getDayLabel = (day: DayOfWeek) => {
|
|
75
|
+
const labels: Record<DayOfWeek, string> = {
|
|
76
|
+
MONDAY: t('dayMonday', 'Monday'),
|
|
77
|
+
TUESDAY: t('dayTuesday', 'Tuesday'),
|
|
78
|
+
WEDNESDAY: t('dayWednesday', 'Wednesday'),
|
|
79
|
+
THURSDAY: t('dayThursday', 'Thursday'),
|
|
80
|
+
FRIDAY: t('dayFriday', 'Friday'),
|
|
81
|
+
SATURDAY: t('daySaturday', 'Saturday'),
|
|
82
|
+
SUNDAY: t('daySunday', 'Sunday'),
|
|
83
|
+
};
|
|
84
|
+
return labels[day];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleAddBlock = (dayOfWeek: DayOfWeek) => {
|
|
88
|
+
append({
|
|
89
|
+
clientId: crypto.randomUUID(),
|
|
90
|
+
dayOfWeek,
|
|
91
|
+
startTime: defaultBlockTimes.startTime,
|
|
92
|
+
endTime: defaultBlockTimes.endTime,
|
|
93
|
+
maxAppointmentsLimit: null,
|
|
94
|
+
voided: false,
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleCopyDayBlocks = (targetDays: Array<DayOfWeek>, mode: CopyDayBlocksMode) => {
|
|
99
|
+
if (!copySourceDay) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sourceDay = copySourceDay;
|
|
104
|
+
const nextBlocks = copyDayBlocksToDays(blocks, sourceDay, targetDays, mode);
|
|
105
|
+
setValue('blocks', nextBlocks, { shouldDirty: true });
|
|
106
|
+
setCopySourceDay(null);
|
|
107
|
+
|
|
108
|
+
showSnackbar({
|
|
109
|
+
title: t('success', 'Success'),
|
|
110
|
+
kind: 'success',
|
|
111
|
+
subtitle: t('copyDayBlocksSuccess', 'Copied blocks from {{sourceDay}} to {{count}} day(s).', {
|
|
112
|
+
sourceDay: getDayLabel(sourceDay),
|
|
113
|
+
count: targetDays.length,
|
|
114
|
+
}),
|
|
115
|
+
isLowContrast: true,
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const handleRemoveBlock = (index: number) => {
|
|
120
|
+
const block = blocks[index];
|
|
121
|
+
if (block?.uuid) {
|
|
122
|
+
update(index, { ...block, voided: true });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
remove(index);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const onSubmit = async (values: AppointmentServiceFormValues) => {
|
|
130
|
+
if (!appointmentService) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const payload = mapFormValuesToSavePayload(appointmentService, values);
|
|
136
|
+
await saveAppointmentService(payload);
|
|
137
|
+
await revalidateAppointmentServices();
|
|
138
|
+
|
|
139
|
+
showSnackbar({
|
|
140
|
+
title: t('success', 'Success'),
|
|
141
|
+
kind: 'success',
|
|
142
|
+
subtitle: t('serviceAvailabilitySaved', 'Availability saved for {{serviceName}}.', {
|
|
143
|
+
serviceName: appointmentService.name,
|
|
144
|
+
}),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
closeWorkspaceWithSavedChanges();
|
|
148
|
+
} catch (error) {
|
|
149
|
+
showSnackbar({
|
|
150
|
+
title: t('error', 'Error'),
|
|
151
|
+
kind: 'error',
|
|
152
|
+
subtitle: getErrorMessage(
|
|
153
|
+
error,
|
|
154
|
+
t('serviceAvailabilitySaveError', 'Error saving appointment service availability'),
|
|
155
|
+
),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (!appointmentService) {
|
|
161
|
+
return (
|
|
162
|
+
<div className={styles.formContainer}>
|
|
163
|
+
<p>{t('selectServiceFromList', 'Select a service from the list to configure availability.')}</p>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Form className={styles.form} onSubmit={handleSubmit(onSubmit)}>
|
|
170
|
+
<div className={styles.formContainer}>
|
|
171
|
+
<p className={styles.serviceName}>{appointmentService.name}</p>
|
|
172
|
+
|
|
173
|
+
<Stack gap={6}>
|
|
174
|
+
<ResponsiveWrapper>
|
|
175
|
+
<Controller
|
|
176
|
+
control={control}
|
|
177
|
+
name="maxAppointmentsLimit"
|
|
178
|
+
render={({ field }) => (
|
|
179
|
+
<NumberInput
|
|
180
|
+
id="maxAppointmentsLimit"
|
|
181
|
+
label={t('serviceMaxAppointmentsLimit', 'Service max appointments (daily cap)')}
|
|
182
|
+
helperText={t(
|
|
183
|
+
'serviceMaxAppointmentsLimitHelper',
|
|
184
|
+
'Optional overall limit for this service. Weekly block limits are summed per day.',
|
|
185
|
+
)}
|
|
186
|
+
min={0}
|
|
187
|
+
value={field.value ?? ''}
|
|
188
|
+
onChange={(_, { value }) => {
|
|
189
|
+
if (value === '' || value === undefined) {
|
|
190
|
+
field.onChange(null);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
field.onChange(Number(value));
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
/>
|
|
198
|
+
</ResponsiveWrapper>
|
|
199
|
+
|
|
200
|
+
<div>
|
|
201
|
+
<h4 className={styles.sectionTitle}>{t('weeklyAvailability', 'Weekly availability')}</h4>
|
|
202
|
+
|
|
203
|
+
{DAYS_OF_WEEK.map((day) => {
|
|
204
|
+
const dayBlocks = fields
|
|
205
|
+
.map((field, index) => ({ field, index }))
|
|
206
|
+
.filter(({ index }) => blocks[index]?.dayOfWeek === day && !blocks[index]?.voided);
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<section className={styles.daySection} key={day}>
|
|
210
|
+
<div className={styles.dayHeader}>
|
|
211
|
+
<h5 className={styles.dayTitle}>{getDayLabel(day)}</h5>
|
|
212
|
+
<div className={styles.dayActions}>
|
|
213
|
+
<Button
|
|
214
|
+
disabled={dayBlocks.length === 0}
|
|
215
|
+
kind="ghost"
|
|
216
|
+
renderIcon={Copy}
|
|
217
|
+
size="sm"
|
|
218
|
+
onClick={() => setCopySourceDay(day)}>
|
|
219
|
+
{t('copyToOtherDays', 'Copy to other days')}
|
|
220
|
+
</Button>
|
|
221
|
+
<Button kind="ghost" renderIcon={Add} size="sm" onClick={() => handleAddBlock(day)}>
|
|
222
|
+
{t('addBlock', 'Add block')}
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{dayBlocks.length === 0 ? (
|
|
228
|
+
<p className={styles.emptyDay}>{t('noBlocksForDay', 'No availability blocks for this day.')}</p>
|
|
229
|
+
) : (
|
|
230
|
+
dayBlocks.map(({ field, index }) => (
|
|
231
|
+
<div className={styles.blockRow} key={field.clientId}>
|
|
232
|
+
<Controller
|
|
233
|
+
control={control}
|
|
234
|
+
name={`blocks.${index}.startTime`}
|
|
235
|
+
rules={{ required: t('startTimeRequired', 'Start time is required') }}
|
|
236
|
+
render={({ field: timeField, fieldState }) => (
|
|
237
|
+
<TextInput
|
|
238
|
+
id={`${field.clientId}-start`}
|
|
239
|
+
labelText={t('startTime', 'Start time')}
|
|
240
|
+
placeholder="09:00"
|
|
241
|
+
type="time"
|
|
242
|
+
value={timeField.value}
|
|
243
|
+
onChange={timeField.onChange}
|
|
244
|
+
invalid={!!fieldState.error}
|
|
245
|
+
invalidText={fieldState.error?.message}
|
|
246
|
+
/>
|
|
247
|
+
)}
|
|
248
|
+
/>
|
|
249
|
+
<Controller
|
|
250
|
+
control={control}
|
|
251
|
+
name={`blocks.${index}.endTime`}
|
|
252
|
+
rules={{ required: t('endTimeRequired', 'End time is required') }}
|
|
253
|
+
render={({ field: timeField, fieldState }) => (
|
|
254
|
+
<TextInput
|
|
255
|
+
id={`${field.clientId}-end`}
|
|
256
|
+
labelText={t('endTime', 'End time')}
|
|
257
|
+
placeholder="17:00"
|
|
258
|
+
type="time"
|
|
259
|
+
value={timeField.value}
|
|
260
|
+
onChange={timeField.onChange}
|
|
261
|
+
invalid={!!fieldState.error}
|
|
262
|
+
invalidText={fieldState.error?.message}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
/>
|
|
266
|
+
<Controller
|
|
267
|
+
control={control}
|
|
268
|
+
name={`blocks.${index}.maxAppointmentsLimit`}
|
|
269
|
+
render={({ field: limitField }) => (
|
|
270
|
+
<NumberInput
|
|
271
|
+
id={`${field.clientId}-limit`}
|
|
272
|
+
label={t('blockMaxAppointments', 'Block limit')}
|
|
273
|
+
min={0}
|
|
274
|
+
value={limitField.value ?? ''}
|
|
275
|
+
onChange={(_, { value }) => {
|
|
276
|
+
if (value === '' || value === undefined) {
|
|
277
|
+
limitField.onChange(null);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
limitField.onChange(Number(value));
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
/>
|
|
285
|
+
<Button
|
|
286
|
+
kind="danger--ghost"
|
|
287
|
+
renderIcon={TrashCan}
|
|
288
|
+
iconDescription={t('removeBlock', 'Remove block')}
|
|
289
|
+
hasIconOnly
|
|
290
|
+
onClick={() => handleRemoveBlock(index)}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
))
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<p className={styles.dayTotal}>
|
|
297
|
+
{t('dayBlockTotal', 'Day total: {{total}}', {
|
|
298
|
+
total: getDayBlockTotal(blocks, day),
|
|
299
|
+
})}
|
|
300
|
+
</p>
|
|
301
|
+
</section>
|
|
302
|
+
);
|
|
303
|
+
})}
|
|
304
|
+
</div>
|
|
305
|
+
</Stack>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<CopyDayBlocksModal
|
|
309
|
+
open={copySourceDay !== null}
|
|
310
|
+
sourceDay={copySourceDay}
|
|
311
|
+
getDayLabel={getDayLabel}
|
|
312
|
+
onClose={() => setCopySourceDay(null)}
|
|
313
|
+
onApply={handleCopyDayBlocks}
|
|
314
|
+
/>
|
|
315
|
+
|
|
316
|
+
<ButtonSet className={classNames(styles.buttonSet, { [styles.buttonSetTablet]: isTablet })}>
|
|
317
|
+
<Button kind="secondary" onClick={() => closeWorkspace()}>
|
|
318
|
+
{t('cancel', 'Cancel')}
|
|
319
|
+
</Button>
|
|
320
|
+
<Button disabled={isSubmitting || !isDirty} kind="primary" type="submit">
|
|
321
|
+
{isSubmitting ? (
|
|
322
|
+
<span className={styles.inlineLoading}>
|
|
323
|
+
{t('saving', 'Saving')}
|
|
324
|
+
<InlineLoading />
|
|
325
|
+
</span>
|
|
326
|
+
) : (
|
|
327
|
+
t('saveAndClose', 'Save & close')
|
|
328
|
+
)}
|
|
329
|
+
</Button>
|
|
330
|
+
</ButtonSet>
|
|
331
|
+
</Form>
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export default AppointmentServiceAdminWorkspace;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppointmentService,
|
|
3
|
+
AppointmentServiceFormValues,
|
|
4
|
+
AppointmentServiceSavePayload,
|
|
5
|
+
AvailabilityBlockFormValue,
|
|
6
|
+
WeeklyAvailabilityPayload,
|
|
7
|
+
} from '../types';
|
|
8
|
+
|
|
9
|
+
export function toFormTime(time?: string): string {
|
|
10
|
+
if (!time) {
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return time.substring(0, 5);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Bahmni expects java.sql.Time as "HH:mm:ss". Never send empty strings. */
|
|
18
|
+
export function toApiTime(time?: string): string | undefined {
|
|
19
|
+
if (!time?.trim()) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trimmed = time.trim();
|
|
24
|
+
|
|
25
|
+
if (trimmed.length === 5) {
|
|
26
|
+
return `${trimmed}:00`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function mapServiceToFormValues(service: AppointmentService): AppointmentServiceFormValues {
|
|
33
|
+
return {
|
|
34
|
+
maxAppointmentsLimit: service.maxAppointmentsLimit ?? null,
|
|
35
|
+
blocks: (service.weeklyAvailability ?? []).map((block, index) => ({
|
|
36
|
+
clientId: block.uuid ?? `existing-${index}`,
|
|
37
|
+
uuid: block.uuid,
|
|
38
|
+
dayOfWeek: block.dayOfWeek,
|
|
39
|
+
startTime: toFormTime(block.startTime),
|
|
40
|
+
endTime: toFormTime(block.endTime),
|
|
41
|
+
maxAppointmentsLimit: block.maxAppointmentsLimit ?? null,
|
|
42
|
+
voided: false,
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getDayBlockTotal(blocks: Array<AvailabilityBlockFormValue>, dayOfWeek: string): number {
|
|
48
|
+
return blocks
|
|
49
|
+
.filter((block) => block.dayOfWeek === dayOfWeek && !block.voided)
|
|
50
|
+
.reduce((total, block) => total + (block.maxAppointmentsLimit ?? 0), 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mapFormValuesToSavePayload(
|
|
54
|
+
service: AppointmentService,
|
|
55
|
+
values: AppointmentServiceFormValues,
|
|
56
|
+
): AppointmentServiceSavePayload {
|
|
57
|
+
const payload: AppointmentServiceSavePayload = {
|
|
58
|
+
uuid: service.uuid,
|
|
59
|
+
name: service.name,
|
|
60
|
+
description: service.description,
|
|
61
|
+
specialityUuid: service.speciality?.uuid,
|
|
62
|
+
locationUuid: service.location?.uuid,
|
|
63
|
+
maxAppointmentsLimit: values.maxAppointmentsLimit,
|
|
64
|
+
durationMins: service.durationMins ?? undefined,
|
|
65
|
+
color: service.color,
|
|
66
|
+
initialAppointmentStatus: service.initialAppointmentStatus,
|
|
67
|
+
weeklyAvailability: values.blocks
|
|
68
|
+
.filter((block) => block.uuid || !block.voided)
|
|
69
|
+
.map((block) => {
|
|
70
|
+
const availability: WeeklyAvailabilityPayload = {
|
|
71
|
+
dayOfWeek: block.dayOfWeek,
|
|
72
|
+
maxAppointmentsLimit: block.maxAppointmentsLimit,
|
|
73
|
+
voided: block.voided ?? false,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (block.uuid) {
|
|
77
|
+
availability.uuid = block.uuid;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const startTime = toApiTime(block.startTime);
|
|
81
|
+
const endTime = toApiTime(block.endTime);
|
|
82
|
+
|
|
83
|
+
if (startTime) {
|
|
84
|
+
availability.startTime = startTime;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (endTime) {
|
|
88
|
+
availability.endTime = endTime;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return availability;
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const serviceStartTime = toApiTime(toFormTime(service.startTime));
|
|
96
|
+
const serviceEndTime = toApiTime(toFormTime(service.endTime));
|
|
97
|
+
|
|
98
|
+
if (serviceStartTime) {
|
|
99
|
+
payload.startTime = serviceStartTime;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (serviceEndTime) {
|
|
103
|
+
payload.endTime = serviceEndTime;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return payload;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getErrorMessage(error: unknown, fallback: string): string {
|
|
110
|
+
const err = error as { responseBody?: { error?: { message?: string } }; message?: string };
|
|
111
|
+
return err?.responseBody?.error?.message ?? err?.message ?? fallback;
|
|
112
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
CheckboxGroup,
|
|
7
|
+
ComposedModal,
|
|
8
|
+
ModalBody,
|
|
9
|
+
ModalFooter,
|
|
10
|
+
ModalHeader,
|
|
11
|
+
RadioButton,
|
|
12
|
+
RadioButtonGroup,
|
|
13
|
+
Stack,
|
|
14
|
+
} from '@carbon/react';
|
|
15
|
+
import { DAYS_OF_WEEK, WEEKDAYS, type DayOfWeek } from '../constants';
|
|
16
|
+
import type { CopyDayBlocksMode } from './copy-day-blocks.helper';
|
|
17
|
+
import styles from './copy-day-blocks-modal.scss';
|
|
18
|
+
|
|
19
|
+
interface CopyDayBlocksModalProps {
|
|
20
|
+
open: boolean;
|
|
21
|
+
sourceDay: DayOfWeek | null;
|
|
22
|
+
getDayLabel: (day: DayOfWeek) => string;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onApply: (targetDays: Array<DayOfWeek>, mode: CopyDayBlocksMode) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CopyDayBlocksModal: React.FC<CopyDayBlocksModalProps> = ({ open, sourceDay, getDayLabel, onClose, onApply }) => {
|
|
28
|
+
const { t } = useTranslation();
|
|
29
|
+
const [selectedDays, setSelectedDays] = useState<Array<DayOfWeek>>([]);
|
|
30
|
+
const [mode, setMode] = useState<CopyDayBlocksMode>('replace');
|
|
31
|
+
|
|
32
|
+
const targetDayOptions = useMemo(() => DAYS_OF_WEEK.filter((day) => day !== sourceDay), [sourceDay]);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (open) {
|
|
36
|
+
setSelectedDays([]);
|
|
37
|
+
setMode('replace');
|
|
38
|
+
}
|
|
39
|
+
}, [open, sourceDay]);
|
|
40
|
+
|
|
41
|
+
const handleApply = () => {
|
|
42
|
+
if (!sourceDay || selectedDays.length === 0) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onApply(selectedDays, mode);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const selectWeekdays = () => {
|
|
50
|
+
setSelectedDays(WEEKDAYS.filter((day) => day !== sourceDay));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const selectAll = () => {
|
|
54
|
+
setSelectedDays([...targetDayOptions]);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const clearSelection = () => {
|
|
58
|
+
setSelectedDays([]);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ComposedModal open={open} onClose={onClose} size="sm">
|
|
63
|
+
<ModalHeader
|
|
64
|
+
title={t('copyDayBlocksTitle', 'Copy {{day}} blocks', {
|
|
65
|
+
day: sourceDay ? getDayLabel(sourceDay) : '',
|
|
66
|
+
})}
|
|
67
|
+
closeModal={onClose}
|
|
68
|
+
/>
|
|
69
|
+
<ModalBody>
|
|
70
|
+
<Stack gap={5}>
|
|
71
|
+
<p className={styles.description}>
|
|
72
|
+
{t(
|
|
73
|
+
'copyDayBlocksDescription',
|
|
74
|
+
'Copy this day’s time blocks and limits to other days. Existing blocks on target days can be replaced or kept.',
|
|
75
|
+
)}
|
|
76
|
+
</p>
|
|
77
|
+
|
|
78
|
+
<div className={styles.quickSelect}>
|
|
79
|
+
<Button kind="ghost" size="sm" onClick={selectWeekdays}>
|
|
80
|
+
{t('selectWeekdays', 'Weekdays')}
|
|
81
|
+
</Button>
|
|
82
|
+
<Button kind="ghost" size="sm" onClick={selectAll}>
|
|
83
|
+
{t('selectAllDays', 'All days')}
|
|
84
|
+
</Button>
|
|
85
|
+
<Button kind="ghost" size="sm" onClick={clearSelection}>
|
|
86
|
+
{t('clearSelection', 'Clear')}
|
|
87
|
+
</Button>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<CheckboxGroup legendText={t('copyToDays', 'Copy to')}>
|
|
91
|
+
{targetDayOptions.map((day) => (
|
|
92
|
+
<Checkbox
|
|
93
|
+
key={day}
|
|
94
|
+
id={`copy-to-${day}`}
|
|
95
|
+
labelText={getDayLabel(day)}
|
|
96
|
+
checked={selectedDays.includes(day)}
|
|
97
|
+
onChange={(_, { checked }) => {
|
|
98
|
+
setSelectedDays((current) =>
|
|
99
|
+
checked ? [...current, day] : current.filter((selectedDay) => selectedDay !== day),
|
|
100
|
+
);
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
))}
|
|
104
|
+
</CheckboxGroup>
|
|
105
|
+
|
|
106
|
+
<RadioButtonGroup
|
|
107
|
+
legendText={t('copyMode', 'When target days already have blocks')}
|
|
108
|
+
name="copy-day-blocks-mode"
|
|
109
|
+
valueSelected={mode}
|
|
110
|
+
onChange={(value) => setMode(value as CopyDayBlocksMode)}>
|
|
111
|
+
<RadioButton
|
|
112
|
+
id="copy-mode-replace"
|
|
113
|
+
labelText={t('copyModeReplace', 'Replace existing blocks')}
|
|
114
|
+
value="replace"
|
|
115
|
+
/>
|
|
116
|
+
<RadioButton
|
|
117
|
+
id="copy-mode-append"
|
|
118
|
+
labelText={t('copyModeAppend', 'Add to existing blocks')}
|
|
119
|
+
value="append"
|
|
120
|
+
/>
|
|
121
|
+
</RadioButtonGroup>
|
|
122
|
+
</Stack>
|
|
123
|
+
</ModalBody>
|
|
124
|
+
<ModalFooter>
|
|
125
|
+
<Button kind="secondary" onClick={onClose}>
|
|
126
|
+
{t('cancel', 'Cancel')}
|
|
127
|
+
</Button>
|
|
128
|
+
<Button disabled={selectedDays.length === 0} kind="primary" onClick={handleApply}>
|
|
129
|
+
{t('copyBlocks', 'Copy blocks')}
|
|
130
|
+
</Button>
|
|
131
|
+
</ModalFooter>
|
|
132
|
+
</ComposedModal>
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export default CopyDayBlocksModal;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { AvailabilityBlockFormValue } from '../types';
|
|
2
|
+
|
|
3
|
+
export type CopyDayBlocksMode = 'replace' | 'append';
|
|
4
|
+
|
|
5
|
+
export function copyDayBlocksToDays(
|
|
6
|
+
blocks: Array<AvailabilityBlockFormValue>,
|
|
7
|
+
sourceDay: string,
|
|
8
|
+
targetDays: Array<string>,
|
|
9
|
+
mode: CopyDayBlocksMode,
|
|
10
|
+
): Array<AvailabilityBlockFormValue> {
|
|
11
|
+
const sourceBlocks = blocks.filter((block) => block.dayOfWeek === sourceDay && !block.voided);
|
|
12
|
+
|
|
13
|
+
if (sourceBlocks.length === 0 || targetDays.length === 0) {
|
|
14
|
+
return blocks;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const targetDaySet = new Set(targetDays);
|
|
18
|
+
let nextBlocks = [...blocks];
|
|
19
|
+
|
|
20
|
+
if (mode === 'replace') {
|
|
21
|
+
nextBlocks = nextBlocks
|
|
22
|
+
.map((block) => {
|
|
23
|
+
if (!targetDaySet.has(block.dayOfWeek) || block.voided) {
|
|
24
|
+
return block;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (block.uuid) {
|
|
28
|
+
return { ...block, voided: true };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
})
|
|
33
|
+
.filter((block): block is AvailabilityBlockFormValue => block !== null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const copies = targetDays.flatMap((targetDay) =>
|
|
37
|
+
sourceBlocks.map((sourceBlock) => ({
|
|
38
|
+
clientId: crypto.randomUUID(),
|
|
39
|
+
dayOfWeek: targetDay,
|
|
40
|
+
startTime: sourceBlock.startTime,
|
|
41
|
+
endTime: sourceBlock.endTime,
|
|
42
|
+
maxAppointmentsLimit: sourceBlock.maxAppointmentsLimit,
|
|
43
|
+
voided: false,
|
|
44
|
+
})),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return [...nextBlocks, ...copies];
|
|
48
|
+
}
|