@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.
Files changed (76) hide show
  1. package/.turbo/turbo-build.log +7 -0
  2. package/dist/117.js +1 -0
  3. package/dist/145.js +1 -0
  4. package/dist/145.js.map +1 -0
  5. package/dist/15.js +1 -0
  6. package/dist/15.js.map +1 -0
  7. package/dist/152.js +1 -0
  8. package/dist/152.js.map +1 -0
  9. package/dist/184.js +1 -0
  10. package/dist/184.js.map +1 -0
  11. package/dist/204.js +43 -0
  12. package/dist/204.js.map +1 -0
  13. package/dist/209.js +1 -0
  14. package/dist/209.js.map +1 -0
  15. package/dist/357.js +1 -0
  16. package/dist/357.js.map +1 -0
  17. package/dist/442.js +1 -0
  18. package/dist/442.js.map +1 -0
  19. package/dist/466.js +1 -0
  20. package/dist/466.js.map +1 -0
  21. package/dist/61.js +1 -0
  22. package/dist/61.js.map +1 -0
  23. package/dist/611.js +1 -0
  24. package/dist/611.js.map +1 -0
  25. package/dist/689.js +1 -0
  26. package/dist/689.js.map +1 -0
  27. package/dist/697.js +1 -0
  28. package/dist/697.js.map +1 -0
  29. package/dist/711.js +1 -0
  30. package/dist/711.js.map +1 -0
  31. package/dist/712.js +1 -0
  32. package/dist/712.js.map +1 -0
  33. package/dist/771.js +1 -0
  34. package/dist/771.js.map +1 -0
  35. package/dist/789.js +1 -0
  36. package/dist/789.js.map +1 -0
  37. package/dist/806.js +1 -0
  38. package/dist/868.js +1 -0
  39. package/dist/868.js.map +1 -0
  40. package/dist/878.js +1 -0
  41. package/dist/878.js.map +1 -0
  42. package/dist/88.js +15 -0
  43. package/dist/88.js.map +1 -0
  44. package/dist/ethiopia-esm-appointments-admin-app.js +6 -0
  45. package/dist/ethiopia-esm-appointments-admin-app.js.buildmanifest.json +701 -0
  46. package/dist/ethiopia-esm-appointments-admin-app.js.map +1 -0
  47. package/dist/main.js +6 -0
  48. package/dist/main.js.map +1 -0
  49. package/dist/routes.json +1 -0
  50. package/jest.config.js +6 -0
  51. package/package.json +52 -0
  52. package/rspack.config.js +1 -0
  53. package/src/api/appointment-service.resource.ts +46 -0
  54. package/src/components/nav-tile-link.component.tsx +26 -0
  55. package/src/components/nav-tile-link.scss +29 -0
  56. package/src/config-schema.ts +20 -0
  57. package/src/constants.ts +21 -0
  58. package/src/declarations.d.ts +2 -0
  59. package/src/extensions/appointment-service-admin-nav-link.extension.tsx +29 -0
  60. package/src/home/appointment-services-table.component.tsx +126 -0
  61. package/src/home/appointment-services-table.scss +15 -0
  62. package/src/home/dashboard.component.tsx +17 -0
  63. package/src/home/home.component.tsx +25 -0
  64. package/src/home/home.scss +14 -0
  65. package/src/index.ts +33 -0
  66. package/src/routes.json +35 -0
  67. package/src/types/index.ts +63 -0
  68. package/src/workspace/appointment-service-admin.workspace.scss +81 -0
  69. package/src/workspace/appointment-service-admin.workspace.tsx +335 -0
  70. package/src/workspace/appointment-service-form.helper.ts +112 -0
  71. package/src/workspace/copy-day-blocks-modal.component.tsx +136 -0
  72. package/src/workspace/copy-day-blocks-modal.scss +13 -0
  73. package/src/workspace/copy-day-blocks.helper.ts +48 -0
  74. package/translations/am.json +55 -0
  75. package/translations/en.json +55 -0
  76. 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,13 @@
1
+ @use '@carbon/layout';
2
+ @use '@carbon/type';
3
+
4
+ .description {
5
+ @include type.type-style('body-compact-01');
6
+ color: var(--cds-text-secondary);
7
+ }
8
+
9
+ .quickSelect {
10
+ display: flex;
11
+ flex-wrap: wrap;
12
+ gap: layout.$spacing-02;
13
+ }
@@ -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
+ }