@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.
@@ -0,0 +1,1596 @@
1
+ import { useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import type { EventClickArg, EventContentArg, EventInput } from '@fullcalendar/core';
4
+ import FullCalendar from '@fullcalendar/react';
5
+ import { confirmAction } from '@devpablocristo/core-browser';
6
+ import {
7
+ CalendarSurface,
8
+ resolveInitialTimeGridScrollTime,
9
+ resolveInitialTimeGridViewport,
10
+ type CalendarSurfaceProps,
11
+ type CalendarView,
12
+ } from '../../../calendar/board/ts/src/next';
13
+ import type { SchedulingClient } from './client';
14
+ import {
15
+ formatSchedulingClock,
16
+ formatSchedulingCompactClock,
17
+ formatSchedulingDateOnly,
18
+ resolveSchedulingCopyLocale,
19
+ } from './locale';
20
+ import {
21
+ SchedulingBookingModal,
22
+ type SchedulingBookingAction,
23
+ type SchedulingBookingCreateEditor,
24
+ type SchedulingBookingDraft,
25
+ type SchedulingBookingModalState,
26
+ } from './SchedulingBookingModal';
27
+ import {
28
+ BlockedRangeModal,
29
+ blockedRangeDraftFromRange,
30
+ emptyBlockedRangeDraft,
31
+ type BlockedRangeDraft,
32
+ type BlockedRangeModalState,
33
+ } from './BlockedRangeModal';
34
+ import {
35
+ buildFullCalendarEventInputs,
36
+ buildSchedulingCreateModalStateFromSlot,
37
+ buildSchedulingCreateModalStateFromStart,
38
+ buildSchedulingBusinessHours,
39
+ buildSchedulingCalendarEvents,
40
+ buildSchedulingDetailsModalState,
41
+ buildSlotIdentity,
42
+ buildSyntheticTimeSlotFromEditor,
43
+ resolveBookingDisplayTitle,
44
+ toDateInputValue as toDateInputValueFromIso,
45
+ toTimeInputValue,
46
+ } from './schedulingCalendarLogic';
47
+ import type {
48
+ AvailabilityRule,
49
+ BlockedRange,
50
+ BlockedRangePayload,
51
+ Booking,
52
+ BookingStatus,
53
+ Branch,
54
+ CalendarEvent,
55
+ DashboardStats,
56
+ Resource,
57
+ SchedulingCalendarCopy,
58
+ Service,
59
+ TimeSlot,
60
+ } from './types';
61
+
62
+ const schedulingKeys = {
63
+ branches: ['scheduling', 'branches'] as const,
64
+ services: ['scheduling', 'services'] as const,
65
+ resources: (branchId: string | null) => ['scheduling', 'resources', branchId ?? 'all'] as const,
66
+ availabilityRules: (branchId: string | null, resourceId: string | null) =>
67
+ ['scheduling', 'availability-rules', branchId ?? 'none', resourceId ?? 'any'] as const,
68
+ dashboard: (branchId: string | null, day: string) => ['scheduling', 'dashboard', branchId ?? 'all', day] as const,
69
+ slots: (branchId: string | null, serviceId: string | null, resourceId: string | null, day: string) =>
70
+ ['scheduling', 'slots', branchId ?? 'none', serviceId ?? 'none', resourceId ?? 'any', day] as const,
71
+ visibleSlotsRange: (branchId: string | null, serviceId: string | null, resourceId: string | null, start: string, end: string) =>
72
+ ['scheduling', 'visible-slots-range', branchId ?? 'none', serviceId ?? 'none', resourceId ?? 'any', start, end] as const,
73
+ bookingsRange: (branchId: string | null, start: string, end: string) =>
74
+ ['scheduling', 'bookings-range', branchId ?? 'none', start, end] as const,
75
+ blockedRangesRange: (branchId: string | null, start: string, end: string) =>
76
+ ['scheduling', 'blocked-ranges-range', branchId ?? 'none', start, end] as const,
77
+ };
78
+
79
+ /** Referencia estable cuando aún no hay datos de slots (evita loop en efecto de sync del modal). */
80
+ const emptySlotOptions: TimeSlot[] = [];
81
+
82
+ /** RFC3339 sin fracciones (alinea payloads con slots del API y expectativas de tests). */
83
+ function schedulingInstantRFC3339(iso: string): string {
84
+ const ms = Date.parse(iso);
85
+ if (!Number.isFinite(ms)) {
86
+ return iso;
87
+ }
88
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
89
+ }
90
+
91
+ export const schedulingCalendarCopyPresets: Record<'en' | 'es', SchedulingCalendarCopy> = {
92
+ en: {
93
+ branchLabel: 'Branch',
94
+ serviceLabel: 'Service',
95
+ resourceLabel: 'Resource',
96
+ anyResource: 'Any resource',
97
+ focusDateLabel: 'Date',
98
+ summaryTitle: 'Daily overview',
99
+ summaryBookings: 'Bookings',
100
+ summaryConfirmed: 'Confirmed',
101
+ summaryQueues: 'Active queues',
102
+ summaryWaiting: 'Waiting tickets',
103
+ slotsTitle: 'Available slots',
104
+ slotsDescription: 'Slots are generated from availability rules, buffers, and conflicts.',
105
+ slotsEmpty: 'No slots available for the current filters.',
106
+ slotsLoading: 'Loading slots…',
107
+ loading: 'Loading schedule…',
108
+ unavailableTitle: 'Schedule is not configured yet',
109
+ unavailableDescription: 'Create at least one active branch and one schedule-enabled service to use this calendar.',
110
+ filtersTitle: 'Filters',
111
+ filtersDescription: 'Branch and service are mandatory. Resource is optional.',
112
+ timelineTitle: '',
113
+ timelineDescription: '',
114
+ openBooking: 'Book slot',
115
+ titleLabel: 'Title',
116
+ repeatLabel: 'Repeat',
117
+ repeatNever: 'Does not repeat',
118
+ repeatDaily: 'Daily',
119
+ repeatWeekly: 'Weekly',
120
+ repeatMonthly: 'Monthly',
121
+ repeatCustom: 'Custom',
122
+ repeatFrequencyLabel: 'Frequency',
123
+ repeatIntervalLabel: 'Every',
124
+ repeatCountLabel: 'Occurrences',
125
+ repeatWeekdaysLabel: 'Weekdays',
126
+ bookingTitleCreate: 'Create booking',
127
+ bookingTitleDetails: 'Booking details',
128
+ bookingSubtitleCreate: 'New booking',
129
+ bookingSubtitleDetails: 'Current lifecycle state',
130
+ availableSlotLabel: 'Available slot',
131
+ availableSlotHint: 'Change the time, duration, or resource using an available slot.',
132
+ availableSlotLoading: 'Checking availability…',
133
+ unavailableSlotMessage: 'No available slot matches the selected date, time, duration, and resource.',
134
+ slotSummaryTitle: 'Slot summary',
135
+ bookingPreviewTitle: 'Booking preview',
136
+ customerNameLabel: 'Customer name',
137
+ customerPhoneLabel: 'Phone',
138
+ customerEmailLabel: 'Email',
139
+ notesLabel: 'Notes',
140
+ statusLabel: 'Status',
141
+ serviceNameLabel: 'Service',
142
+ resourceNameLabel: 'Resource',
143
+ slotLabel: 'Slot',
144
+ slotStartLabel: 'Start',
145
+ slotEndLabel: 'End',
146
+ durationLabel: 'Duration',
147
+ timezoneLabel: 'Timezone',
148
+ occupiesLabel: 'Occupies',
149
+ conflictLabel: 'Conflicts',
150
+ slotRemainingLabel: 'Open spots',
151
+ referenceLabel: 'Reference',
152
+ close: 'Close',
153
+ create: 'Save',
154
+ saving: 'Saving…',
155
+ cancelBooking: 'Cancel booking',
156
+ confirmBooking: 'Confirm booking',
157
+ checkInBooking: 'Check in',
158
+ startService: 'Start service',
159
+ completeBooking: 'Complete',
160
+ noShowBooking: 'Mark as no-show',
161
+ rescheduleBooking: 'Reschedule booking',
162
+ dragRescheduleTitle: 'Move event',
163
+ dragRescheduleDescription: 'Do you want to move this event to the new slot?',
164
+ destructiveTitle: 'Confirm action',
165
+ cancelActionDescription: 'This booking will be cancelled and removed from the active agenda.',
166
+ noShowActionDescription: 'This booking will be marked as no-show.',
167
+ closeDirtyTitle: 'Discard booking draft',
168
+ closeDirtyDescription: 'You have unsaved changes in this booking draft.',
169
+ keepEditing: 'Keep editing',
170
+ discard: 'Discard',
171
+ resizeLockedMessage: 'This calendar event keeps the duration defined by the service.',
172
+ resizeBookingTitle: 'Resize booking',
173
+ resizeBookingDescription: 'Do you want to update this booking with the new duration?',
174
+ searchPlaceholder: 'Search events, customers, resources…',
175
+ statuses: {
176
+ hold: 'On hold',
177
+ pending_confirmation: 'Pending confirmation',
178
+ confirmed: 'Confirmed',
179
+ checked_in: 'Checked in',
180
+ in_service: 'In service',
181
+ completed: 'Completed',
182
+ cancelled: 'Cancelled',
183
+ no_show: 'No show',
184
+ expired: 'Expired',
185
+ },
186
+ blockedRangeAction: 'Block time',
187
+ blockedRangeEyebrow: 'Calendar block',
188
+ blockedRangeCreateTitle: 'Block time',
189
+ blockedRangeEditTitle: 'Edit block',
190
+ blockedRangeKindLabel: 'Type',
191
+ blockedRangeKindOptions: {
192
+ manual: 'Personal block',
193
+ holiday: 'Holiday',
194
+ maintenance: 'Maintenance',
195
+ leave: 'Leave',
196
+ },
197
+ blockedRangeReasonLabel: 'Reason',
198
+ blockedRangeReasonPlaceholder: 'Meeting with supplier, vacation…',
199
+ blockedRangeCreate: 'Save block',
200
+ blockedRangeUpdate: 'Update block',
201
+ blockedRangeDelete: 'Delete block',
202
+ blockedRangeDeleteTitle: 'Delete this block?',
203
+ blockedRangeDeleteDescription: 'The blocked time will be removed and the slot becomes available again.',
204
+ blockedRangeFallbackTitle: 'Blocked',
205
+ },
206
+ es: {
207
+ branchLabel: 'Sucursal',
208
+ serviceLabel: 'Servicio',
209
+ resourceLabel: 'Recurso',
210
+ anyResource: 'Cualquier recurso',
211
+ focusDateLabel: 'Fecha foco',
212
+ summaryTitle: 'Panorama diario',
213
+ summaryBookings: 'Reservas',
214
+ summaryConfirmed: 'Confirmadas',
215
+ summaryQueues: 'Colas activas',
216
+ summaryWaiting: 'Esperando',
217
+ slotsTitle: 'Slots disponibles',
218
+ slotsDescription: 'Los slots se calculan con disponibilidad, buffers y conflictos.',
219
+ slotsEmpty: 'No hay slots disponibles para los filtros actuales.',
220
+ slotsLoading: 'Cargando slots…',
221
+ loading: 'Cargando agenda…',
222
+ unavailableTitle: 'La agenda todavía no está configurada',
223
+ unavailableDescription: 'Creá al menos una sucursal activa y un servicio de agenda para usar este calendario.',
224
+ filtersTitle: 'Filtros',
225
+ filtersDescription: 'Sucursal y servicio son obligatorios. Recurso es opcional.',
226
+ timelineTitle: '',
227
+ timelineDescription: '',
228
+ openBooking: 'Reservar slot',
229
+ titleLabel: 'Título',
230
+ repeatLabel: 'Repetir',
231
+ repeatNever: 'No se repite',
232
+ repeatDaily: 'Diaria',
233
+ repeatWeekly: 'Semanal',
234
+ repeatMonthly: 'Mensual',
235
+ repeatCustom: 'Personalizada',
236
+ repeatFrequencyLabel: 'Frecuencia',
237
+ repeatIntervalLabel: 'Cada',
238
+ repeatCountLabel: 'Ocurrencias',
239
+ repeatWeekdaysLabel: 'Días',
240
+ bookingTitleCreate: 'Crear reserva',
241
+ bookingTitleDetails: 'Detalle de la reserva',
242
+ bookingSubtitleCreate: 'Nueva reserva',
243
+ bookingSubtitleDetails: 'Estado actual del turno',
244
+ availableSlotLabel: 'Slot disponible',
245
+ availableSlotHint: 'Cambiá hora, duración o recurso usando un slot válido.',
246
+ availableSlotLoading: 'Buscando disponibilidad...',
247
+ unavailableSlotMessage: 'No hay un slot disponible para la fecha, hora, duración y recurso elegidos.',
248
+ slotSummaryTitle: 'Resumen del slot',
249
+ bookingPreviewTitle: 'Vista previa',
250
+ customerNameLabel: 'Cliente',
251
+ customerPhoneLabel: 'Teléfono',
252
+ customerEmailLabel: 'Email',
253
+ notesLabel: 'Notas',
254
+ statusLabel: 'Estado',
255
+ serviceNameLabel: 'Servicio',
256
+ resourceNameLabel: 'Recurso',
257
+ slotLabel: 'Slot',
258
+ slotStartLabel: 'Inicio',
259
+ slotEndLabel: 'Fin',
260
+ durationLabel: 'Duración',
261
+ timezoneLabel: 'Zona horaria',
262
+ occupiesLabel: 'Bloquea',
263
+ conflictLabel: 'Conflictos',
264
+ slotRemainingLabel: 'Cupos',
265
+ referenceLabel: 'Referencia',
266
+ close: 'Cerrar',
267
+ create: 'Guardar',
268
+ saving: 'Guardando…',
269
+ cancelBooking: 'Cancelar reserva',
270
+ confirmBooking: 'Confirmar',
271
+ checkInBooking: 'Check-in',
272
+ startService: 'Iniciar atención',
273
+ completeBooking: 'Completar',
274
+ noShowBooking: 'Marcar no-show',
275
+ rescheduleBooking: 'Reprogramar reserva',
276
+ dragRescheduleTitle: 'Mover evento',
277
+ dragRescheduleDescription: '¿Querés mover este evento al nuevo horario?',
278
+ destructiveTitle: 'Confirmar acción',
279
+ cancelActionDescription: 'La reserva se cancelará y saldrá de la agenda activa.',
280
+ noShowActionDescription: 'La reserva se marcará como no-show.',
281
+ closeDirtyTitle: 'Descartar borrador',
282
+ closeDirtyDescription: 'Hay datos cargados sin guardar en esta reserva.',
283
+ keepEditing: 'Seguir editando',
284
+ discard: 'Descartar',
285
+ resizeLockedMessage: 'Este evento del calendario mantiene la duración definida por el servicio.',
286
+ resizeBookingTitle: 'Cambiar duración del turno',
287
+ resizeBookingDescription: '¿Querés actualizar este turno con la nueva duración?',
288
+ searchPlaceholder: 'Buscar eventos, clientes, recursos…',
289
+ statuses: {
290
+ hold: 'En espera',
291
+ pending_confirmation: 'Pendiente de confirmación',
292
+ confirmed: 'Confirmada',
293
+ checked_in: 'Check-in',
294
+ in_service: 'En atención',
295
+ completed: 'Completada',
296
+ cancelled: 'Cancelada',
297
+ no_show: 'No-show',
298
+ expired: 'Expirada',
299
+ },
300
+ blockedRangeAction: 'Bloquear horario',
301
+ blockedRangeEyebrow: 'Bloqueo de agenda',
302
+ blockedRangeCreateTitle: 'Bloquear horario',
303
+ blockedRangeEditTitle: 'Editar bloqueo',
304
+ blockedRangeKindLabel: 'Tipo',
305
+ blockedRangeKindOptions: {
306
+ manual: 'Bloqueo personal',
307
+ holiday: 'Feriado',
308
+ maintenance: 'Mantenimiento',
309
+ leave: 'Licencia',
310
+ },
311
+ blockedRangeReasonLabel: 'Motivo',
312
+ blockedRangeReasonPlaceholder: 'Reunión con proveedor, vacaciones…',
313
+ blockedRangeCreate: 'Guardar bloqueo',
314
+ blockedRangeUpdate: 'Actualizar bloqueo',
315
+ blockedRangeDelete: 'Eliminar bloqueo',
316
+ blockedRangeDeleteTitle: '¿Eliminar este bloqueo?',
317
+ blockedRangeDeleteDescription: 'El horario bloqueado se eliminará y volverá a estar disponible.',
318
+ blockedRangeFallbackTitle: 'Bloqueado',
319
+ },
320
+ };
321
+
322
+ export type SchedulingCalendarProps = {
323
+ client: SchedulingClient;
324
+ searchQuery?: string;
325
+ copy?: Partial<SchedulingCalendarCopy>;
326
+ locale?: string;
327
+ initialBranchId?: string;
328
+ initialServiceId?: string;
329
+ initialResourceId?: string | null;
330
+ initialDate?: string;
331
+ className?: string;
332
+ };
333
+
334
+ type VisibleRange = {
335
+ start: Date;
336
+ end: Date;
337
+ };
338
+
339
+ type CalendarDateClickArg = Parameters<NonNullable<CalendarSurfaceProps['onDateClick']>>[0];
340
+ type CalendarDateSelectArg = Parameters<NonNullable<CalendarSurfaceProps['onSelect']>>[0];
341
+ type CalendarEventDropArg = Parameters<NonNullable<CalendarSurfaceProps['onEventDrop']>>[0];
342
+ type CalendarEventResizeArg = Parameters<NonNullable<CalendarSurfaceProps['onEventResize']>>[0];
343
+ type CalendarSelectAllowArg = Parameters<NonNullable<CalendarSurfaceProps['selectAllow']>>[0];
344
+ type CalendarEventAllowArg = Parameters<NonNullable<CalendarSurfaceProps['eventAllow']>>[0];
345
+ type CalendarDraggedEventArg = Parameters<NonNullable<CalendarSurfaceProps['eventAllow']>>[1];
346
+
347
+ function startOfDay(value: Date): Date {
348
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate());
349
+ }
350
+
351
+ function toDateInputValue(value: Date): string {
352
+ return value.toISOString().slice(0, 10);
353
+ }
354
+
355
+ function statusClassName(status: BookingStatus): string {
356
+ switch (status) {
357
+ case 'confirmed':
358
+ return 'modules-scheduling__badge modules-scheduling__badge--success';
359
+ case 'checked_in':
360
+ case 'in_service':
361
+ return 'modules-scheduling__badge modules-scheduling__badge--attention';
362
+ case 'cancelled':
363
+ case 'no_show':
364
+ case 'expired':
365
+ return 'modules-scheduling__badge modules-scheduling__badge--critical';
366
+ default:
367
+ return 'modules-scheduling__badge modules-scheduling__badge--neutral';
368
+ }
369
+ }
370
+
371
+ function eventColor(status: BookingStatus): string {
372
+ switch (status) {
373
+ case 'confirmed':
374
+ return '#0f766e';
375
+ case 'checked_in':
376
+ return '#1d4ed8';
377
+ case 'in_service':
378
+ return '#7c3aed';
379
+ case 'completed':
380
+ return '#475569';
381
+ case 'cancelled':
382
+ case 'no_show':
383
+ case 'expired':
384
+ return '#b91c1c';
385
+ default:
386
+ return '#d97706';
387
+ }
388
+ }
389
+
390
+ function buildVisibleDates(range: VisibleRange): string[] {
391
+ const dates: string[] = [];
392
+ const cursor = startOfDay(range.start);
393
+ const limit = startOfDay(range.end);
394
+ while (cursor < limit) {
395
+ dates.push(toDateInputValue(cursor));
396
+ cursor.setDate(cursor.getDate() + 1);
397
+ }
398
+ return dates;
399
+ }
400
+
401
+ function uniqueBookings(items: Booking[]): Booking[] {
402
+ const seen = new Map<string, Booking>();
403
+ for (const item of items) {
404
+ seen.set(item.id, item);
405
+ }
406
+ return Array.from(seen.values()).sort((left, right) => left.start_at.localeCompare(right.start_at));
407
+ }
408
+
409
+ function defaultVisibleRange(): VisibleRange {
410
+ const today = new Date();
411
+ const start = startOfDay(today);
412
+ const end = new Date(start);
413
+ end.setDate(end.getDate() + 7);
414
+ return { start, end };
415
+ }
416
+
417
+ function matchesSearch(searchQuery: string, pieces: Array<string | undefined | null>): boolean {
418
+ if (!searchQuery.trim()) {
419
+ return true;
420
+ }
421
+ const normalized = searchQuery.trim().toLocaleLowerCase();
422
+ return pieces.some((piece) => piece?.toLocaleLowerCase().includes(normalized));
423
+ }
424
+
425
+ function matchesCreateEditorSlot(editor: SchedulingBookingCreateEditor, slot: TimeSlot): boolean {
426
+ return (
427
+ slot.resource_id === editor.resourceId &&
428
+ toDateInputValueFromIso(slot.start_at) === editor.date &&
429
+ toTimeInputValue(slot.start_at) === editor.startTime &&
430
+ toTimeInputValue(slot.end_at) === editor.endTime
431
+ );
432
+ }
433
+
434
+ export function SchedulingCalendar({
435
+ client,
436
+ searchQuery = '',
437
+ copy: copyOverrides,
438
+ locale = 'en',
439
+ initialBranchId,
440
+ initialServiceId,
441
+ initialResourceId = null,
442
+ initialDate,
443
+ className = '',
444
+ }: SchedulingCalendarProps) {
445
+ const queryClient = useQueryClient();
446
+ const calendarRef = useRef<FullCalendar>(null);
447
+ const [view, setView] = useState<CalendarView>('timeGridWeek');
448
+ const [calendarTitle, setCalendarTitle] = useState('');
449
+ const [visibleRange, setVisibleRange] = useState<VisibleRange>(defaultVisibleRange);
450
+ const [focusedDate, setFocusedDate] = useState(initialDate ?? toDateInputValue(new Date()));
451
+ const [selectedBranchId, setSelectedBranchId] = useState<string | null>(initialBranchId ?? null);
452
+ const [selectedServiceId, setSelectedServiceId] = useState<string | null>(initialServiceId ?? null);
453
+ const [selectedResourceId, setSelectedResourceId] = useState<string | null>(initialResourceId ?? null);
454
+ const [actionError, setActionError] = useState<string | null>(null);
455
+ const [modalState, setModalState] = useState<SchedulingBookingModalState>({ open: false });
456
+ const [blockedModalState, setBlockedModalState] = useState<BlockedRangeModalState>({ open: false });
457
+ const deferredSearch = useDeferredValue(searchQuery);
458
+
459
+ // useMutation ejecuta mutationFn en un tick posterior; sin ref el closure puede ver modalState cerrado o slot null.
460
+ const createBookingContextRef = useRef({
461
+ branchId: selectedBranchId,
462
+ serviceId: selectedServiceId,
463
+ modal: modalState,
464
+ });
465
+ createBookingContextRef.current = {
466
+ branchId: selectedBranchId,
467
+ serviceId: selectedServiceId,
468
+ modal: modalState,
469
+ };
470
+
471
+ const copy = { ...schedulingCalendarCopyPresets[resolveSchedulingCopyLocale(locale)], ...copyOverrides };
472
+
473
+ const branchesQuery = useQuery<Branch[]>({
474
+ queryKey: schedulingKeys.branches,
475
+ queryFn: () => client.listBranches(),
476
+ staleTime: 60_000,
477
+ });
478
+
479
+ const servicesQuery = useQuery<Service[]>({
480
+ queryKey: schedulingKeys.services,
481
+ queryFn: () => client.listServices(),
482
+ staleTime: 60_000,
483
+ });
484
+
485
+ const resourcesQuery = useQuery<Resource[]>({
486
+ queryKey: schedulingKeys.resources(selectedBranchId),
487
+ queryFn: () => client.listResources(selectedBranchId),
488
+ enabled: Boolean(selectedBranchId),
489
+ staleTime: 60_000,
490
+ });
491
+
492
+ const availabilityRulesQuery = useQuery<AvailabilityRule[]>({
493
+ queryKey: schedulingKeys.availabilityRules(selectedBranchId, selectedResourceId),
494
+ queryFn: () => client.listAvailabilityRules(selectedBranchId, selectedResourceId),
495
+ enabled: Boolean(selectedBranchId),
496
+ staleTime: 60_000,
497
+ });
498
+
499
+ const dashboardQuery = useQuery<DashboardStats>({
500
+ queryKey: schedulingKeys.dashboard(selectedBranchId, focusedDate),
501
+ queryFn: () => client.getDashboard(selectedBranchId, focusedDate),
502
+ enabled: Boolean(selectedBranchId),
503
+ staleTime: 20_000,
504
+ });
505
+
506
+ const slotsQuery = useQuery<TimeSlot[]>({
507
+ queryKey: schedulingKeys.slots(selectedBranchId, selectedServiceId, selectedResourceId, focusedDate),
508
+ queryFn: () =>
509
+ client.listSlots({
510
+ branchId: selectedBranchId ?? '',
511
+ serviceId: selectedServiceId ?? '',
512
+ resourceId: selectedResourceId,
513
+ date: focusedDate,
514
+ }),
515
+ enabled: Boolean(selectedBranchId && selectedServiceId),
516
+ staleTime: 10_000,
517
+ });
518
+
519
+ const createEditorDate = modalState.open && modalState.mode === 'create' ? modalState.editor.date : focusedDate;
520
+
521
+ const createSlotsQuery = useQuery<TimeSlot[]>({
522
+ queryKey: schedulingKeys.slots(selectedBranchId, selectedServiceId, null, createEditorDate),
523
+ queryFn: () =>
524
+ client.listSlots({
525
+ branchId: selectedBranchId ?? '',
526
+ serviceId: selectedServiceId ?? '',
527
+ resourceId: null,
528
+ date: createEditorDate,
529
+ }),
530
+ enabled: Boolean(selectedBranchId && selectedServiceId && modalState.open && modalState.mode === 'create'),
531
+ staleTime: 10_000,
532
+ });
533
+
534
+ const rangeDates = useMemo(() => buildVisibleDates(visibleRange), [visibleRange]);
535
+
536
+ const visibleSlotsQuery = useQuery<TimeSlot[]>({
537
+ queryKey: schedulingKeys.visibleSlotsRange(
538
+ selectedBranchId,
539
+ selectedServiceId,
540
+ selectedResourceId,
541
+ rangeDates[0] ?? focusedDate,
542
+ rangeDates[rangeDates.length - 1] ?? focusedDate,
543
+ ),
544
+ queryFn: async () => {
545
+ if (!selectedBranchId || !selectedServiceId || rangeDates.length === 0) {
546
+ return [];
547
+ }
548
+ const batches = await Promise.all(
549
+ rangeDates.map((date) =>
550
+ client.listSlots({
551
+ branchId: selectedBranchId,
552
+ serviceId: selectedServiceId,
553
+ resourceId: selectedResourceId,
554
+ date,
555
+ }),
556
+ ),
557
+ );
558
+ const unique = new Map<string, TimeSlot>();
559
+ for (const slot of batches.flat()) {
560
+ unique.set(buildSlotIdentity(slot.resource_id, slot.start_at, slot.end_at), slot);
561
+ }
562
+ return Array.from(unique.values()).sort((left, right) => left.start_at.localeCompare(right.start_at));
563
+ },
564
+ enabled: Boolean(selectedBranchId && selectedServiceId && rangeDates.length > 0),
565
+ staleTime: 10_000,
566
+ });
567
+
568
+ const bookingsQuery = useQuery<Booking[]>({
569
+ queryKey: schedulingKeys.bookingsRange(
570
+ selectedBranchId,
571
+ rangeDates[0] ?? focusedDate,
572
+ rangeDates[rangeDates.length - 1] ?? focusedDate,
573
+ ),
574
+ queryFn: async () => {
575
+ if (!selectedBranchId || rangeDates.length === 0) {
576
+ return [];
577
+ }
578
+ const batches = await Promise.all(
579
+ rangeDates.map((date) =>
580
+ client.listBookings({
581
+ branchId: selectedBranchId,
582
+ date,
583
+ limit: 200,
584
+ }),
585
+ ),
586
+ );
587
+ return uniqueBookings(batches.flat());
588
+ },
589
+ enabled: Boolean(selectedBranchId && rangeDates.length > 0),
590
+ staleTime: 10_000,
591
+ });
592
+
593
+ const blockedRangesQuery = useQuery<BlockedRange[]>({
594
+ queryKey: schedulingKeys.blockedRangesRange(
595
+ selectedBranchId,
596
+ rangeDates[0] ?? focusedDate,
597
+ rangeDates[rangeDates.length - 1] ?? focusedDate,
598
+ ),
599
+ queryFn: async () => {
600
+ if (!selectedBranchId || rangeDates.length === 0) {
601
+ return [];
602
+ }
603
+ const batches = await Promise.all(
604
+ rangeDates.map((date) =>
605
+ client.listBlockedRanges({
606
+ branchId: selectedBranchId,
607
+ date,
608
+ }),
609
+ ),
610
+ );
611
+ const seen = new Map<string, BlockedRange>();
612
+ for (const item of batches.flat()) {
613
+ seen.set(item.id, item);
614
+ }
615
+ return Array.from(seen.values()).sort((left, right) => left.start_at.localeCompare(right.start_at));
616
+ },
617
+ enabled: Boolean(selectedBranchId && rangeDates.length > 0),
618
+ staleTime: 10_000,
619
+ });
620
+
621
+ const branches = branchesQuery.data ?? [];
622
+ const services = servicesQuery.data ?? [];
623
+ const resources = resourcesQuery.data ?? [];
624
+ const availabilityRules = availabilityRulesQuery.data ?? [];
625
+
626
+ const scheduleServices = useMemo(
627
+ () =>
628
+ services.filter(
629
+ (service) =>
630
+ service.active && (service.fulfillment_mode === 'schedule' || service.fulfillment_mode === 'hybrid'),
631
+ ),
632
+ [services],
633
+ );
634
+
635
+ const selectedBranch = branches.find((branch) => branch.id === selectedBranchId) ?? null;
636
+
637
+ const filteredResources = useMemo(() => {
638
+ const branchResources = resources.filter((resource) => resource.active);
639
+ const service = scheduleServices.find((candidate) => candidate.id === selectedServiceId);
640
+ if (!service || !service.resource_ids?.length) {
641
+ return branchResources;
642
+ }
643
+ const allowed = new Set(service.resource_ids);
644
+ return branchResources.filter((resource) => allowed.has(resource.id));
645
+ }, [resources, scheduleServices, selectedServiceId]);
646
+
647
+ const selectedService = scheduleServices.find((service) => service.id === selectedServiceId) ?? null;
648
+ const selectedResource = filteredResources.find((resource) => resource.id === selectedResourceId) ?? null;
649
+
650
+ const visibleSlots = visibleSlotsQuery.data ?? [];
651
+ const visibleSlotsByDate = useMemo(() => {
652
+ const grouped = new Map<string, TimeSlot[]>();
653
+ for (const slot of visibleSlots) {
654
+ const day = toDateInputValueFromIso(slot.start_at);
655
+ const items = grouped.get(day);
656
+ if (items) {
657
+ items.push(slot);
658
+ } else {
659
+ grouped.set(day, [slot]);
660
+ }
661
+ }
662
+ return grouped;
663
+ }, [visibleSlots]);
664
+
665
+ const businessHours = useMemo(
666
+ () => buildSchedulingBusinessHours(availabilityRules, selectedResourceId),
667
+ [availabilityRules, selectedResourceId],
668
+ );
669
+
670
+ useEffect(() => {
671
+ if (selectedBranchId) {
672
+ return;
673
+ }
674
+ const preferred = branches.find((branch) => branch.active) ?? branches[0];
675
+ if (preferred) {
676
+ setSelectedBranchId(preferred.id);
677
+ }
678
+ }, [branches, selectedBranchId]);
679
+
680
+ useEffect(() => {
681
+ if (selectedServiceId && scheduleServices.some((service) => service.id === selectedServiceId)) {
682
+ return;
683
+ }
684
+ const preferred = scheduleServices[0];
685
+ setSelectedServiceId(preferred?.id ?? null);
686
+ }, [scheduleServices, selectedServiceId]);
687
+
688
+ useEffect(() => {
689
+ if (!selectedResourceId) {
690
+ return;
691
+ }
692
+ if (!filteredResources.some((resource) => resource.id === selectedResourceId)) {
693
+ setSelectedResourceId(null);
694
+ }
695
+ }, [filteredResources, selectedResourceId]);
696
+
697
+ const invalidateSchedule = async () => {
698
+ await Promise.all([
699
+ queryClient.invalidateQueries({ queryKey: schedulingKeys.dashboard(selectedBranchId, focusedDate) }),
700
+ queryClient.invalidateQueries({ queryKey: ['scheduling', 'slots'] }),
701
+ queryClient.invalidateQueries({ queryKey: ['scheduling', 'visible-slots-range'] }),
702
+ queryClient.invalidateQueries({
703
+ queryKey: schedulingKeys.bookingsRange(
704
+ selectedBranchId,
705
+ rangeDates[0] ?? focusedDate,
706
+ rangeDates[rangeDates.length - 1] ?? focusedDate,
707
+ ),
708
+ }),
709
+ queryClient.invalidateQueries({ queryKey: ['scheduling', 'blocked-ranges-range'] }),
710
+ ]);
711
+ };
712
+
713
+ const buildBlockedRangePayload = (draft: BlockedRangeDraft, branchId: string): BlockedRangePayload => {
714
+ const startAt = new Date(`${draft.date}T${draft.startTime}:00`).toISOString();
715
+ const endAt = new Date(`${draft.date}T${draft.endTime}:00`).toISOString();
716
+ return {
717
+ branch_id: branchId,
718
+ resource_id: draft.resourceId || null,
719
+ kind: draft.kind,
720
+ reason: draft.reason.trim(),
721
+ start_at: startAt,
722
+ end_at: endAt,
723
+ all_day: false,
724
+ };
725
+ };
726
+
727
+ const createBlockedRangeMutation = useMutation<BlockedRange, Error, BlockedRangeDraft>({
728
+ mutationFn: async (draft) => {
729
+ if (!selectedBranchId) {
730
+ throw new Error('missing branch');
731
+ }
732
+ return client.createBlockedRange(buildBlockedRangePayload(draft, selectedBranchId));
733
+ },
734
+ onMutate: () => setActionError(null),
735
+ onSuccess: async () => {
736
+ setBlockedModalState({ open: false });
737
+ await invalidateSchedule();
738
+ },
739
+ onError: (error: Error) => setActionError(error.message),
740
+ });
741
+
742
+ const updateBlockedRangeMutation = useMutation<BlockedRange, Error, { id: string; draft: BlockedRangeDraft }>({
743
+ mutationFn: async ({ id, draft }) => {
744
+ if (!selectedBranchId) {
745
+ throw new Error('missing branch');
746
+ }
747
+ return client.updateBlockedRange(id, buildBlockedRangePayload(draft, selectedBranchId));
748
+ },
749
+ onMutate: () => setActionError(null),
750
+ onSuccess: async () => {
751
+ setBlockedModalState({ open: false });
752
+ await invalidateSchedule();
753
+ },
754
+ onError: (error: Error) => setActionError(error.message),
755
+ });
756
+
757
+ const deleteBlockedRangeMutation = useMutation<void, Error, string>({
758
+ mutationFn: async (id) => {
759
+ await client.deleteBlockedRange(id);
760
+ },
761
+ onMutate: () => setActionError(null),
762
+ onSuccess: async () => {
763
+ setBlockedModalState({ open: false });
764
+ await invalidateSchedule();
765
+ },
766
+ onError: (error: Error) => setActionError(error.message),
767
+ });
768
+
769
+ const createBookingMutation = useMutation<Booking, Error, SchedulingBookingDraft>({
770
+ mutationFn: async (draft: SchedulingBookingDraft) => {
771
+ const { branchId, serviceId, modal } = createBookingContextRef.current;
772
+ if (!branchId || !serviceId || !modal.open || modal.mode !== 'create' || !modal.slot) {
773
+ throw new Error('missing scheduling context');
774
+ }
775
+ const slot = modal.slot;
776
+ const recurrenceMode = draft.recurrence.mode;
777
+ const interval = Number.parseInt(draft.recurrence.interval, 10);
778
+ const count = Number.parseInt(draft.recurrence.count, 10);
779
+ const recurrence =
780
+ recurrenceMode === 'none'
781
+ ? undefined
782
+ : {
783
+ freq: (recurrenceMode === 'custom'
784
+ ? draft.recurrence.frequency
785
+ : recurrenceMode) as 'daily' | 'weekly' | 'monthly',
786
+ interval: Number.isFinite(interval) && interval > 0 ? interval : 1,
787
+ count: Number.isFinite(count) && count > 0 ? count : 8,
788
+ by_weekday:
789
+ (recurrenceMode === 'custom' ? draft.recurrence.frequency : recurrenceMode) === 'weekly'
790
+ ? draft.recurrence.byWeekday.length
791
+ ? draft.recurrence.byWeekday
792
+ : [new Date(slot.start_at).getUTCDay()]
793
+ : undefined,
794
+ };
795
+ return client.createBooking({
796
+ branch_id: branchId,
797
+ service_id: serviceId,
798
+ resource_id: slot.resource_id,
799
+ customer_name: draft.customerName.trim(),
800
+ customer_phone: draft.customerPhone.trim(),
801
+ customer_email: draft.customerEmail.trim() || undefined,
802
+ start_at: schedulingInstantRFC3339(slot.start_at),
803
+ ...(recurrence ? {} : { end_at: schedulingInstantRFC3339(slot.end_at) }),
804
+ notes: draft.notes.trim() || undefined,
805
+ metadata: draft.title.trim() ? { title: draft.title.trim() } : undefined,
806
+ recurrence,
807
+ source: 'admin',
808
+ });
809
+ },
810
+ onMutate: () => setActionError(null),
811
+ onSuccess: async () => {
812
+ setModalState({ open: false });
813
+ await invalidateSchedule();
814
+ },
815
+ onError: (error: Error) => setActionError(error.message),
816
+ });
817
+
818
+ const bookingActionMutation = useMutation<Booking, Error, { action: SchedulingBookingAction; booking: Booking }>({
819
+ mutationFn: async ({ action, booking }) => {
820
+ switch (action) {
821
+ case 'confirm':
822
+ return client.confirmBooking(booking.id);
823
+ case 'cancel':
824
+ return client.cancelBooking(booking.id, 'Cancelled from scheduling calendar');
825
+ case 'check_in':
826
+ return client.checkInBooking(booking.id);
827
+ case 'start_service':
828
+ return client.startService(booking.id);
829
+ case 'complete':
830
+ return client.completeBooking(booking.id);
831
+ case 'no_show':
832
+ return client.markBookingNoShow(booking.id, 'Marked as no-show from scheduling calendar');
833
+ default:
834
+ throw new Error(`unsupported action: ${String(action)}`);
835
+ }
836
+ },
837
+ onMutate: () => setActionError(null),
838
+ onSuccess: async (booking: Booking) => {
839
+ setModalState(buildSchedulingDetailsModalState(booking, scheduleServices, filteredResources));
840
+ await invalidateSchedule();
841
+ },
842
+ onError: (error: Error) => setActionError(error.message),
843
+ });
844
+
845
+ const filteredBookings = useMemo(() => {
846
+ const source = bookingsQuery.data ?? [];
847
+ return source.filter((booking) => {
848
+ if (selectedServiceId && booking.service_id !== selectedServiceId) {
849
+ return false;
850
+ }
851
+ if (selectedResourceId && booking.resource_id !== selectedResourceId) {
852
+ return false;
853
+ }
854
+ const serviceName = scheduleServices.find((service) => service.id === booking.service_id)?.name;
855
+ const resourceName = filteredResources.find((resource) => resource.id === booking.resource_id)?.name;
856
+ return matchesSearch(deferredSearch, [
857
+ resolveBookingDisplayTitle(booking),
858
+ booking.customer_name,
859
+ booking.customer_phone,
860
+ booking.customer_email,
861
+ booking.reference,
862
+ booking.notes,
863
+ serviceName,
864
+ resourceName,
865
+ copy.statuses[booking.status],
866
+ ]);
867
+ });
868
+ }, [bookingsQuery.data, selectedServiceId, selectedResourceId, scheduleServices, filteredResources, deferredSearch, copy.statuses]);
869
+
870
+ const slotItems = useMemo(() => {
871
+ const source = slotsQuery.data ?? [];
872
+ return source.filter((slot) =>
873
+ matchesSearch(deferredSearch, [
874
+ slot.resource_name,
875
+ formatSchedulingClock(slot.start_at, locale),
876
+ formatSchedulingClock(slot.end_at, locale),
877
+ selectedService?.name,
878
+ ]),
879
+ );
880
+ }, [slotsQuery.data, deferredSearch, selectedService?.name, locale]);
881
+
882
+ const calendarEvents = useMemo(
883
+ () => buildSchedulingCalendarEvents(filteredBookings, scheduleServices, filteredResources, eventColor),
884
+ [filteredBookings, scheduleServices, filteredResources],
885
+ );
886
+
887
+ const blockedRanges = blockedRangesQuery.data ?? [];
888
+
889
+ const blockedRangeEventInputs = useMemo<EventInput[]>(
890
+ () =>
891
+ blockedRanges.map((range) => ({
892
+ id: `blocked:${range.id}`,
893
+ title: range.reason || copy.blockedRangeKindOptions[range.kind] || copy.blockedRangeFallbackTitle,
894
+ start: range.start_at,
895
+ end: range.end_at,
896
+ backgroundColor: '#9ca3af',
897
+ borderColor: '#6b7280',
898
+ textColor: '#111827',
899
+ classNames: ['modules-scheduling__calendar-event--blocked'],
900
+ editable: true,
901
+ durationEditable: true,
902
+ extendedProps: {
903
+ blockedRange: range,
904
+ },
905
+ })),
906
+ [blockedRanges, copy.blockedRangeKindOptions, copy.blockedRangeFallbackTitle],
907
+ );
908
+
909
+ const fullCalendarEventInputs = useMemo(
910
+ () => [...buildFullCalendarEventInputs(calendarEvents), ...blockedRangeEventInputs],
911
+ [calendarEvents, blockedRangeEventInputs],
912
+ );
913
+
914
+ const resolveDaySlots = (value: Date): TimeSlot[] => visibleSlotsByDate.get(toDateInputValue(value)) ?? [];
915
+
916
+ const handleSelectAllow = (info: CalendarSelectAllowArg) => {
917
+ // Google-like: allow any non-empty range when a service is selected.
918
+ // Backend revalidates against availability rules, blocked ranges and overlaps
919
+ // when the booking is persisted; the frontend only requires a service.
920
+ if (!selectedService || !info.start || !info.end) {
921
+ return false;
922
+ }
923
+ return info.end.getTime() > info.start.getTime();
924
+ };
925
+
926
+ const handleEventAllow = (dropInfo: CalendarEventAllowArg, draggedEvent: CalendarDraggedEventArg) => {
927
+ if (!draggedEvent) {
928
+ return false;
929
+ }
930
+ // Both blocks and bookings are draggable/resizable freely from the owner console.
931
+ // Backend revalidates against availability rules, blocked ranges, and booking
932
+ // overlaps when persisting; the frontend only requires a non-empty range.
933
+ return Boolean(dropInfo.start && dropInfo.end);
934
+ };
935
+
936
+ const renderEventContent = (info: EventContentArg) => {
937
+ const calendarEvent = info.event.extendedProps.calendarEvent as CalendarEvent | undefined;
938
+ if (!calendarEvent) {
939
+ return <span>{info.event.title}</span>;
940
+ }
941
+ const statusLabel = copy.statuses[calendarEvent.status];
942
+ const listTimeRange = `${formatSchedulingClock(calendarEvent.start_at, locale)} - ${formatSchedulingClock(calendarEvent.end_at, locale)}`;
943
+ const compactTimeRange = `${formatSchedulingCompactClock(calendarEvent.start_at, locale)}-${formatSchedulingCompactClock(calendarEvent.end_at, locale)}`;
944
+ const listView = info.view.type.startsWith('list');
945
+ const monthView = info.view.type === 'dayGridMonth';
946
+ const contextLabel = selectedResourceId
947
+ ? calendarEvent.serviceName ?? calendarEvent.resourceName
948
+ : calendarEvent.resourceName ?? calendarEvent.serviceName;
949
+ const secondaryLine = listView
950
+ ? [listTimeRange, statusLabel, calendarEvent.resourceName, calendarEvent.serviceName].filter(Boolean).join(' · ')
951
+ : [compactTimeRange, monthView ? undefined : contextLabel].filter(Boolean).join(' · ');
952
+
953
+ return (
954
+ <div className={`modules-scheduling__calendar-event ${listView ? 'modules-scheduling__calendar-event--list' : ''}`}>
955
+ <div className="modules-scheduling__calendar-event-top">
956
+ <strong>{calendarEvent.title}</strong>
957
+ {listView ? <span className={statusClassName(calendarEvent.status)}>{statusLabel}</span> : null}
958
+ </div>
959
+ {secondaryLine ? (
960
+ <div className="modules-scheduling__calendar-event-meta">
961
+ <span>{secondaryLine}</span>
962
+ </div>
963
+ ) : null}
964
+ </div>
965
+ );
966
+ };
967
+
968
+ const openCreateBookingModal = (slot: TimeSlot, resourceName?: string) => {
969
+ setModalState(
970
+ buildSchedulingCreateModalStateFromSlot(slot, selectedService, slotsQuery.data ?? [], filteredResources, resourceName),
971
+ );
972
+ };
973
+
974
+ const handleCreateEditorChange = (editorPatch: Partial<SchedulingBookingCreateEditor>) => {
975
+ setModalState((current) => {
976
+ if (!(current.open && current.mode === 'create')) {
977
+ return current;
978
+ }
979
+ return {
980
+ ...current,
981
+ editor: {
982
+ ...current.editor,
983
+ ...editorPatch,
984
+ },
985
+ };
986
+ });
987
+ };
988
+
989
+ useEffect(() => {
990
+ if (!(modalState.open && modalState.mode === 'create')) {
991
+ return;
992
+ }
993
+ const slotOptions = createSlotsQuery.data ?? emptySlotOptions;
994
+
995
+ setModalState((current) => {
996
+ if (!(current.open && current.mode === 'create')) {
997
+ return current;
998
+ }
999
+ const editor = current.editor;
1000
+ const resourceMatchInner = filteredResources.find((resource) => resource.id === editor.resourceId);
1001
+ const matchedOrSynthetic =
1002
+ slotOptions.find((slot) => matchesCreateEditorSlot(editor, slot)) ??
1003
+ (current.service
1004
+ ? buildSyntheticTimeSlotFromEditor(editor, current.service, resourceMatchInner, selectedBranch)
1005
+ : null);
1006
+ // Mientras llegan slots/recursos, no pisar el slot que ya armó el calendario (date click / drag).
1007
+ const slotToSet =
1008
+ matchedOrSynthetic ?? (createSlotsQuery.isFetching && current.slot ? current.slot : null);
1009
+
1010
+ const nextResourceName =
1011
+ resourceMatchInner?.name ??
1012
+ slotOptions.find((slot) => slot.resource_id === editor.resourceId)?.resource_name ??
1013
+ current.resourceName;
1014
+
1015
+ const nextValidationMessage =
1016
+ slotToSet || createSlotsQuery.isFetching ? null : copy.unavailableSlotMessage;
1017
+ const sameSlot =
1018
+ current.slot?.resource_id === slotToSet?.resource_id &&
1019
+ current.slot?.start_at === slotToSet?.start_at &&
1020
+ current.slot?.end_at === slotToSet?.end_at;
1021
+ if (
1022
+ sameSlot &&
1023
+ current.slotOptions === slotOptions &&
1024
+ current.resourceName === nextResourceName &&
1025
+ current.validationMessage === nextValidationMessage &&
1026
+ current.resourceOptions.length === filteredResources.length
1027
+ ) {
1028
+ return current;
1029
+ }
1030
+ return {
1031
+ ...current,
1032
+ slot: slotToSet,
1033
+ slotOptions,
1034
+ resourceOptions: filteredResources.map((resource) => ({
1035
+ id: resource.id,
1036
+ name: resource.name,
1037
+ timezone: resource.timezone,
1038
+ })),
1039
+ resourceName: nextResourceName,
1040
+ validationMessage: nextValidationMessage,
1041
+ };
1042
+ });
1043
+ }, [
1044
+ modalState,
1045
+ createSlotsQuery.data,
1046
+ createSlotsQuery.isFetching,
1047
+ copy.unavailableSlotMessage,
1048
+ filteredResources,
1049
+ selectedBranch,
1050
+ ]);
1051
+
1052
+ const openCreateFromStart = (start: Date, startAt: string, end: Date | null = null, endAt: string | null = null) => {
1053
+ const nextState = buildSchedulingCreateModalStateFromStart({
1054
+ start,
1055
+ startAt,
1056
+ end,
1057
+ endAt,
1058
+ slots: resolveDaySlots(start),
1059
+ selectedService,
1060
+ selectedResource,
1061
+ filteredResources,
1062
+ selectedBranch,
1063
+ });
1064
+ if (nextState) {
1065
+ setModalState(nextState);
1066
+ }
1067
+ };
1068
+
1069
+ const openBlockedRangeEditModal = (range: BlockedRange) => {
1070
+ setBlockedModalState({
1071
+ open: true,
1072
+ mode: 'edit',
1073
+ id: range.id,
1074
+ branchId: range.branch_id,
1075
+ resourceOptions: filteredResources.map((resource) => ({ id: resource.id, name: resource.name })),
1076
+ initial: blockedRangeDraftFromRange(range),
1077
+ });
1078
+ };
1079
+
1080
+ const openBlockedRangeCreateModal = () => {
1081
+ if (!selectedBranchId) {
1082
+ return;
1083
+ }
1084
+ setBlockedModalState({
1085
+ open: true,
1086
+ mode: 'create',
1087
+ branchId: selectedBranchId,
1088
+ resourceId: selectedResourceId,
1089
+ resourceOptions: filteredResources.map((resource) => ({ id: resource.id, name: resource.name })),
1090
+ initial: { ...emptyBlockedRangeDraft(focusedDate), resourceId: selectedResourceId ?? '' },
1091
+ });
1092
+ };
1093
+
1094
+ const handleCalendarEventClick = (info: EventClickArg) => {
1095
+ const blockedRange = info.event.extendedProps.blockedRange as BlockedRange | undefined;
1096
+ if (blockedRange) {
1097
+ openBlockedRangeEditModal(blockedRange);
1098
+ return;
1099
+ }
1100
+ const calendarEvent = info.event.extendedProps.calendarEvent as CalendarEvent | undefined;
1101
+ const sourceBooking = calendarEvent?.sourceBooking;
1102
+ if (!sourceBooking) {
1103
+ return;
1104
+ }
1105
+ setModalState(buildSchedulingDetailsModalState(sourceBooking, scheduleServices, filteredResources));
1106
+ };
1107
+
1108
+ const handleDateClick = (info: CalendarDateClickArg) => {
1109
+ setFocusedDate(info.dateStr.slice(0, 10));
1110
+ openCreateFromStart(info.date, info.date.toISOString());
1111
+ };
1112
+
1113
+ const handleSelect = (info: CalendarDateSelectArg) => {
1114
+ setFocusedDate(info.startStr.slice(0, 10));
1115
+ calendarRef.current?.getApi().unselect();
1116
+ const nextState = buildSchedulingCreateModalStateFromStart({
1117
+ start: info.start,
1118
+ startAt: info.start.toISOString(),
1119
+ end: info.end,
1120
+ endAt: info.end.toISOString(),
1121
+ slots: resolveDaySlots(info.start),
1122
+ selectedService,
1123
+ selectedResource,
1124
+ filteredResources,
1125
+ selectedBranch,
1126
+ });
1127
+ if (nextState) {
1128
+ setModalState(nextState);
1129
+ }
1130
+ };
1131
+
1132
+ const persistBookingReschedule = async (
1133
+ info: CalendarEventDropArg | CalendarEventResizeArg,
1134
+ sourceBooking: Booking,
1135
+ confirmCopy: { title: string; description: string },
1136
+ ) => {
1137
+ const newStart = info.event.start;
1138
+ const newEnd = info.event.end;
1139
+ if (!newStart || !newEnd) {
1140
+ info.revert();
1141
+ return;
1142
+ }
1143
+ const confirmed = await confirmAction({
1144
+ title: confirmCopy.title,
1145
+ description: confirmCopy.description,
1146
+ confirmLabel: copy.rescheduleBooking,
1147
+ cancelLabel: copy.close,
1148
+ });
1149
+ if (!confirmed) {
1150
+ info.revert();
1151
+ return;
1152
+ }
1153
+ try {
1154
+ setActionError(null);
1155
+ await client.rescheduleBooking(info.event.id, {
1156
+ branch_id: selectedBranchId ?? undefined,
1157
+ resource_id: sourceBooking.resource_id,
1158
+ start_at: newStart.toISOString(),
1159
+ end_at: newEnd.toISOString(),
1160
+ });
1161
+ await invalidateSchedule();
1162
+ } catch (error) {
1163
+ info.revert();
1164
+ setActionError(error instanceof Error ? error.message : String(error));
1165
+ }
1166
+ };
1167
+
1168
+ const persistBlockedRangeReschedule = async (
1169
+ info: CalendarEventDropArg | CalendarEventResizeArg,
1170
+ blockedRange: BlockedRange,
1171
+ ) => {
1172
+ const newStart = info.event.start;
1173
+ const newEnd = info.event.end;
1174
+ if (!newStart || !newEnd) {
1175
+ info.revert();
1176
+ return;
1177
+ }
1178
+ try {
1179
+ setActionError(null);
1180
+ await client.updateBlockedRange(blockedRange.id, {
1181
+ branch_id: blockedRange.branch_id,
1182
+ resource_id: blockedRange.resource_id ?? null,
1183
+ kind: blockedRange.kind,
1184
+ reason: blockedRange.reason,
1185
+ start_at: newStart.toISOString(),
1186
+ end_at: newEnd.toISOString(),
1187
+ all_day: blockedRange.all_day,
1188
+ });
1189
+ await invalidateSchedule();
1190
+ } catch (error) {
1191
+ info.revert();
1192
+ setActionError(error instanceof Error ? error.message : String(error));
1193
+ }
1194
+ };
1195
+
1196
+ const handleCalendarEventDrop = async (info: CalendarEventDropArg) => {
1197
+ const blockedRange = info.event.extendedProps.blockedRange as BlockedRange | undefined;
1198
+ if (blockedRange) {
1199
+ await persistBlockedRangeReschedule(info, blockedRange);
1200
+ return;
1201
+ }
1202
+ const calendarEvent = info.event.extendedProps.calendarEvent as CalendarEvent | undefined;
1203
+ const sourceBooking = calendarEvent?.sourceBooking;
1204
+ if (!sourceBooking) {
1205
+ info.revert();
1206
+ return;
1207
+ }
1208
+ await persistBookingReschedule(info, sourceBooking, {
1209
+ title: copy.dragRescheduleTitle,
1210
+ description: copy.dragRescheduleDescription,
1211
+ });
1212
+ };
1213
+
1214
+ const handleEventResize = async (info: CalendarEventResizeArg) => {
1215
+ const blockedRange = info.event.extendedProps.blockedRange as BlockedRange | undefined;
1216
+ if (blockedRange) {
1217
+ await persistBlockedRangeReschedule(info, blockedRange);
1218
+ return;
1219
+ }
1220
+ const calendarEvent = info.event.extendedProps.calendarEvent as CalendarEvent | undefined;
1221
+ const sourceBooking = calendarEvent?.sourceBooking;
1222
+ if (!sourceBooking) {
1223
+ info.revert();
1224
+ return;
1225
+ }
1226
+ await persistBookingReschedule(info, sourceBooking, {
1227
+ title: copy.resizeBookingTitle,
1228
+ description: copy.resizeBookingDescription,
1229
+ });
1230
+ };
1231
+
1232
+ const handleModalAction = async (action: SchedulingBookingAction, booking: Booking) => {
1233
+ if (action === 'cancel' || action === 'no_show') {
1234
+ const confirmed = await confirmAction({
1235
+ title: copy.destructiveTitle,
1236
+ description: action === 'cancel' ? copy.cancelActionDescription : copy.noShowActionDescription,
1237
+ confirmLabel: action === 'cancel' ? copy.cancelBooking : copy.noShowBooking,
1238
+ cancelLabel: copy.close,
1239
+ tone: 'danger',
1240
+ });
1241
+ if (!confirmed) {
1242
+ return;
1243
+ }
1244
+ }
1245
+ await bookingActionMutation.mutateAsync({ action, booking });
1246
+ };
1247
+
1248
+ const updateCalendarTitle = () => {
1249
+ const api = calendarRef.current?.getApi();
1250
+ if (!api) {
1251
+ return;
1252
+ }
1253
+ setCalendarTitle(api.view.title);
1254
+ };
1255
+
1256
+ const scrollCalendarToRelevantTime = () => {
1257
+ const api = calendarRef.current?.getApi();
1258
+ if (!api || !api.view.type.startsWith('timeGrid')) {
1259
+ return;
1260
+ }
1261
+ api.scrollToTime(
1262
+ resolveInitialTimeGridScrollTime({
1263
+ events: fullCalendarEventInputs,
1264
+ rangeStart: api.view.activeStart,
1265
+ rangeEnd: api.view.activeEnd,
1266
+ fallbackHour: 8,
1267
+ }),
1268
+ );
1269
+ };
1270
+
1271
+ const timeGridViewport = useMemo(
1272
+ () =>
1273
+ resolveInitialTimeGridViewport({
1274
+ events: fullCalendarEventInputs,
1275
+ rangeStart: visibleRange.start,
1276
+ rangeEnd: visibleRange.end,
1277
+ fallbackHour: 8,
1278
+ }),
1279
+ [fullCalendarEventInputs, visibleRange],
1280
+ );
1281
+
1282
+ const fullCalendarLocale = resolveSchedulingCopyLocale(locale) === 'es' ? 'es' : 'en';
1283
+
1284
+ const fullCalendarOptions = useMemo(() => {
1285
+ if (resolveSchedulingCopyLocale(locale) !== 'es') {
1286
+ return undefined;
1287
+ }
1288
+ return {
1289
+ titleFormat: {
1290
+ day: '2-digit' as const,
1291
+ month: '2-digit' as const,
1292
+ year: 'numeric' as const,
1293
+ },
1294
+ };
1295
+ }, [locale]);
1296
+
1297
+ const loading = branchesQuery.isLoading || servicesQuery.isLoading;
1298
+
1299
+ if (loading) {
1300
+ return (
1301
+ <section className={`modules-scheduling ${className}`.trim()}>
1302
+ <div className="card modules-scheduling__empty">
1303
+ <div className="spinner" />
1304
+ <p>{copy.loading}</p>
1305
+ </div>
1306
+ </section>
1307
+ );
1308
+ }
1309
+
1310
+ if (!branches.length || !scheduleServices.length) {
1311
+ return (
1312
+ <section className={`modules-scheduling ${className}`.trim()}>
1313
+ <div className="card empty-state">
1314
+ <h3 className="text-section-title">{copy.unavailableTitle}</h3>
1315
+ <p>{copy.unavailableDescription}</p>
1316
+ </div>
1317
+ </section>
1318
+ );
1319
+ }
1320
+
1321
+ return (
1322
+ <section className={`modules-scheduling ${className}`.trim()}>
1323
+ {actionError ? <div className="alert alert-error">{actionError}</div> : null}
1324
+
1325
+ <div className="modules-scheduling__layout">
1326
+ <div className="card modules-scheduling__calendar-card">
1327
+ {(copy.timelineTitle || copy.timelineDescription) && (
1328
+ <div className="card-header">
1329
+ <div>
1330
+ {copy.timelineTitle && <h2>{copy.timelineTitle}</h2>}
1331
+ {copy.timelineDescription && <p className="text-secondary">{copy.timelineDescription}</p>}
1332
+ </div>
1333
+ </div>
1334
+ )}
1335
+ <CalendarSurface
1336
+ calendarRef={calendarRef}
1337
+ view={view}
1338
+ title={calendarTitle}
1339
+ locale={fullCalendarLocale}
1340
+ calendarOptions={fullCalendarOptions ?? {}}
1341
+ loaded={!bookingsQuery.isLoading}
1342
+ onToday={() => {
1343
+ const api = calendarRef.current?.getApi();
1344
+ api?.today();
1345
+ updateCalendarTitle();
1346
+ scrollCalendarToRelevantTime();
1347
+ setFocusedDate(toDateInputValue(new Date()));
1348
+ }}
1349
+ onPrev={() => {
1350
+ const api = calendarRef.current?.getApi();
1351
+ api?.prev();
1352
+ updateCalendarTitle();
1353
+ }}
1354
+ onNext={() => {
1355
+ const api = calendarRef.current?.getApi();
1356
+ api?.next();
1357
+ updateCalendarTitle();
1358
+ }}
1359
+ onViewChange={(nextView: CalendarView) => {
1360
+ const api = calendarRef.current?.getApi();
1361
+ api?.changeView(nextView);
1362
+ setView(nextView);
1363
+ updateCalendarTitle();
1364
+ window.requestAnimationFrame(() => {
1365
+ scrollCalendarToRelevantTime();
1366
+ });
1367
+ }}
1368
+ scrollTime={timeGridViewport.scrollTime}
1369
+ scrollTimeReset={false}
1370
+ slotMinTime={timeGridViewport.slotMinTime}
1371
+ events={fullCalendarEventInputs}
1372
+ businessHours={businessHours}
1373
+ editable
1374
+ selectable={Boolean(selectedService)}
1375
+ eventDurationEditable
1376
+ selectAllow={handleSelectAllow}
1377
+ eventAllow={handleEventAllow}
1378
+ eventConstraint={businessHours.length ? 'businessHours' : undefined}
1379
+ eventContent={renderEventContent}
1380
+ dayMaxEvents
1381
+ weekends
1382
+ onEventClick={handleCalendarEventClick}
1383
+ onEventDrop={handleCalendarEventDrop}
1384
+ onEventResize={handleEventResize}
1385
+ onDateClick={handleDateClick}
1386
+ onSelect={handleSelect}
1387
+ onDatesSet={(info: { start: Date; end: Date }) => {
1388
+ setVisibleRange({ start: info.start, end: info.end });
1389
+ updateCalendarTitle();
1390
+ scrollCalendarToRelevantTime();
1391
+ }}
1392
+ />
1393
+ </div>
1394
+
1395
+ <aside className="card modules-scheduling__slots-card">
1396
+ <div className="card-header">
1397
+ <div>
1398
+ <h2>{copy.slotsTitle}</h2>
1399
+ <p className="text-secondary">{copy.slotsDescription}</p>
1400
+ </div>
1401
+ <button
1402
+ type="button"
1403
+ className="btn-secondary btn-sm"
1404
+ onClick={openBlockedRangeCreateModal}
1405
+ disabled={!selectedBranchId}
1406
+ >
1407
+ {copy.blockedRangeAction}
1408
+ </button>
1409
+ </div>
1410
+ <div className="modules-scheduling__slots-date">
1411
+ <span>{formatSchedulingDateOnly(focusedDate, locale)}</span>
1412
+ {selectedBranch ? <span>{selectedBranch.name}</span> : null}
1413
+ </div>
1414
+ {slotsQuery.isLoading ? (
1415
+ <div className="modules-scheduling__empty">{copy.slotsLoading}</div>
1416
+ ) : slotItems.length === 0 ? (
1417
+ <div className="modules-scheduling__empty">{copy.slotsEmpty}</div>
1418
+ ) : (
1419
+ <ul className="modules-scheduling__slot-list">
1420
+ {slotItems.map((slot) => (
1421
+ <li key={`${slot.resource_id}:${slot.start_at}`} className="modules-scheduling__slot-card">
1422
+ <div className="modules-scheduling__slot-main">
1423
+ <strong>
1424
+ {formatSchedulingClock(slot.start_at, locale)} - {formatSchedulingClock(slot.end_at, locale)}
1425
+ </strong>
1426
+ <span>{slot.resource_name}</span>
1427
+ </div>
1428
+ <div className="modules-scheduling__slot-meta">
1429
+ <span>
1430
+ {copy.slotRemainingLabel}: {slot.remaining}
1431
+ </span>
1432
+ <button
1433
+ type="button"
1434
+ className="btn-primary btn-sm"
1435
+ onClick={() => openCreateBookingModal(slot, slot.resource_name)}
1436
+ >
1437
+ {copy.openBooking}
1438
+ </button>
1439
+ </div>
1440
+ </li>
1441
+ ))}
1442
+ </ul>
1443
+ )}
1444
+
1445
+ <div className="modules-scheduling__results">
1446
+ {filteredBookings.length === 0 ? (
1447
+ <span className="text-muted">{copy.searchPlaceholder}</span>
1448
+ ) : (
1449
+ filteredBookings.slice(0, 6).map((booking) => {
1450
+ const serviceName = scheduleServices.find((service) => service.id === booking.service_id)?.name;
1451
+ const resourceName = filteredResources.find((resource) => resource.id === booking.resource_id)?.name;
1452
+ return (
1453
+ <button
1454
+ key={booking.id}
1455
+ type="button"
1456
+ className="modules-scheduling__booking-row"
1457
+ onClick={() => setModalState(buildSchedulingDetailsModalState(booking, scheduleServices, filteredResources))}
1458
+ >
1459
+ <div className="modules-scheduling__booking-row-main">
1460
+ <strong>{resolveBookingDisplayTitle(booking)}</strong>
1461
+ <span>{serviceName ?? booking.service_id}</span>
1462
+ </div>
1463
+ <div className="modules-scheduling__booking-row-meta">
1464
+ <span>{formatSchedulingClock(booking.start_at, locale)}</span>
1465
+ <span className={statusClassName(booking.status)}>{copy.statuses[booking.status]}</span>
1466
+ <span>{resourceName ?? booking.resource_id}</span>
1467
+ </div>
1468
+ </button>
1469
+ );
1470
+ })
1471
+ )}
1472
+ </div>
1473
+ </aside>
1474
+ </div>
1475
+
1476
+ <div className="card">
1477
+ <div className="card-header">
1478
+ <div>
1479
+ <h2>{copy.filtersTitle}</h2>
1480
+ <p className="text-secondary">{copy.filtersDescription}</p>
1481
+ </div>
1482
+ </div>
1483
+ <div className="modules-scheduling__filters">
1484
+ <div className="form-group grow">
1485
+ <label htmlFor="scheduling-branch">{copy.branchLabel}</label>
1486
+ <select id="scheduling-branch" value={selectedBranchId ?? ''} onChange={(event) => setSelectedBranchId(event.target.value || null)}>
1487
+ {branches.map((branch) => (
1488
+ <option key={branch.id} value={branch.id}>
1489
+ {branch.name}
1490
+ </option>
1491
+ ))}
1492
+ </select>
1493
+ </div>
1494
+ <div className="form-group grow">
1495
+ <label htmlFor="scheduling-service">{copy.serviceLabel}</label>
1496
+ <select
1497
+ id="scheduling-service"
1498
+ value={selectedServiceId ?? ''}
1499
+ onChange={(event) => setSelectedServiceId(event.target.value || null)}
1500
+ >
1501
+ {scheduleServices.map((service) => (
1502
+ <option key={service.id} value={service.id}>
1503
+ {service.name}
1504
+ </option>
1505
+ ))}
1506
+ </select>
1507
+ </div>
1508
+ <div className="form-group grow">
1509
+ <label htmlFor="scheduling-resource">{copy.resourceLabel}</label>
1510
+ <select
1511
+ id="scheduling-resource"
1512
+ value={selectedResourceId ?? ''}
1513
+ onChange={(event) => setSelectedResourceId(event.target.value || null)}
1514
+ >
1515
+ <option value="">{copy.anyResource}</option>
1516
+ {filteredResources.map((resource) => (
1517
+ <option key={resource.id} value={resource.id}>
1518
+ {resource.name}
1519
+ </option>
1520
+ ))}
1521
+ </select>
1522
+ </div>
1523
+ <div className="form-group">
1524
+ <label htmlFor="scheduling-date">{copy.focusDateLabel}</label>
1525
+ <input
1526
+ id="scheduling-date"
1527
+ type="date"
1528
+ value={focusedDate}
1529
+ onChange={(event) => {
1530
+ const nextDate = event.target.value;
1531
+ setFocusedDate(nextDate);
1532
+ calendarRef.current?.getApi().gotoDate(nextDate);
1533
+ }}
1534
+ />
1535
+ </div>
1536
+ </div>
1537
+ </div>
1538
+
1539
+ <div className="modules-scheduling__summary stats-grid">
1540
+ <article className="stat-card">
1541
+ <div className="stat-label">{copy.summaryBookings}</div>
1542
+ <div className="stat-value">{dashboardQuery.data?.bookings_today ?? '—'}</div>
1543
+ </article>
1544
+ <article className="stat-card">
1545
+ <div className="stat-label">{copy.summaryConfirmed}</div>
1546
+ <div className="stat-value">{dashboardQuery.data?.confirmed_bookings_today ?? '—'}</div>
1547
+ </article>
1548
+ <article className="stat-card">
1549
+ <div className="stat-label">{copy.summaryQueues}</div>
1550
+ <div className="stat-value">{dashboardQuery.data?.active_queues ?? '—'}</div>
1551
+ </article>
1552
+ <article className="stat-card">
1553
+ <div className="stat-label">{copy.summaryWaiting}</div>
1554
+ <div className="stat-value">{dashboardQuery.data?.waiting_tickets ?? '—'}</div>
1555
+ </article>
1556
+ </div>
1557
+
1558
+ <SchedulingBookingModal
1559
+ state={modalState}
1560
+ copy={copy}
1561
+ locale={locale}
1562
+ saving={createBookingMutation.isPending || bookingActionMutation.isPending}
1563
+ slotLoading={createSlotsQuery.isFetching}
1564
+ onClose={() => setModalState({ open: false })}
1565
+ onEditorChange={handleCreateEditorChange}
1566
+ onCreate={async (draft) => {
1567
+ await createBookingMutation.mutateAsync(draft);
1568
+ }}
1569
+ onAction={async (action, booking) => {
1570
+ await handleModalAction(action, booking);
1571
+ }}
1572
+ />
1573
+
1574
+ <BlockedRangeModal
1575
+ state={blockedModalState}
1576
+ copy={copy}
1577
+ saving={
1578
+ createBlockedRangeMutation.isPending ||
1579
+ updateBlockedRangeMutation.isPending ||
1580
+ deleteBlockedRangeMutation.isPending
1581
+ }
1582
+ onClose={() => setBlockedModalState({ open: false })}
1583
+ onSave={async (draft) => {
1584
+ if (blockedModalState.open && blockedModalState.mode === 'edit') {
1585
+ await updateBlockedRangeMutation.mutateAsync({ id: blockedModalState.id, draft });
1586
+ } else {
1587
+ await createBlockedRangeMutation.mutateAsync(draft);
1588
+ }
1589
+ }}
1590
+ onDelete={async (id) => {
1591
+ await deleteBlockedRangeMutation.mutateAsync(id);
1592
+ }}
1593
+ />
1594
+ </section>
1595
+ );
1596
+ }