@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,830 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
+ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+ import { useEffect } from 'react';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import type { SchedulingClient } from './client';
8
+ import { SchedulingCalendar } from './SchedulingCalendarBoard';
9
+ import type {
10
+ BlockedRange,
11
+ Booking,
12
+ Branch,
13
+ CalendarEvent,
14
+ DashboardStats,
15
+ Resource,
16
+ Service,
17
+ TimeSlot,
18
+ } from './types';
19
+
20
+ const confirmActionMock = vi.hoisted(() => vi.fn(async () => true));
21
+ const modalMocks = vi.hoisted(() => ({
22
+ props: [] as Array<{
23
+ state: {
24
+ open: boolean;
25
+ mode?: 'create' | 'details';
26
+ booking?: Booking;
27
+ slot?: TimeSlot | null;
28
+ editor?: { date: string; startTime: string; endTime: string; resourceId: string };
29
+ };
30
+ onEditorChange?: (editor: { date?: string; startTime?: string; endTime?: string; resourceId?: string }) => void;
31
+ onCreate: (draft: {
32
+ title: string;
33
+ customerName: string;
34
+ customerPhone: string;
35
+ customerEmail: string;
36
+ notes: string;
37
+ recurrence: {
38
+ mode: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
39
+ frequency: 'daily' | 'weekly' | 'monthly';
40
+ interval: string;
41
+ count: string;
42
+ byWeekday: number[];
43
+ };
44
+ }) => Promise<void> | void;
45
+ onAction: (action: 'confirm', booking: Booking) => Promise<void> | void;
46
+ }>,
47
+ }));
48
+ const calendarSurfaceMocks = vi.hoisted(() => ({
49
+ last: null as null | {
50
+ onEventDrop?: (info: {
51
+ event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
52
+ revert: () => void;
53
+ }) => Promise<void> | void;
54
+ onEventResize?: (info: {
55
+ event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
56
+ revert: () => void;
57
+ }) => Promise<void> | void;
58
+ onSelect?: (info: { start: Date; end: Date; startStr: string; endStr: string }) => void;
59
+ },
60
+ }));
61
+
62
+ const sampleBooking: Booking = {
63
+ id: 'booking-1',
64
+ org_id: 'org-1',
65
+ branch_id: 'branch-1',
66
+ service_id: 'service-1',
67
+ resource_id: 'resource-1',
68
+ reference: 'BK-001',
69
+ customer_name: 'Ada Lovelace',
70
+ customer_phone: '+54 381 555 0202',
71
+ status: 'pending_confirmation',
72
+ source: 'admin',
73
+ start_at: '2099-04-05T10:00:00Z',
74
+ end_at: '2099-04-05T10:30:00Z',
75
+ occupies_from: '2099-04-05T10:00:00Z',
76
+ occupies_until: '2099-04-05T10:30:00Z',
77
+ notes: 'Primera visita',
78
+ created_at: '2099-04-01T00:00:00Z',
79
+ updated_at: '2099-04-01T00:00:00Z',
80
+ };
81
+
82
+ const sampleCalendarEvent: CalendarEvent = {
83
+ id: sampleBooking.id,
84
+ kind: 'booking',
85
+ sourceId: sampleBooking.id,
86
+ sourceType: 'booking',
87
+ title: sampleBooking.customer_name,
88
+ start_at: sampleBooking.start_at,
89
+ end_at: sampleBooking.end_at,
90
+ color: '#d97706',
91
+ status: sampleBooking.status,
92
+ serviceName: 'Consulta inicial',
93
+ resourceName: 'Dr. Rivera',
94
+ sourceBooking: sampleBooking,
95
+ };
96
+
97
+ vi.mock('@devpablocristo/core-browser', () => ({
98
+ confirmAction: confirmActionMock,
99
+ }));
100
+
101
+ vi.mock('@fullcalendar/react', () => ({
102
+ default: () => null,
103
+ }));
104
+
105
+ vi.mock('./SchedulingBookingModal', () => ({
106
+ SchedulingBookingModal: ({
107
+ state,
108
+ onEditorChange,
109
+ onCreate,
110
+ onAction,
111
+ }: {
112
+ state: {
113
+ open: boolean;
114
+ mode?: 'create' | 'details';
115
+ booking?: Booking;
116
+ slot?: TimeSlot | null;
117
+ editor?: { date: string; startTime: string; endTime: string; resourceId: string };
118
+ };
119
+ onEditorChange?: (editor: { date?: string; startTime?: string; endTime?: string; resourceId?: string }) => void;
120
+ onCreate: (draft: {
121
+ title: string;
122
+ customerName: string;
123
+ customerPhone: string;
124
+ customerEmail: string;
125
+ notes: string;
126
+ recurrence: {
127
+ mode: 'none' | 'daily' | 'weekly' | 'monthly' | 'custom';
128
+ frequency: 'daily' | 'weekly' | 'monthly';
129
+ interval: string;
130
+ count: string;
131
+ byWeekday: number[];
132
+ };
133
+ }) => Promise<void> | void;
134
+ onAction: (action: 'confirm', booking: Booking) => Promise<void> | void;
135
+ }) => {
136
+ modalMocks.props.push({ state, onEditorChange, onCreate, onAction });
137
+ if (!state.open) {
138
+ return null;
139
+ }
140
+ return (
141
+ <div data-testid="mock-booking-modal">
142
+ {state.mode === 'create' ? (
143
+ <>
144
+ <button
145
+ type="button"
146
+ onClick={() =>
147
+ void onCreate({
148
+ title: 'Control anual',
149
+ customerName: 'Grace Hopper',
150
+ customerPhone: '+54 381 555 0303',
151
+ customerEmail: 'grace@example.com',
152
+ notes: 'Control anual',
153
+ recurrence: {
154
+ mode: 'weekly',
155
+ frequency: 'weekly',
156
+ interval: '1',
157
+ count: '8',
158
+ byWeekday: [1],
159
+ },
160
+ })
161
+ }
162
+ >
163
+ mock-create-booking
164
+ </button>
165
+ <button
166
+ type="button"
167
+ onClick={() =>
168
+ onEditorChange?.({
169
+ startTime: '11:00',
170
+ endTime: '11:30',
171
+ })
172
+ }
173
+ >
174
+ mock-change-slot
175
+ </button>
176
+ </>
177
+ ) : (
178
+ <button
179
+ type="button"
180
+ onClick={() => {
181
+ if (state.booking) {
182
+ void onAction('confirm', state.booking);
183
+ }
184
+ }}
185
+ >
186
+ mock-confirm-booking
187
+ </button>
188
+ )}
189
+ </div>
190
+ );
191
+ },
192
+ }));
193
+
194
+ vi.mock('../../../calendar/board/ts/src/next', () => ({
195
+ resolveInitialTimeGridScrollTime: () => '08:00:00',
196
+ resolveInitialTimeGridViewport: () => ({ scrollTime: '07:30:00', slotMinTime: '07:00:00' }),
197
+ CalendarSurface: ({
198
+ calendarRef,
199
+ events,
200
+ onEventClick,
201
+ onDateClick,
202
+ onSelect,
203
+ onEventDrop,
204
+ onEventResize,
205
+ eventDurationEditable,
206
+ onDatesSet,
207
+ }: {
208
+ calendarRef: { current: unknown };
209
+ events?: Array<{ extendedProps?: { calendarEvent?: CalendarEvent } }>;
210
+ onEventClick?: (info: { event: { extendedProps: { calendarEvent?: CalendarEvent } } }) => void;
211
+ onDateClick?: (info: { date: Date; dateStr: string }) => void;
212
+ onSelect?: (info: { start: Date; end: Date; startStr: string; endStr: string }) => void;
213
+ onEventDrop?: (info: {
214
+ event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
215
+ revert: () => void;
216
+ }) => void;
217
+ onEventResize?: (info: {
218
+ event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
219
+ revert: () => void;
220
+ }) => Promise<void> | void;
221
+ eventDurationEditable?: boolean;
222
+ onDatesSet?: (info: { start: Date; end: Date }) => void;
223
+ }) => {
224
+ const currentEvents = events ?? [];
225
+ calendarSurfaceMocks.last = { onEventDrop, onEventResize, onSelect };
226
+
227
+ useEffect(() => {
228
+ const api = {
229
+ view: {
230
+ title: 'Week',
231
+ type: 'timeGridWeek',
232
+ activeStart: new Date('2099-04-05T00:00:00Z'),
233
+ activeEnd: new Date('2099-04-06T00:00:00Z'),
234
+ },
235
+ today: vi.fn(),
236
+ prev: vi.fn(),
237
+ next: vi.fn(),
238
+ changeView: vi.fn(),
239
+ gotoDate: vi.fn(),
240
+ scrollToTime: vi.fn(),
241
+ unselect: vi.fn(),
242
+ };
243
+ calendarRef.current = {
244
+ getApi: () => api,
245
+ };
246
+ onDatesSet?.({
247
+ start: api.view.activeStart,
248
+ end: api.view.activeEnd,
249
+ });
250
+ }, []);
251
+
252
+ return (
253
+ <div data-testid="calendar-surface">
254
+ calendar
255
+ <button
256
+ type="button"
257
+ onClick={() =>
258
+ onDateClick?.({
259
+ date: new Date('2099-04-05T10:00:00Z'),
260
+ dateStr: '2099-04-05T10:00:00Z',
261
+ })
262
+ }
263
+ >
264
+ open-calendar-create
265
+ </button>
266
+ <button
267
+ type="button"
268
+ onClick={() =>
269
+ onSelect?.({
270
+ start: new Date('2099-04-05T10:00:00Z'),
271
+ end: new Date('2099-04-05T10:30:00Z'),
272
+ startStr: '2099-04-05T10:00:00Z',
273
+ endStr: '2099-04-05T10:30:00Z',
274
+ })
275
+ }
276
+ >
277
+ open-calendar-select
278
+ </button>
279
+ {currentEvents.length > 0 ? (
280
+ <>
281
+ <button
282
+ type="button"
283
+ onClick={() => {
284
+ const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
285
+ if (calendarEvent) {
286
+ onEventClick?.({ event: { extendedProps: { calendarEvent } } });
287
+ }
288
+ }}
289
+ >
290
+ open-calendar-booking
291
+ </button>
292
+ <button
293
+ type="button"
294
+ onClick={() => {
295
+ const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
296
+ if (calendarEvent) {
297
+ onEventDrop?.({
298
+ event: {
299
+ id: calendarEvent.id,
300
+ startStr: '2099-04-05T11:00:00Z',
301
+ start: new Date('2099-04-05T11:00:00Z'),
302
+ extendedProps: { calendarEvent },
303
+ },
304
+ revert: vi.fn(),
305
+ });
306
+ }
307
+ }}
308
+ >
309
+ drag-calendar-booking
310
+ </button>
311
+ {eventDurationEditable ? (
312
+ <button
313
+ type="button"
314
+ onClick={() => {
315
+ const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
316
+ if (calendarEvent) {
317
+ onEventResize?.({
318
+ event: {
319
+ id: calendarEvent.id,
320
+ startStr: '2099-04-05T10:00:00Z',
321
+ start: new Date('2099-04-05T10:00:00Z'),
322
+ end: new Date('2099-04-05T11:00:00Z'),
323
+ extendedProps: { calendarEvent },
324
+ },
325
+ revert: vi.fn(),
326
+ });
327
+ }
328
+ }}
329
+ >
330
+ resize-calendar-booking
331
+ </button>
332
+ ) : null}
333
+ </>
334
+ ) : null}
335
+ </div>
336
+ );
337
+ },
338
+ }));
339
+
340
+ function createClient(overrides?: Partial<Record<keyof SchedulingClient, unknown>>): SchedulingClient {
341
+ const branches: Branch[] = [
342
+ {
343
+ id: 'branch-1',
344
+ org_id: 'org-1',
345
+ code: 'HQ',
346
+ name: 'Casa Central',
347
+ timezone: 'America/Argentina/Tucuman',
348
+ address: 'Main street 123',
349
+ active: true,
350
+ created_at: '2099-04-01T00:00:00Z',
351
+ updated_at: '2099-04-01T00:00:00Z',
352
+ },
353
+ ];
354
+
355
+ const services: Service[] = [
356
+ {
357
+ id: 'service-1',
358
+ org_id: 'org-1',
359
+ code: 'CONSULTA',
360
+ name: 'Consulta inicial',
361
+ description: 'Consulta general',
362
+ fulfillment_mode: 'schedule',
363
+ default_duration_minutes: 30,
364
+ buffer_before_minutes: 0,
365
+ buffer_after_minutes: 0,
366
+ slot_granularity_minutes: 30,
367
+ max_concurrent_bookings: 1,
368
+ min_cancel_notice_minutes: 60,
369
+ allow_waitlist: true,
370
+ active: true,
371
+ resource_ids: ['resource-1'],
372
+ created_at: '2099-04-01T00:00:00Z',
373
+ updated_at: '2099-04-01T00:00:00Z',
374
+ },
375
+ ];
376
+
377
+ const resources: Resource[] = [
378
+ {
379
+ id: 'resource-1',
380
+ org_id: 'org-1',
381
+ branch_id: 'branch-1',
382
+ code: 'DOC-1',
383
+ name: 'Dr. Rivera',
384
+ kind: 'professional',
385
+ capacity: 1,
386
+ timezone: 'America/Argentina/Tucuman',
387
+ active: true,
388
+ created_at: '2099-04-01T00:00:00Z',
389
+ updated_at: '2099-04-01T00:00:00Z',
390
+ },
391
+ ];
392
+
393
+ const slot: TimeSlot = {
394
+ resource_id: 'resource-1',
395
+ resource_name: 'Dr. Rivera',
396
+ start_at: '2099-04-05T10:00:00Z',
397
+ end_at: '2099-04-05T10:30:00Z',
398
+ occupies_from: '2099-04-05T10:00:00Z',
399
+ occupies_until: '2099-04-05T10:30:00Z',
400
+ timezone: 'America/Argentina/Tucuman',
401
+ remaining: 1,
402
+ conflict_count: 0,
403
+ granularity_minutes: 30,
404
+ };
405
+
406
+ const laterSlot: TimeSlot = {
407
+ resource_id: 'resource-1',
408
+ resource_name: 'Dr. Rivera',
409
+ start_at: '2099-04-05T11:00:00Z',
410
+ end_at: '2099-04-05T11:30:00Z',
411
+ occupies_from: '2099-04-05T11:00:00Z',
412
+ occupies_until: '2099-04-05T11:30:00Z',
413
+ timezone: 'America/Argentina/Tucuman',
414
+ remaining: 1,
415
+ conflict_count: 0,
416
+ granularity_minutes: 30,
417
+ };
418
+
419
+ const dashboard: DashboardStats = {
420
+ date: '2099-04-05',
421
+ timezone: 'America/Argentina/Tucuman',
422
+ bookings_today: 3,
423
+ confirmed_bookings_today: 1,
424
+ active_queues: 1,
425
+ waiting_tickets: 2,
426
+ tickets_in_service: 0,
427
+ };
428
+
429
+ return {
430
+ listBranches: vi.fn(async () => branches),
431
+ listServices: vi.fn(async () => services),
432
+ listResources: vi.fn(async () => resources),
433
+ listAvailabilityRules: vi.fn(async () => []),
434
+ getDashboard: vi.fn(async () => dashboard),
435
+ listSlots: vi.fn(async () => [slot, laterSlot]),
436
+ listBookings: vi.fn(async () => [sampleBooking]),
437
+ getBooking: vi.fn(),
438
+ createBooking: vi.fn(async () => sampleBooking),
439
+ confirmBooking: vi.fn(async () => ({ ...sampleBooking, status: 'confirmed' })),
440
+ cancelBooking: vi.fn(),
441
+ checkInBooking: vi.fn(),
442
+ startService: vi.fn(),
443
+ completeBooking: vi.fn(),
444
+ markBookingNoShow: vi.fn(),
445
+ rescheduleBooking: vi.fn(async () => sampleBooking),
446
+ listBlockedRanges: vi.fn(async () => [] as BlockedRange[]),
447
+ createBlockedRange: vi.fn(async () => sampleBlockedRange),
448
+ updateBlockedRange: vi.fn(async () => sampleBlockedRange),
449
+ deleteBlockedRange: vi.fn(async () => undefined),
450
+ listWaitlist: vi.fn(),
451
+ listQueues: vi.fn(),
452
+ createQueueTicket: vi.fn(),
453
+ getQueuePosition: vi.fn(),
454
+ pauseQueue: vi.fn(),
455
+ reopenQueue: vi.fn(),
456
+ closeQueue: vi.fn(),
457
+ callNext: vi.fn(),
458
+ serveTicket: vi.fn(),
459
+ completeTicket: vi.fn(),
460
+ markTicketNoShow: vi.fn(),
461
+ cancelTicket: vi.fn(),
462
+ returnTicketToWaiting: vi.fn(),
463
+ getDayAgenda: vi.fn(),
464
+ ...(overrides ?? {}),
465
+ } as SchedulingClient;
466
+ }
467
+
468
+ const sampleBlockedRange: BlockedRange = {
469
+ id: 'block-1',
470
+ org_id: 'org-1',
471
+ branch_id: 'branch-1',
472
+ resource_id: null,
473
+ kind: 'manual',
474
+ reason: 'Reunión con proveedor',
475
+ start_at: '2099-04-05T17:00:00Z',
476
+ end_at: '2099-04-05T18:00:00Z',
477
+ all_day: false,
478
+ created_at: '2099-04-01T00:00:00Z',
479
+ };
480
+
481
+ function renderCalendar(client: SchedulingClient, locale = 'es') {
482
+ // Evita múltiples <SchedulingCalendar> en el mismo documento (getByRole pegaba al árbol viejo).
483
+ cleanup();
484
+ // Reset module-global mock state so closures from previous tests don't leak in.
485
+ calendarSurfaceMocks.last = null;
486
+ modalMocks.props.length = 0;
487
+
488
+ const queryClient = new QueryClient({
489
+ defaultOptions: {
490
+ queries: { retry: false },
491
+ mutations: { retry: false },
492
+ },
493
+ });
494
+
495
+ return render(
496
+ <QueryClientProvider client={queryClient}>
497
+ <SchedulingCalendar client={client} locale={locale} initialDate="2099-04-05" initialBranchId="branch-1" />
498
+ </QueryClientProvider>,
499
+ );
500
+ }
501
+
502
+ describe('SchedulingCalendar', () => {
503
+ it('creates a booking from an available slot with the canonical admin payload', async () => {
504
+ const client = createClient();
505
+ modalMocks.props.length = 0;
506
+ confirmActionMock.mockReset();
507
+ confirmActionMock.mockResolvedValue(true);
508
+
509
+ renderCalendar(client);
510
+
511
+ fireEvent.click((await screen.findAllByRole('button', { name: 'Reservar slot' }))[0]);
512
+ fireEvent.click(await screen.findByRole('button', { name: 'mock-create-booking' }));
513
+
514
+ await waitFor(() => {
515
+ expect(client.createBooking).toHaveBeenCalledWith({
516
+ branch_id: 'branch-1',
517
+ service_id: 'service-1',
518
+ resource_id: 'resource-1',
519
+ customer_name: 'Grace Hopper',
520
+ customer_phone: '+54 381 555 0303',
521
+ customer_email: 'grace@example.com',
522
+ start_at: '2099-04-05T10:00:00Z',
523
+ notes: 'Control anual',
524
+ metadata: { title: 'Control anual' },
525
+ recurrence: {
526
+ freq: 'weekly',
527
+ interval: 1,
528
+ count: 8,
529
+ by_weekday: [1],
530
+ },
531
+ source: 'admin',
532
+ });
533
+ });
534
+ });
535
+
536
+ it('creates a booking draft from calendar date click', async () => {
537
+ const client = createClient();
538
+ modalMocks.props.length = 0;
539
+ confirmActionMock.mockReset();
540
+ confirmActionMock.mockResolvedValue(true);
541
+
542
+ renderCalendar(client);
543
+
544
+ await screen.findAllByRole('button', { name: 'Reservar slot' });
545
+ await act(async () => {
546
+ fireEvent.click(screen.getByRole('button', { name: 'open-calendar-create' }));
547
+ });
548
+ await act(async () => {
549
+ fireEvent.click(screen.getByRole('button', { name: 'mock-create-booking' }));
550
+ });
551
+
552
+ await waitFor(() => {
553
+ expect(client.createBooking).toHaveBeenCalledWith(
554
+ expect.objectContaining({
555
+ branch_id: 'branch-1',
556
+ service_id: 'service-1',
557
+ resource_id: 'resource-1',
558
+ customer_name: 'Grace Hopper',
559
+ customer_phone: '+54 381 555 0303',
560
+ customer_email: 'grace@example.com',
561
+ start_at: '2099-04-05T10:00:00Z',
562
+ notes: 'Control anual',
563
+ metadata: { title: 'Control anual' },
564
+ recurrence: {
565
+ freq: 'weekly',
566
+ interval: 1,
567
+ count: 8,
568
+ by_weekday: [1],
569
+ },
570
+ source: 'admin',
571
+ }),
572
+ );
573
+ });
574
+ });
575
+
576
+ it('keeps spanish copy when using a regional spanish locale', async () => {
577
+ const client = createClient();
578
+ modalMocks.props.length = 0;
579
+
580
+ renderCalendar(client, 'es-AR');
581
+
582
+ expect((await screen.findAllByRole('button', { name: 'Reservar slot' })).length).toBeGreaterThan(0);
583
+ });
584
+
585
+ it('uses the newly selected available slot when creating from the modal', async () => {
586
+ const client = createClient();
587
+ modalMocks.props.length = 0;
588
+ confirmActionMock.mockReset();
589
+ confirmActionMock.mockResolvedValue(true);
590
+
591
+ renderCalendar(client);
592
+
593
+ await screen.findAllByRole('button', { name: 'Reservar slot' });
594
+ fireEvent.click(await screen.findByRole('button', { name: 'open-calendar-create' }));
595
+ fireEvent.click(await screen.findByRole('button', { name: 'mock-change-slot' }));
596
+ let latestCreateProps: (typeof modalMocks.props)[number] | undefined;
597
+ await waitFor(() => {
598
+ latestCreateProps = [...modalMocks.props]
599
+ .reverse()
600
+ .find((entry) => entry.state.open && entry.state.mode === 'create' && entry.state.editor?.startTime === '11:00');
601
+ expect(Boolean(latestCreateProps)).toBe(true);
602
+ });
603
+
604
+ expect(latestCreateProps?.state.editor?.startTime).toBe('11:00');
605
+ expect(latestCreateProps?.state.editor?.endTime).toBe('11:30');
606
+ });
607
+
608
+ it('creates a booking draft from calendar range selection', async () => {
609
+ const client = createClient();
610
+ modalMocks.props.length = 0;
611
+ confirmActionMock.mockReset();
612
+ confirmActionMock.mockResolvedValue(true);
613
+
614
+ renderCalendar(client);
615
+
616
+ await screen.findAllByRole('button', { name: 'Reservar slot' });
617
+ fireEvent.click(await screen.findByRole('button', { name: 'open-calendar-select' }));
618
+ fireEvent.click(await screen.findByRole('button', { name: 'mock-create-booking' }));
619
+
620
+ await waitFor(() => {
621
+ expect(client.createBooking).toHaveBeenCalledWith(
622
+ expect.objectContaining({
623
+ branch_id: 'branch-1',
624
+ service_id: 'service-1',
625
+ resource_id: 'resource-1',
626
+ start_at: '2099-04-05T10:00:00Z',
627
+ recurrence: {
628
+ freq: 'weekly',
629
+ interval: 1,
630
+ count: 8,
631
+ by_weekday: [1],
632
+ },
633
+ }),
634
+ );
635
+ });
636
+ });
637
+
638
+ it('prefers the exact slot duration when the selected range matches availability', async () => {
639
+ const longSlot: TimeSlot = {
640
+ resource_id: 'resource-1',
641
+ resource_name: 'Dr. Rivera',
642
+ start_at: '2099-04-05T10:00:00Z',
643
+ end_at: '2099-04-05T11:00:00Z',
644
+ occupies_from: '2099-04-05T10:00:00Z',
645
+ occupies_until: '2099-04-05T11:00:00Z',
646
+ timezone: 'America/Argentina/Tucuman',
647
+ remaining: 1,
648
+ conflict_count: 0,
649
+ granularity_minutes: 30,
650
+ };
651
+ const client = createClient({
652
+ listSlots: vi.fn(async () => [longSlot]),
653
+ });
654
+ modalMocks.props.length = 0;
655
+ confirmActionMock.mockReset();
656
+ confirmActionMock.mockResolvedValue(true);
657
+
658
+ renderCalendar(client);
659
+
660
+ await screen.findByRole('button', { name: 'Reservar slot' });
661
+
662
+ await waitFor(() => {
663
+ expect(calendarSurfaceMocks.last?.onSelect).toBeTruthy();
664
+ });
665
+
666
+ await act(async () => {
667
+ calendarSurfaceMocks.last?.onSelect?.({
668
+ start: new Date('2099-04-05T10:00:00Z'),
669
+ end: new Date('2099-04-05T11:00:00Z'),
670
+ startStr: '2099-04-05T10:00:00Z',
671
+ endStr: '2099-04-05T11:00:00Z',
672
+ });
673
+ });
674
+
675
+ await waitFor(() => {
676
+ const matchingCreateState = modalMocks.props
677
+ .map((entry) => entry.state)
678
+ .find((state) => state.open && state.mode === 'create' && state.slot?.end_at === '2099-04-05T11:00:00Z');
679
+ expect(Boolean(matchingCreateState)).toBe(true);
680
+ });
681
+ });
682
+
683
+ it('opens booking details from a calendar event and confirms it', async () => {
684
+ const client = createClient();
685
+ modalMocks.props.length = 0;
686
+ confirmActionMock.mockReset();
687
+ confirmActionMock.mockResolvedValue(true);
688
+
689
+ renderCalendar(client);
690
+
691
+ await waitFor(() => {
692
+ expect(modalMocks.props.length).toBeGreaterThan(0);
693
+ });
694
+ const latestModalProps = modalMocks.props[modalMocks.props.length - 1];
695
+ expect(latestModalProps).toBeTruthy();
696
+ await act(async () => {
697
+ await latestModalProps?.onAction('confirm', sampleBooking);
698
+ });
699
+
700
+ await waitFor(() => {
701
+ expect(client.confirmBooking).toHaveBeenCalledWith('booking-1');
702
+ });
703
+ });
704
+
705
+ it('confirms and persists calendar event drag reschedules with custom duration', async () => {
706
+ const client = createClient();
707
+ modalMocks.props.length = 0;
708
+ confirmActionMock.mockReset();
709
+ confirmActionMock.mockResolvedValue(true);
710
+
711
+ renderCalendar(client);
712
+
713
+ await waitFor(() => {
714
+ expect(calendarSurfaceMocks.last?.onEventDrop).toBeTruthy();
715
+ });
716
+
717
+ await act(async () => {
718
+ await calendarSurfaceMocks.last?.onEventDrop?.({
719
+ event: {
720
+ id: 'booking-1',
721
+ startStr: '2099-04-05T11:00:00Z',
722
+ start: new Date('2099-04-05T11:00:00Z'),
723
+ end: new Date('2099-04-05T11:30:00Z'),
724
+ extendedProps: { calendarEvent: sampleCalendarEvent },
725
+ },
726
+ revert: vi.fn(),
727
+ });
728
+ });
729
+
730
+ expect(confirmActionMock).toHaveBeenCalledWith(
731
+ expect.objectContaining({
732
+ title: 'Mover evento',
733
+ confirmLabel: 'Reprogramar reserva',
734
+ }),
735
+ );
736
+ expect(client.rescheduleBooking).toHaveBeenCalledWith(
737
+ 'booking-1',
738
+ expect.objectContaining({
739
+ start_at: '2099-04-05T11:00:00.000Z',
740
+ end_at: '2099-04-05T11:30:00.000Z',
741
+ }),
742
+ );
743
+ });
744
+
745
+ it('persists calendar event resize as a custom-duration reschedule', async () => {
746
+ const client = createClient();
747
+ modalMocks.props.length = 0;
748
+ confirmActionMock.mockReset();
749
+ confirmActionMock.mockResolvedValue(true);
750
+
751
+ renderCalendar(client);
752
+
753
+ await waitFor(() => {
754
+ expect(calendarSurfaceMocks.last?.onEventResize).toBeTruthy();
755
+ });
756
+
757
+ await act(async () => {
758
+ await calendarSurfaceMocks.last?.onEventResize?.({
759
+ event: {
760
+ id: 'booking-1',
761
+ startStr: '2099-04-05T10:00:00Z',
762
+ start: new Date('2099-04-05T10:00:00Z'),
763
+ end: new Date('2099-04-05T11:00:00Z'),
764
+ extendedProps: { calendarEvent: sampleCalendarEvent },
765
+ },
766
+ revert: vi.fn(),
767
+ });
768
+ });
769
+
770
+ expect(confirmActionMock).toHaveBeenCalledWith(
771
+ expect.objectContaining({
772
+ title: 'Cambiar duración del turno',
773
+ }),
774
+ );
775
+ expect(client.rescheduleBooking).toHaveBeenCalledWith(
776
+ 'booking-1',
777
+ expect.objectContaining({
778
+ start_at: '2099-04-05T10:00:00.000Z',
779
+ end_at: '2099-04-05T11:00:00.000Z',
780
+ }),
781
+ );
782
+ });
783
+
784
+ it('queries blocked ranges for the visible range when a branch is selected', async () => {
785
+ const client = createClient();
786
+
787
+ renderCalendar(client);
788
+
789
+ await waitFor(() => {
790
+ expect(client.listBlockedRanges).toHaveBeenCalled();
791
+ });
792
+ const firstCall = (client.listBlockedRanges as ReturnType<typeof vi.fn>).mock.calls[0]?.[0];
793
+ expect(firstCall).toEqual(expect.objectContaining({ branchId: 'branch-1' }));
794
+ });
795
+
796
+ it('creates a blocked range from the calendar action button', async () => {
797
+ const client = createClient();
798
+ modalMocks.props.length = 0;
799
+
800
+ renderCalendar(client);
801
+
802
+ await screen.findAllByRole('button', { name: 'Reservar slot' });
803
+ fireEvent.click(await screen.findByRole('button', { name: 'Bloquear horario' }));
804
+
805
+ const reasonInput = await screen.findByLabelText('Motivo');
806
+ fireEvent.change(reasonInput, { target: { value: 'Reunión con proveedor' } });
807
+
808
+ fireEvent.change(document.getElementById('blocked-range-date') as HTMLInputElement, {
809
+ target: { value: '2099-04-05' },
810
+ });
811
+ fireEvent.change(document.getElementById('blocked-range-start') as HTMLInputElement, {
812
+ target: { value: '14:00' },
813
+ });
814
+ fireEvent.change(document.getElementById('blocked-range-end') as HTMLInputElement, {
815
+ target: { value: '15:00' },
816
+ });
817
+
818
+ fireEvent.click(screen.getByRole('button', { name: 'Guardar bloqueo' }));
819
+
820
+ await waitFor(() => {
821
+ expect(client.createBlockedRange).toHaveBeenCalledWith(
822
+ expect.objectContaining({
823
+ branch_id: 'branch-1',
824
+ kind: 'manual',
825
+ reason: 'Reunión con proveedor',
826
+ }),
827
+ );
828
+ });
829
+ });
830
+ });