@devpablocristo/modules-scheduling 0.4.0
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/package.json +49 -0
- package/src/BlockedRangeModal.tsx +256 -0
- package/src/PublicSchedulingFlow.test.tsx +192 -0
- package/src/PublicSchedulingFlow.tsx +570 -0
- package/src/QueueOperatorBoard.test.tsx +169 -0
- package/src/QueueOperatorBoard.tsx +650 -0
- package/src/SchedulingBookingModal.test.tsx +241 -0
- package/src/SchedulingBookingModal.tsx +551 -0
- package/src/SchedulingCalendar.test.tsx +830 -0
- package/src/SchedulingCalendarBoard.tsx +1596 -0
- package/src/SchedulingDaySummary.test.tsx +136 -0
- package/src/SchedulingDaySummary.tsx +190 -0
- package/src/client.test.ts +105 -0
- package/src/client.ts +300 -0
- package/src/index.ts +66 -0
- package/src/locale.test.ts +36 -0
- package/src/locale.ts +91 -0
- package/src/next.ts +66 -0
- package/src/schedulingCalendarLogic.ts +373 -0
- package/src/styles.css +649 -0
- package/src/styles.next.css +1 -0
- package/src/types.ts +653 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
2
|
+
import { confirmAction } from '@devpablocristo/core-browser';
|
|
3
|
+
import { formatSchedulingDateTime, formatSchedulingWeekdayNarrow } from './locale';
|
|
4
|
+
import type { Booking, SchedulingCalendarCopy, Service, TimeSlot } from './types';
|
|
5
|
+
|
|
6
|
+
export type SchedulingBookingRecurrenceDraft = {
|
|
7
|
+
mode: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
|
|
8
|
+
frequency: 'daily' | 'weekly' | 'monthly';
|
|
9
|
+
interval: string;
|
|
10
|
+
count: string;
|
|
11
|
+
byWeekday: number[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type SchedulingBookingDraft = {
|
|
15
|
+
title: string;
|
|
16
|
+
customerName: string;
|
|
17
|
+
customerPhone: string;
|
|
18
|
+
customerEmail: string;
|
|
19
|
+
notes: string;
|
|
20
|
+
recurrence: SchedulingBookingRecurrenceDraft;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SchedulingBookingCreateEditor = {
|
|
24
|
+
date: string;
|
|
25
|
+
startTime: string;
|
|
26
|
+
endTime: string;
|
|
27
|
+
resourceId: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SchedulingBookingCreateResourceOption = {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
timezone?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type SchedulingBookingModalState =
|
|
37
|
+
| {
|
|
38
|
+
open: false;
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
open: true;
|
|
42
|
+
mode: 'create';
|
|
43
|
+
slot: TimeSlot | null;
|
|
44
|
+
slotOptions: TimeSlot[];
|
|
45
|
+
resourceOptions: SchedulingBookingCreateResourceOption[];
|
|
46
|
+
editor: SchedulingBookingCreateEditor;
|
|
47
|
+
validationMessage?: string | null;
|
|
48
|
+
service?: Service;
|
|
49
|
+
resourceName?: string;
|
|
50
|
+
draft: SchedulingBookingDraft;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
open: true;
|
|
54
|
+
mode: 'details';
|
|
55
|
+
booking: Booking;
|
|
56
|
+
service?: Service;
|
|
57
|
+
resourceName?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type SchedulingBookingAction =
|
|
61
|
+
| 'confirm'
|
|
62
|
+
| 'cancel'
|
|
63
|
+
| 'check_in'
|
|
64
|
+
| 'start_service'
|
|
65
|
+
| 'complete'
|
|
66
|
+
| 'no_show';
|
|
67
|
+
|
|
68
|
+
type Props = {
|
|
69
|
+
state: SchedulingBookingModalState;
|
|
70
|
+
copy: SchedulingCalendarCopy;
|
|
71
|
+
locale?: string;
|
|
72
|
+
saving?: boolean;
|
|
73
|
+
slotLoading?: boolean;
|
|
74
|
+
onClose: () => void;
|
|
75
|
+
onEditorChange?: (editor: Partial<SchedulingBookingCreateEditor>) => void;
|
|
76
|
+
onCreate: (draft: SchedulingBookingDraft) => Promise<void> | void;
|
|
77
|
+
onAction: (action: SchedulingBookingAction, booking: Booking) => Promise<void> | void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function actionButtons(status: Booking['status']): SchedulingBookingAction[] {
|
|
81
|
+
switch (status) {
|
|
82
|
+
case 'hold':
|
|
83
|
+
case 'pending_confirmation':
|
|
84
|
+
return ['confirm', 'cancel'];
|
|
85
|
+
case 'confirmed':
|
|
86
|
+
return ['check_in', 'cancel', 'no_show'];
|
|
87
|
+
case 'checked_in':
|
|
88
|
+
return ['start_service', 'cancel'];
|
|
89
|
+
case 'in_service':
|
|
90
|
+
return ['complete'];
|
|
91
|
+
default:
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function actionLabel(copy: SchedulingCalendarCopy, action: SchedulingBookingAction): string {
|
|
97
|
+
switch (action) {
|
|
98
|
+
case 'confirm':
|
|
99
|
+
return copy.confirmBooking;
|
|
100
|
+
case 'cancel':
|
|
101
|
+
return copy.cancelBooking;
|
|
102
|
+
case 'check_in':
|
|
103
|
+
return copy.checkInBooking;
|
|
104
|
+
case 'start_service':
|
|
105
|
+
return copy.startService;
|
|
106
|
+
case 'complete':
|
|
107
|
+
return copy.completeBooking;
|
|
108
|
+
case 'no_show':
|
|
109
|
+
return copy.noShowBooking;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function defaultRecurrenceDraft(): SchedulingBookingRecurrenceDraft {
|
|
114
|
+
return {
|
|
115
|
+
mode: 'none',
|
|
116
|
+
frequency: 'weekly',
|
|
117
|
+
interval: '1',
|
|
118
|
+
count: '8',
|
|
119
|
+
byWeekday: [],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function SchedulingBookingModal({
|
|
124
|
+
state,
|
|
125
|
+
copy,
|
|
126
|
+
locale,
|
|
127
|
+
saving = false,
|
|
128
|
+
slotLoading = false,
|
|
129
|
+
onClose,
|
|
130
|
+
onEditorChange,
|
|
131
|
+
onCreate,
|
|
132
|
+
onAction,
|
|
133
|
+
}: Props) {
|
|
134
|
+
const [draft, setDraft] = useState<SchedulingBookingDraft>({
|
|
135
|
+
title: '',
|
|
136
|
+
customerName: '',
|
|
137
|
+
customerPhone: '',
|
|
138
|
+
customerEmail: '',
|
|
139
|
+
notes: '',
|
|
140
|
+
recurrence: defaultRecurrenceDraft(),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const closeWithGuard = async () => {
|
|
144
|
+
if (saving) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (state.open && state.mode === 'create') {
|
|
148
|
+
const dirty =
|
|
149
|
+
draft.title.trim() !== state.draft.title.trim() ||
|
|
150
|
+
draft.customerName.trim() !== state.draft.customerName.trim() ||
|
|
151
|
+
draft.customerPhone.trim() !== state.draft.customerPhone.trim() ||
|
|
152
|
+
draft.customerEmail.trim() !== state.draft.customerEmail.trim() ||
|
|
153
|
+
draft.notes.trim() !== state.draft.notes.trim() ||
|
|
154
|
+
JSON.stringify(draft.recurrence) !== JSON.stringify(state.draft.recurrence);
|
|
155
|
+
if (dirty) {
|
|
156
|
+
const confirmed = await confirmAction({
|
|
157
|
+
title: copy.closeDirtyTitle,
|
|
158
|
+
description: copy.closeDirtyDescription,
|
|
159
|
+
confirmLabel: copy.discard,
|
|
160
|
+
cancelLabel: copy.keepEditing,
|
|
161
|
+
tone: 'danger',
|
|
162
|
+
});
|
|
163
|
+
if (!confirmed) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
onClose();
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (state.open && state.mode === 'create') {
|
|
173
|
+
setDraft(state.draft);
|
|
174
|
+
}
|
|
175
|
+
}, [state]);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!state.open) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
183
|
+
if (event.key !== 'Escape') {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
event.preventDefault();
|
|
187
|
+
void closeWithGuard();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
window.addEventListener('keydown', onKeyDown);
|
|
191
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
192
|
+
}, [state.open, closeWithGuard]);
|
|
193
|
+
|
|
194
|
+
if (!state.open) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
if (state.open && state.mode === 'create' && !state.slot) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
await onCreate(draft);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const renderCreateSummary = () => {
|
|
207
|
+
if (!(state.open && state.mode === 'create')) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const selectedSlot = state.slot;
|
|
212
|
+
const activeFrequency = draft.recurrence.mode === 'custom' ? draft.recurrence.frequency : draft.recurrence.mode;
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="modules-scheduling__creator-grid">
|
|
216
|
+
<div className="modules-scheduling__creator-main">
|
|
217
|
+
<div className="form-group">
|
|
218
|
+
<label htmlFor="scheduling-booking-title">{copy.titleLabel}</label>
|
|
219
|
+
<input
|
|
220
|
+
id="scheduling-booking-title"
|
|
221
|
+
value={draft.title}
|
|
222
|
+
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
|
223
|
+
autoFocus
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<div className="modules-scheduling__form-row">
|
|
228
|
+
<div className="form-group grow">
|
|
229
|
+
<label htmlFor="scheduling-slot-date">{copy.focusDateLabel}</label>
|
|
230
|
+
<input
|
|
231
|
+
id="scheduling-slot-date"
|
|
232
|
+
type="date"
|
|
233
|
+
value={state.editor.date}
|
|
234
|
+
onChange={(event) => onEditorChange?.({ date: event.target.value })}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="form-group grow">
|
|
238
|
+
<label htmlFor="scheduling-slot-resource">{copy.resourceNameLabel}</label>
|
|
239
|
+
<select
|
|
240
|
+
id="scheduling-slot-resource"
|
|
241
|
+
value={state.editor.resourceId}
|
|
242
|
+
onChange={(event) => onEditorChange?.({ resourceId: event.target.value })}
|
|
243
|
+
>
|
|
244
|
+
{state.resourceOptions.map((resource) => (
|
|
245
|
+
<option key={resource.id} value={resource.id}>
|
|
246
|
+
{resource.name}
|
|
247
|
+
</option>
|
|
248
|
+
))}
|
|
249
|
+
</select>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="modules-scheduling__form-row">
|
|
254
|
+
<div className="form-group grow">
|
|
255
|
+
<label htmlFor="scheduling-slot-start">{copy.slotStartLabel}</label>
|
|
256
|
+
<input
|
|
257
|
+
id="scheduling-slot-start"
|
|
258
|
+
type="time"
|
|
259
|
+
step={60 * ((selectedSlot?.granularity_minutes ?? state.service?.slot_granularity_minutes ?? 30))}
|
|
260
|
+
value={state.editor.startTime}
|
|
261
|
+
onChange={(event) => onEditorChange?.({ startTime: event.target.value })}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
264
|
+
<div className="form-group grow">
|
|
265
|
+
<label htmlFor="scheduling-slot-end">{copy.slotEndLabel}</label>
|
|
266
|
+
<input
|
|
267
|
+
id="scheduling-slot-end"
|
|
268
|
+
type="time"
|
|
269
|
+
step={60 * ((selectedSlot?.granularity_minutes ?? state.service?.slot_granularity_minutes ?? 30))}
|
|
270
|
+
value={state.editor.endTime}
|
|
271
|
+
onChange={(event) => onEditorChange?.({ endTime: event.target.value })}
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
<div className="modules-scheduling__form-row">
|
|
277
|
+
<div className="form-group grow">
|
|
278
|
+
<label htmlFor="scheduling-repeat-mode">{copy.repeatLabel}</label>
|
|
279
|
+
<select
|
|
280
|
+
id="scheduling-repeat-mode"
|
|
281
|
+
value={draft.recurrence.mode}
|
|
282
|
+
onChange={(event) =>
|
|
283
|
+
setDraft((current) => ({
|
|
284
|
+
...current,
|
|
285
|
+
recurrence: {
|
|
286
|
+
...current.recurrence,
|
|
287
|
+
mode: event.target.value as SchedulingBookingRecurrenceDraft['mode'],
|
|
288
|
+
},
|
|
289
|
+
}))
|
|
290
|
+
}
|
|
291
|
+
>
|
|
292
|
+
<option value="none">{copy.repeatNever}</option>
|
|
293
|
+
<option value="daily">{copy.repeatDaily}</option>
|
|
294
|
+
<option value="weekly">{copy.repeatWeekly}</option>
|
|
295
|
+
<option value="monthly">{copy.repeatMonthly}</option>
|
|
296
|
+
<option value="custom">{copy.repeatCustom}</option>
|
|
297
|
+
</select>
|
|
298
|
+
</div>
|
|
299
|
+
{draft.recurrence.mode !== 'none' ? (
|
|
300
|
+
<div className="form-group grow">
|
|
301
|
+
<label htmlFor="scheduling-repeat-interval">{copy.repeatIntervalLabel}</label>
|
|
302
|
+
<input
|
|
303
|
+
id="scheduling-repeat-interval"
|
|
304
|
+
type="number"
|
|
305
|
+
min={1}
|
|
306
|
+
max={60}
|
|
307
|
+
value={draft.recurrence.interval}
|
|
308
|
+
onChange={(event) =>
|
|
309
|
+
setDraft((current) => ({
|
|
310
|
+
...current,
|
|
311
|
+
recurrence: {
|
|
312
|
+
...current.recurrence,
|
|
313
|
+
interval: event.target.value,
|
|
314
|
+
},
|
|
315
|
+
}))
|
|
316
|
+
}
|
|
317
|
+
/>
|
|
318
|
+
</div>
|
|
319
|
+
) : null}
|
|
320
|
+
{draft.recurrence.mode !== 'none' ? (
|
|
321
|
+
<div className="form-group grow">
|
|
322
|
+
<label htmlFor="scheduling-repeat-count">{copy.repeatCountLabel}</label>
|
|
323
|
+
<input
|
|
324
|
+
id="scheduling-repeat-count"
|
|
325
|
+
type="number"
|
|
326
|
+
min={1}
|
|
327
|
+
max={60}
|
|
328
|
+
value={draft.recurrence.count}
|
|
329
|
+
onChange={(event) =>
|
|
330
|
+
setDraft((current) => ({
|
|
331
|
+
...current,
|
|
332
|
+
recurrence: {
|
|
333
|
+
...current.recurrence,
|
|
334
|
+
count: event.target.value,
|
|
335
|
+
},
|
|
336
|
+
}))
|
|
337
|
+
}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
) : null}
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{draft.recurrence.mode === 'custom' ? (
|
|
344
|
+
<div className="form-group">
|
|
345
|
+
<label htmlFor="scheduling-repeat-frequency">{copy.repeatFrequencyLabel}</label>
|
|
346
|
+
<select
|
|
347
|
+
id="scheduling-repeat-frequency"
|
|
348
|
+
value={draft.recurrence.frequency}
|
|
349
|
+
onChange={(event) =>
|
|
350
|
+
setDraft((current) => ({
|
|
351
|
+
...current,
|
|
352
|
+
recurrence: {
|
|
353
|
+
...current.recurrence,
|
|
354
|
+
frequency: event.target.value as SchedulingBookingRecurrenceDraft['frequency'],
|
|
355
|
+
},
|
|
356
|
+
}))
|
|
357
|
+
}
|
|
358
|
+
>
|
|
359
|
+
<option value="daily">{copy.repeatDaily}</option>
|
|
360
|
+
<option value="weekly">{copy.repeatWeekly}</option>
|
|
361
|
+
<option value="monthly">{copy.repeatMonthly}</option>
|
|
362
|
+
</select>
|
|
363
|
+
</div>
|
|
364
|
+
) : null}
|
|
365
|
+
|
|
366
|
+
{draft.recurrence.mode !== 'none' && activeFrequency === 'weekly' ? (
|
|
367
|
+
<div className="form-group">
|
|
368
|
+
<label>{copy.repeatWeekdaysLabel}</label>
|
|
369
|
+
<div className="modules-scheduling__weekday-picker">
|
|
370
|
+
{[0, 1, 2, 3, 4, 5, 6].map((weekday) => {
|
|
371
|
+
const active = draft.recurrence.byWeekday.includes(weekday);
|
|
372
|
+
return (
|
|
373
|
+
<button
|
|
374
|
+
key={weekday}
|
|
375
|
+
type="button"
|
|
376
|
+
className={`modules-scheduling__weekday-btn ${active ? 'modules-scheduling__weekday-btn--active' : ''}`}
|
|
377
|
+
onClick={() =>
|
|
378
|
+
setDraft((current) => ({
|
|
379
|
+
...current,
|
|
380
|
+
recurrence: {
|
|
381
|
+
...current.recurrence,
|
|
382
|
+
byWeekday: active
|
|
383
|
+
? current.recurrence.byWeekday.filter((item) => item !== weekday)
|
|
384
|
+
: [...current.recurrence.byWeekday, weekday].sort((left, right) => left - right),
|
|
385
|
+
},
|
|
386
|
+
}))
|
|
387
|
+
}
|
|
388
|
+
>
|
|
389
|
+
{formatSchedulingWeekdayNarrow(weekday, locale)}
|
|
390
|
+
</button>
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
) : null}
|
|
396
|
+
|
|
397
|
+
{slotLoading ? <div className="modules-scheduling__validation-message">{copy.availableSlotLoading}</div> : null}
|
|
398
|
+
{!slotLoading && state.validationMessage ? (
|
|
399
|
+
<div className="modules-scheduling__validation-message">{state.validationMessage}</div>
|
|
400
|
+
) : null}
|
|
401
|
+
|
|
402
|
+
<div className="form-group">
|
|
403
|
+
<label htmlFor="scheduling-customer-name">{copy.customerNameLabel}</label>
|
|
404
|
+
<input
|
|
405
|
+
id="scheduling-customer-name"
|
|
406
|
+
value={draft.customerName}
|
|
407
|
+
onChange={(event) => setDraft((current) => ({ ...current, customerName: event.target.value }))}
|
|
408
|
+
required
|
|
409
|
+
autoComplete="name"
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<div className="modules-scheduling__form-row">
|
|
414
|
+
<div className="form-group grow">
|
|
415
|
+
<label htmlFor="scheduling-customer-phone">{copy.customerPhoneLabel}</label>
|
|
416
|
+
<input
|
|
417
|
+
id="scheduling-customer-phone"
|
|
418
|
+
type="tel"
|
|
419
|
+
value={draft.customerPhone}
|
|
420
|
+
onChange={(event) => setDraft((current) => ({ ...current, customerPhone: event.target.value }))}
|
|
421
|
+
required
|
|
422
|
+
autoComplete="tel"
|
|
423
|
+
/>
|
|
424
|
+
</div>
|
|
425
|
+
<div className="form-group grow">
|
|
426
|
+
<label htmlFor="scheduling-customer-email">{copy.customerEmailLabel}</label>
|
|
427
|
+
<input
|
|
428
|
+
id="scheduling-customer-email"
|
|
429
|
+
type="email"
|
|
430
|
+
value={draft.customerEmail}
|
|
431
|
+
onChange={(event) => setDraft((current) => ({ ...current, customerEmail: event.target.value }))}
|
|
432
|
+
autoComplete="email"
|
|
433
|
+
/>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<div className="form-group">
|
|
438
|
+
<label htmlFor="scheduling-notes">{copy.notesLabel}</label>
|
|
439
|
+
<textarea
|
|
440
|
+
id="scheduling-notes"
|
|
441
|
+
rows={4}
|
|
442
|
+
value={draft.notes}
|
|
443
|
+
onChange={(event) => setDraft((current) => ({ ...current, notes: event.target.value }))}
|
|
444
|
+
/>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div className="modules-scheduling__backdrop app-modal-backdrop" role="presentation" onClick={() => void closeWithGuard()}>
|
|
453
|
+
<div className="modules-scheduling__modal app-modal" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
|
454
|
+
<div className="app-modal__header">
|
|
455
|
+
{state.mode === 'details' ? (
|
|
456
|
+
<div className="app-modal__title-block">
|
|
457
|
+
<p className="app-modal__eyebrow">{copy.bookingSubtitleDetails}</p>
|
|
458
|
+
<h3 className="app-modal__title">{copy.bookingTitleDetails}</h3>
|
|
459
|
+
<p className="app-modal__subtitle">
|
|
460
|
+
{`${formatSchedulingDateTime(state.booking.start_at, locale)} • ${state.resourceName ?? state.booking.resource_id}`}
|
|
461
|
+
</p>
|
|
462
|
+
</div>
|
|
463
|
+
) : (
|
|
464
|
+
<div className="app-modal__footer-spacer" aria-hidden />
|
|
465
|
+
)}
|
|
466
|
+
<button type="button" className="app-modal__close" aria-label={copy.close} onClick={() => void closeWithGuard()}>
|
|
467
|
+
×
|
|
468
|
+
</button>
|
|
469
|
+
</div>
|
|
470
|
+
|
|
471
|
+
{state.mode === 'create' ? (
|
|
472
|
+
<form className="app-modal__body modules-scheduling__modal-form" onSubmit={handleCreate}>
|
|
473
|
+
{renderCreateSummary()}
|
|
474
|
+
|
|
475
|
+
<div className="app-modal__footer">
|
|
476
|
+
<div className="app-modal__footer-spacer" aria-hidden />
|
|
477
|
+
<button type="button" className="btn-secondary btn-sm app-modal__action" disabled={saving} onClick={() => void closeWithGuard()}>
|
|
478
|
+
{copy.close}
|
|
479
|
+
</button>
|
|
480
|
+
<button type="submit" className="btn-primary btn-sm app-modal__action" disabled={saving || slotLoading || !state.slot}>
|
|
481
|
+
{saving ? copy.saving : copy.create}
|
|
482
|
+
</button>
|
|
483
|
+
</div>
|
|
484
|
+
</form>
|
|
485
|
+
) : (
|
|
486
|
+
<>
|
|
487
|
+
<div className="app-modal__body modules-scheduling__modal-body">
|
|
488
|
+
<div className="modules-scheduling__detail-grid">
|
|
489
|
+
<div className="modules-scheduling__detail">
|
|
490
|
+
<span>{copy.customerNameLabel}</span>
|
|
491
|
+
<strong>{state.booking.customer_name}</strong>
|
|
492
|
+
</div>
|
|
493
|
+
<div className="modules-scheduling__detail">
|
|
494
|
+
<span>{copy.customerPhoneLabel}</span>
|
|
495
|
+
<strong>{state.booking.customer_phone || '—'}</strong>
|
|
496
|
+
</div>
|
|
497
|
+
<div className="modules-scheduling__detail">
|
|
498
|
+
<span>{copy.customerEmailLabel}</span>
|
|
499
|
+
<strong>{state.booking.customer_email || '—'}</strong>
|
|
500
|
+
</div>
|
|
501
|
+
<div className="modules-scheduling__detail">
|
|
502
|
+
<span>{copy.statusLabel}</span>
|
|
503
|
+
<strong>{copy.statuses[state.booking.status]}</strong>
|
|
504
|
+
</div>
|
|
505
|
+
<div className="modules-scheduling__detail">
|
|
506
|
+
<span>{copy.serviceNameLabel}</span>
|
|
507
|
+
<strong>{state.service?.name ?? state.booking.service_id}</strong>
|
|
508
|
+
</div>
|
|
509
|
+
<div className="modules-scheduling__detail">
|
|
510
|
+
<span>{copy.resourceNameLabel}</span>
|
|
511
|
+
<strong>{state.resourceName ?? state.booking.resource_id}</strong>
|
|
512
|
+
</div>
|
|
513
|
+
<div className="modules-scheduling__detail">
|
|
514
|
+
<span>{copy.slotLabel}</span>
|
|
515
|
+
<strong>{formatSchedulingDateTime(state.booking.start_at, locale)}</strong>
|
|
516
|
+
</div>
|
|
517
|
+
<div className="modules-scheduling__detail">
|
|
518
|
+
<span>{copy.referenceLabel}</span>
|
|
519
|
+
<strong>{state.booking.reference}</strong>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
{state.booking.notes ? (
|
|
523
|
+
<div className="modules-scheduling__notes">
|
|
524
|
+
<span>{copy.notesLabel}</span>
|
|
525
|
+
<p>{state.booking.notes}</p>
|
|
526
|
+
</div>
|
|
527
|
+
) : null}
|
|
528
|
+
</div>
|
|
529
|
+
<div className="app-modal__footer">
|
|
530
|
+
{actionButtons(state.booking.status).map((action) => (
|
|
531
|
+
<button
|
|
532
|
+
key={action}
|
|
533
|
+
type="button"
|
|
534
|
+
className={action === 'cancel' || action === 'no_show' ? 'btn-danger btn-sm app-modal__action' : 'btn-secondary btn-sm app-modal__action'}
|
|
535
|
+
onClick={() => void onAction(action, state.booking)}
|
|
536
|
+
disabled={saving}
|
|
537
|
+
>
|
|
538
|
+
{actionLabel(copy, action)}
|
|
539
|
+
</button>
|
|
540
|
+
))}
|
|
541
|
+
<div className="app-modal__footer-spacer" aria-hidden />
|
|
542
|
+
<button type="button" className="btn-secondary btn-sm app-modal__action" disabled={saving} onClick={onClose}>
|
|
543
|
+
{copy.close}
|
|
544
|
+
</button>
|
|
545
|
+
</div>
|
|
546
|
+
</>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
);
|
|
551
|
+
}
|