@devpablocristo/modules-scheduling 0.4.0 → 0.4.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devpablocristo/modules-scheduling",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Scheduling calendar, queue operator, and public booking flow for Pymes (React + FullCalendar).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4
4
  import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
- import { useEffect } from 'react';
5
+ import { useEffect, type ReactNode } from 'react';
6
6
  import { describe, expect, it, vi } from 'vitest';
7
7
  import type { SchedulingClient } from './client';
8
8
  import { SchedulingCalendar } from './SchedulingCalendarBoard';
@@ -204,22 +204,40 @@ vi.mock('../../../calendar/board/ts/src/next', () => ({
204
204
  onEventResize,
205
205
  eventDurationEditable,
206
206
  onDatesSet,
207
+ toolbarTrailing,
207
208
  }: {
208
209
  calendarRef: { current: unknown };
209
- events?: Array<{ extendedProps?: { calendarEvent?: CalendarEvent } }>;
210
- onEventClick?: (info: { event: { extendedProps: { calendarEvent?: CalendarEvent } } }) => void;
210
+ events?: Array<{
211
+ extendedProps?: { calendarEvent?: CalendarEvent; freeTimeSlot?: TimeSlot };
212
+ }>;
213
+ onEventClick?: (info: {
214
+ event: { extendedProps: { calendarEvent?: CalendarEvent; freeTimeSlot?: TimeSlot } };
215
+ }) => void;
211
216
  onDateClick?: (info: { date: Date; dateStr: string }) => void;
212
217
  onSelect?: (info: { start: Date; end: Date; startStr: string; endStr: string }) => void;
213
218
  onEventDrop?: (info: {
214
- event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
219
+ event: {
220
+ id: string;
221
+ startStr: string;
222
+ start?: Date;
223
+ end?: Date;
224
+ extendedProps: { calendarEvent?: CalendarEvent; freeTimeSlot?: TimeSlot };
225
+ };
215
226
  revert: () => void;
216
227
  }) => void;
217
228
  onEventResize?: (info: {
218
- event: { id: string; startStr: string; start?: Date; end?: Date; extendedProps: { calendarEvent?: CalendarEvent } };
229
+ event: {
230
+ id: string;
231
+ startStr: string;
232
+ start?: Date;
233
+ end?: Date;
234
+ extendedProps: { calendarEvent?: CalendarEvent; freeTimeSlot?: TimeSlot };
235
+ };
219
236
  revert: () => void;
220
237
  }) => Promise<void> | void;
221
238
  eventDurationEditable?: boolean;
222
239
  onDatesSet?: (info: { start: Date; end: Date }) => void;
240
+ toolbarTrailing?: ReactNode;
223
241
  }) => {
224
242
  const currentEvents = events ?? [];
225
243
  calendarSurfaceMocks.last = { onEventDrop, onEventResize, onSelect };
@@ -249,9 +267,13 @@ vi.mock('../../../calendar/board/ts/src/next', () => ({
249
267
  });
250
268
  }, []);
251
269
 
270
+ const bookingEvent = currentEvents.find((ev) => ev.extendedProps?.calendarEvent);
271
+ const freeSlotEvents = currentEvents.filter((ev) => ev.extendedProps?.freeTimeSlot);
272
+
252
273
  return (
253
274
  <div data-testid="calendar-surface">
254
275
  calendar
276
+ {toolbarTrailing}
255
277
  <button
256
278
  type="button"
257
279
  onClick={() =>
@@ -276,12 +298,29 @@ vi.mock('../../../calendar/board/ts/src/next', () => ({
276
298
  >
277
299
  open-calendar-select
278
300
  </button>
279
- {currentEvents.length > 0 ? (
301
+ {freeSlotEvents.map((ev, idx) => (
302
+ <button
303
+ key={`free-slot-${idx}`}
304
+ type="button"
305
+ onClick={() =>
306
+ onEventClick?.({
307
+ event: {
308
+ extendedProps: {
309
+ freeTimeSlot: ev.extendedProps?.freeTimeSlot,
310
+ },
311
+ },
312
+ })
313
+ }
314
+ >
315
+ Reservar slot
316
+ </button>
317
+ ))}
318
+ {bookingEvent?.extendedProps?.calendarEvent ? (
280
319
  <>
281
320
  <button
282
321
  type="button"
283
322
  onClick={() => {
284
- const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
323
+ const calendarEvent = bookingEvent.extendedProps?.calendarEvent;
285
324
  if (calendarEvent) {
286
325
  onEventClick?.({ event: { extendedProps: { calendarEvent } } });
287
326
  }
@@ -292,7 +331,7 @@ vi.mock('../../../calendar/board/ts/src/next', () => ({
292
331
  <button
293
332
  type="button"
294
333
  onClick={() => {
295
- const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
334
+ const calendarEvent = bookingEvent.extendedProps?.calendarEvent;
296
335
  if (calendarEvent) {
297
336
  onEventDrop?.({
298
337
  event: {
@@ -312,7 +351,7 @@ vi.mock('../../../calendar/board/ts/src/next', () => ({
312
351
  <button
313
352
  type="button"
314
353
  onClick={() => {
315
- const calendarEvent = currentEvents[0]?.extendedProps?.calendarEvent;
354
+ const calendarEvent = bookingEvent.extendedProps?.calendarEvent;
316
355
  if (calendarEvent) {
317
356
  onEventResize?.({
318
357
  event: {
@@ -799,7 +838,6 @@ describe('SchedulingCalendar', () => {
799
838
 
800
839
  renderCalendar(client);
801
840
 
802
- await screen.findAllByRole('button', { name: 'Reservar slot' });
803
841
  fireEvent.click(await screen.findByRole('button', { name: 'Bloquear horario' }));
804
842
 
805
843
  const reasonInput = await screen.findByLabelText('Motivo');
@@ -14,7 +14,6 @@ import type { SchedulingClient } from './client';
14
14
  import {
15
15
  formatSchedulingClock,
16
16
  formatSchedulingCompactClock,
17
- formatSchedulingDateOnly,
18
17
  resolveSchedulingCopyLocale,
19
18
  } from './locale';
20
19
  import {
@@ -40,6 +39,7 @@ import {
40
39
  buildSchedulingDetailsModalState,
41
40
  buildSlotIdentity,
42
41
  buildSyntheticTimeSlotFromEditor,
42
+ calendarSelectionAllowedWithBuffers,
43
43
  resolveBookingDisplayTitle,
44
44
  toDateInputValue as toDateInputValueFromIso,
45
45
  toTimeInputValue,
@@ -503,19 +503,6 @@ export function SchedulingCalendar({
503
503
  staleTime: 20_000,
504
504
  });
505
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
506
  const createEditorDate = modalState.open && modalState.mode === 'create' ? modalState.editor.date : focusedDate;
520
507
 
521
508
  const createSlotsQuery = useQuery<TimeSlot[]>({
@@ -867,17 +854,51 @@ export function SchedulingCalendar({
867
854
  });
868
855
  }, [bookingsQuery.data, selectedServiceId, selectedResourceId, scheduleServices, filteredResources, deferredSearch, copy.statuses]);
869
856
 
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
- ]),
857
+ /** Reservas para colisiones en el calendario (sin filtro de búsqueda: no ocultar conflictos). */
858
+ const collisionBookings = useMemo(() => {
859
+ const source = bookingsQuery.data ?? [];
860
+ return source.filter((booking) => {
861
+ if (selectedServiceId && booking.service_id !== selectedServiceId) {
862
+ return false;
863
+ }
864
+ if (selectedResourceId && booking.resource_id !== selectedResourceId) {
865
+ return false;
866
+ }
867
+ return true;
868
+ });
869
+ }, [bookingsQuery.data, selectedServiceId, selectedResourceId]);
870
+
871
+ const filteredFreeSlots = useMemo(() => {
872
+ return visibleSlots.filter(
873
+ (slot) =>
874
+ slot.remaining > 0 &&
875
+ matchesSearch(deferredSearch, [
876
+ slot.resource_name,
877
+ formatSchedulingClock(slot.start_at, locale),
878
+ formatSchedulingClock(slot.end_at, locale),
879
+ selectedService?.name,
880
+ ]),
879
881
  );
880
- }, [slotsQuery.data, deferredSearch, selectedService?.name, locale]);
882
+ }, [visibleSlots, deferredSearch, selectedService?.name, locale]);
883
+
884
+ const freeSlotCalendarEvents = useMemo<EventInput[]>(
885
+ () =>
886
+ filteredFreeSlots.map((slot) => ({
887
+ id: `free-slot:${buildSlotIdentity(slot.resource_id, slot.start_at, slot.end_at)}`,
888
+ title: copy.openBooking,
889
+ start: slot.start_at,
890
+ end: slot.end_at,
891
+ backgroundColor: '#d1fae5',
892
+ borderColor: '#34d399',
893
+ textColor: '#065f46',
894
+ classNames: ['modules-scheduling__calendar-event--free-slot'],
895
+ editable: false,
896
+ extendedProps: {
897
+ freeTimeSlot: slot,
898
+ },
899
+ })),
900
+ [filteredFreeSlots, copy.openBooking],
901
+ );
881
902
 
882
903
  const calendarEvents = useMemo(
883
904
  () => buildSchedulingCalendarEvents(filteredBookings, scheduleServices, filteredResources, eventColor),
@@ -907,33 +928,103 @@ export function SchedulingCalendar({
907
928
  );
908
929
 
909
930
  const fullCalendarEventInputs = useMemo(
910
- () => [...buildFullCalendarEventInputs(calendarEvents), ...blockedRangeEventInputs],
911
- [calendarEvents, blockedRangeEventInputs],
931
+ () => [
932
+ ...freeSlotCalendarEvents,
933
+ ...blockedRangeEventInputs,
934
+ ...buildFullCalendarEventInputs(calendarEvents),
935
+ ],
936
+ [freeSlotCalendarEvents, blockedRangeEventInputs, calendarEvents],
912
937
  );
913
938
 
914
939
  const resolveDaySlots = (value: Date): TimeSlot[] => visibleSlotsByDate.get(toDateInputValue(value)) ?? [];
915
940
 
916
941
  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
942
  if (!selectedService || !info.start || !info.end) {
921
943
  return false;
922
944
  }
923
- return info.end.getTime() > info.start.getTime();
945
+ if (info.end.getTime() <= info.start.getTime()) {
946
+ return false;
947
+ }
948
+ const resourceIds = filteredResources.map((r) => r.id);
949
+ return calendarSelectionAllowedWithBuffers({
950
+ start: info.start,
951
+ end: info.end,
952
+ service: selectedService,
953
+ resourceIds,
954
+ bookings: collisionBookings,
955
+ blockedRanges,
956
+ });
924
957
  };
925
958
 
926
959
  const handleEventAllow = (dropInfo: CalendarEventAllowArg, draggedEvent: CalendarDraggedEventArg) => {
927
- if (!draggedEvent) {
960
+ if (!draggedEvent || !dropInfo.start || !dropInfo.end) {
928
961
  return false;
929
962
  }
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);
963
+ const ext = draggedEvent.extendedProps as {
964
+ blockedRange?: BlockedRange;
965
+ calendarEvent?: CalendarEvent;
966
+ };
967
+ const blockedRange = ext.blockedRange;
968
+ const sourceBooking = ext.calendarEvent?.sourceBooking;
969
+ if (blockedRange) {
970
+ const resourceIds = blockedRange.resource_id
971
+ ? [blockedRange.resource_id]
972
+ : filteredResources.map((r) => r.id);
973
+ if (!resourceIds.length) {
974
+ return false;
975
+ }
976
+ return calendarSelectionAllowedWithBuffers({
977
+ start: dropInfo.start,
978
+ end: dropInfo.end,
979
+ service: selectedService,
980
+ resourceIds,
981
+ bookings: collisionBookings,
982
+ blockedRanges,
983
+ occupancyIsExplicit: true,
984
+ excludeBlockedRangeId: blockedRange.id,
985
+ });
986
+ }
987
+ if (sourceBooking) {
988
+ const dragService =
989
+ scheduleServices.find((s) => s.id === sourceBooking.service_id) ?? selectedService;
990
+ if (!dragService) {
991
+ return false;
992
+ }
993
+ return calendarSelectionAllowedWithBuffers({
994
+ start: dropInfo.start,
995
+ end: dropInfo.end,
996
+ service: dragService,
997
+ resourceIds: [sourceBooking.resource_id],
998
+ bookings: collisionBookings,
999
+ blockedRanges,
1000
+ excludeBookingId: sourceBooking.id,
1001
+ });
1002
+ }
1003
+ return true;
934
1004
  };
935
1005
 
936
1006
  const renderEventContent = (info: EventContentArg) => {
1007
+ const freeSlot = info.event.extendedProps.freeTimeSlot as TimeSlot | undefined;
1008
+ if (freeSlot) {
1009
+ const listView = info.view.type.startsWith('list');
1010
+ const rangeLabel = `${formatSchedulingClock(freeSlot.start_at, locale)} – ${formatSchedulingClock(freeSlot.end_at, locale)}`;
1011
+ const compactRange = `${formatSchedulingCompactClock(freeSlot.start_at, locale)}-${formatSchedulingCompactClock(freeSlot.end_at, locale)}`;
1012
+ const secondary = listView
1013
+ ? `${rangeLabel} · ${freeSlot.resource_name} · ${copy.slotRemainingLabel}: ${freeSlot.remaining}`
1014
+ : `${compactRange} · ${freeSlot.resource_name} · ${copy.slotRemainingLabel} ${freeSlot.remaining}`;
1015
+ return (
1016
+ <div
1017
+ className={`modules-scheduling__calendar-event modules-scheduling__calendar-event--free-slot${listView ? ' modules-scheduling__calendar-event--list' : ''}`}
1018
+ >
1019
+ <div className="modules-scheduling__calendar-event-top">
1020
+ <strong>{copy.openBooking}</strong>
1021
+ </div>
1022
+ <div className="modules-scheduling__calendar-event-meta">
1023
+ <span>{secondary}</span>
1024
+ </div>
1025
+ </div>
1026
+ );
1027
+ }
937
1028
  const calendarEvent = info.event.extendedProps.calendarEvent as CalendarEvent | undefined;
938
1029
  if (!calendarEvent) {
939
1030
  return <span>{info.event.title}</span>;
@@ -966,8 +1057,16 @@ export function SchedulingCalendar({
966
1057
  };
967
1058
 
968
1059
  const openCreateBookingModal = (slot: TimeSlot, resourceName?: string) => {
1060
+ const dayKey = toDateInputValueFromIso(slot.start_at);
1061
+ const sameDaySlots = visibleSlots.filter((s) => toDateInputValueFromIso(s.start_at) === dayKey);
969
1062
  setModalState(
970
- buildSchedulingCreateModalStateFromSlot(slot, selectedService, slotsQuery.data ?? [], filteredResources, resourceName),
1063
+ buildSchedulingCreateModalStateFromSlot(
1064
+ slot,
1065
+ selectedService,
1066
+ sameDaySlots.length > 0 ? sameDaySlots : [slot],
1067
+ filteredResources,
1068
+ resourceName,
1069
+ ),
971
1070
  );
972
1071
  };
973
1072
 
@@ -1092,6 +1191,11 @@ export function SchedulingCalendar({
1092
1191
  };
1093
1192
 
1094
1193
  const handleCalendarEventClick = (info: EventClickArg) => {
1194
+ const freeSlot = info.event.extendedProps.freeTimeSlot as TimeSlot | undefined;
1195
+ if (freeSlot) {
1196
+ openCreateBookingModal(freeSlot, freeSlot.resource_name);
1197
+ return;
1198
+ }
1095
1199
  const blockedRange = info.event.extendedProps.blockedRange as BlockedRange | undefined;
1096
1200
  if (blockedRange) {
1097
1201
  openBlockedRangeEditModal(blockedRange);
@@ -1327,8 +1431,8 @@ export function SchedulingCalendar({
1327
1431
  {(copy.timelineTitle || copy.timelineDescription) && (
1328
1432
  <div className="card-header">
1329
1433
  <div>
1330
- {copy.timelineTitle && <h2>{copy.timelineTitle}</h2>}
1331
- {copy.timelineDescription && <p className="text-secondary">{copy.timelineDescription}</p>}
1434
+ {copy.timelineTitle ? <h2>{copy.timelineTitle}</h2> : null}
1435
+ {copy.timelineDescription ? <p className="text-secondary">{copy.timelineDescription}</p> : null}
1332
1436
  </div>
1333
1437
  </div>
1334
1438
  )}
@@ -1365,6 +1469,16 @@ export function SchedulingCalendar({
1365
1469
  scrollCalendarToRelevantTime();
1366
1470
  });
1367
1471
  }}
1472
+ toolbarTrailing={
1473
+ <button
1474
+ type="button"
1475
+ className="btn-secondary btn-sm"
1476
+ onClick={openBlockedRangeCreateModal}
1477
+ disabled={!selectedBranchId}
1478
+ >
1479
+ {copy.blockedRangeAction}
1480
+ </button>
1481
+ }
1368
1482
  scrollTime={timeGridViewport.scrollTime}
1369
1483
  scrollTimeReset={false}
1370
1484
  slotMinTime={timeGridViewport.slotMinTime}
@@ -1391,86 +1505,6 @@ export function SchedulingCalendar({
1391
1505
  }}
1392
1506
  />
1393
1507
  </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
1508
  </div>
1475
1509
 
1476
1510
  <div className="card">
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Booking, Service } from './types';
3
+ import {
4
+ bookingBlocksCollisions,
5
+ bookingOccupancyOverlapsWindow,
6
+ buildOccupancyWindowFromServiceRange,
7
+ calendarSelectionAllowedWithBuffers,
8
+ schedulingIntervalsOverlap,
9
+ } from './schedulingCalendarLogic';
10
+
11
+ const baseService: Service = {
12
+ id: 'svc',
13
+ org_id: 'o',
14
+ code: 'c',
15
+ name: 'N',
16
+ description: '',
17
+ fulfillment_mode: 'schedule',
18
+ default_duration_minutes: 30,
19
+ buffer_before_minutes: 0,
20
+ buffer_after_minutes: 15,
21
+ slot_granularity_minutes: 15,
22
+ max_concurrent_bookings: 1,
23
+ min_cancel_notice_minutes: 0,
24
+ allow_waitlist: false,
25
+ active: true,
26
+ resource_ids: [],
27
+ metadata: {},
28
+ created_at: '',
29
+ updated_at: '',
30
+ };
31
+
32
+ describe('schedulingIntervalsOverlap', () => {
33
+ it('detecta solape y permite límite contiguo sin solape', () => {
34
+ const a0 = new Date('2026-04-09T15:30:00Z');
35
+ const a1 = new Date('2026-04-09T16:00:00Z');
36
+ const b0 = new Date('2026-04-09T16:00:00Z');
37
+ const b1 = new Date('2026-04-09T16:30:00Z');
38
+ expect(schedulingIntervalsOverlap(a0, a1, b0, b1)).toBe(false);
39
+ expect(schedulingIntervalsOverlap(a0, a1, new Date('2026-04-09T15:45:00Z'), b1)).toBe(true);
40
+ });
41
+ });
42
+
43
+ describe('buildOccupancyWindowFromServiceRange', () => {
44
+ it('extiende el fin con buffer_after', () => {
45
+ const start = new Date('2026-04-09T16:00:00Z');
46
+ const end = new Date('2026-04-09T16:30:00Z');
47
+ const { occupiesFrom, occupiesUntil } = buildOccupancyWindowFromServiceRange(start, end, baseService);
48
+ expect(occupiesFrom.toISOString()).toBe('2026-04-09T16:00:00.000Z');
49
+ expect(occupiesUntil.toISOString()).toBe('2026-04-09T16:45:00.000Z');
50
+ });
51
+ });
52
+
53
+ describe('calendarSelectionAllowedWithBuffers', () => {
54
+ const booking: Booking = {
55
+ id: 'b1',
56
+ org_id: 'o',
57
+ branch_id: 'br',
58
+ service_id: baseService.id,
59
+ resource_id: 'res1',
60
+ reference: 'r',
61
+ customer_name: 'A',
62
+ customer_phone: '1',
63
+ status: 'confirmed',
64
+ source: 'admin',
65
+ start_at: '2026-04-09T15:30:00Z',
66
+ end_at: '2026-04-09T16:00:00Z',
67
+ occupies_from: '2026-04-09T15:30:00Z',
68
+ occupies_until: '2026-04-09T16:15:00Z',
69
+ notes: '',
70
+ created_by: 'x',
71
+ created_at: '',
72
+ updated_at: '',
73
+ };
74
+
75
+ it('rechaza selección que cae en ventana de buffer del turno previo', () => {
76
+ const ok = calendarSelectionAllowedWithBuffers({
77
+ start: new Date('2026-04-09T16:00:00Z'),
78
+ end: new Date('2026-04-09T16:30:00Z'),
79
+ service: baseService,
80
+ resourceIds: ['res1'],
81
+ bookings: [booking],
82
+ blockedRanges: [],
83
+ });
84
+ expect(ok).toBe(false);
85
+ });
86
+
87
+ it('acepta selección tras el buffer del turno previo', () => {
88
+ const ok = calendarSelectionAllowedWithBuffers({
89
+ start: new Date('2026-04-09T16:15:00Z'),
90
+ end: new Date('2026-04-09T16:45:00Z'),
91
+ service: baseService,
92
+ resourceIds: ['res1'],
93
+ bookings: [booking],
94
+ blockedRanges: [],
95
+ });
96
+ expect(ok).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe('bookingOccupancyOverlapsWindow', () => {
101
+ it('usa occupies del booking', () => {
102
+ const booking: Booking = {
103
+ id: 'b1',
104
+ org_id: 'o',
105
+ branch_id: 'br',
106
+ service_id: 's',
107
+ resource_id: 'res1',
108
+ reference: 'r',
109
+ customer_name: 'A',
110
+ customer_phone: '1',
111
+ status: 'confirmed',
112
+ source: 'admin',
113
+ start_at: '2026-04-09T15:30:00Z',
114
+ end_at: '2026-04-09T16:00:00Z',
115
+ occupies_from: '2026-04-09T15:30:00Z',
116
+ occupies_until: '2026-04-09T16:15:00Z',
117
+ notes: '',
118
+ created_by: 'x',
119
+ created_at: '',
120
+ updated_at: '',
121
+ };
122
+ expect(
123
+ bookingOccupancyOverlapsWindow(
124
+ booking,
125
+ new Date('2026-04-09T16:00:00Z'),
126
+ new Date('2026-04-09T16:30:00Z'),
127
+ ),
128
+ ).toBe(true);
129
+ });
130
+ });
131
+
132
+ describe('bookingBlocksCollisions', () => {
133
+ it('excluye hold vencido', () => {
134
+ const past = new Date(Date.now() - 60_000).toISOString();
135
+ const booking: Booking = {
136
+ id: 'h',
137
+ org_id: 'o',
138
+ branch_id: 'br',
139
+ service_id: 's',
140
+ resource_id: 'r',
141
+ reference: 'r',
142
+ customer_name: 'A',
143
+ customer_phone: '1',
144
+ status: 'hold',
145
+ source: 'admin',
146
+ start_at: '2099-01-01T10:00:00Z',
147
+ end_at: '2099-01-01T10:30:00Z',
148
+ occupies_from: '2099-01-01T10:00:00Z',
149
+ occupies_until: '2099-01-01T10:30:00Z',
150
+ hold_expires_at: past,
151
+ notes: '',
152
+ created_by: 'x',
153
+ created_at: '',
154
+ updated_at: '',
155
+ };
156
+ expect(bookingBlocksCollisions(booking)).toBe(false);
157
+ });
158
+ });
@@ -5,7 +5,16 @@ import type {
5
5
  SchedulingBookingModalState,
6
6
  SchedulingBookingRecurrenceDraft,
7
7
  } from './SchedulingBookingModal';
8
- import type { AvailabilityRule, Booking, Branch, CalendarEvent, Resource, Service, TimeSlot } from './types';
8
+ import type {
9
+ AvailabilityRule,
10
+ BlockedRange,
11
+ Booking,
12
+ Branch,
13
+ CalendarEvent,
14
+ Resource,
15
+ Service,
16
+ TimeSlot,
17
+ } from './types';
9
18
 
10
19
  export type SchedulingBusinessHours = {
11
20
  daysOfWeek: number[];
@@ -371,3 +380,152 @@ export function buildSyntheticTimeSlotFromEditor(
371
380
  granularity_minutes: service.slot_granularity_minutes,
372
381
  };
373
382
  }
383
+
384
+ /** Paridad con `bookingStatusesBlocking` en el repositorio Go. */
385
+ export const SCHEDULING_BLOCKING_BOOKING_STATUSES: ReadonlySet<Booking['status']> = new Set([
386
+ 'hold',
387
+ 'pending_confirmation',
388
+ 'confirmed',
389
+ 'checked_in',
390
+ 'in_service',
391
+ ]);
392
+
393
+ export function bookingBlocksCollisions(booking: Booking, nowMs: number = Date.now()): boolean {
394
+ if (!SCHEDULING_BLOCKING_BOOKING_STATUSES.has(booking.status)) {
395
+ return false;
396
+ }
397
+ if (booking.status === 'hold' && booking.hold_expires_at) {
398
+ const exp = Date.parse(booking.hold_expires_at);
399
+ if (Number.isFinite(exp) && exp < nowMs) {
400
+ return false;
401
+ }
402
+ }
403
+ return true;
404
+ }
405
+
406
+ export function buildOccupancyWindowFromServiceRange(
407
+ start: Date,
408
+ end: Date,
409
+ service: Pick<Service, 'buffer_before_minutes' | 'buffer_after_minutes'>,
410
+ ): { occupiesFrom: Date; occupiesUntil: Date } {
411
+ const beforeMs = Math.max(0, service.buffer_before_minutes ?? 0) * 60_000;
412
+ const afterMs = Math.max(0, service.buffer_after_minutes ?? 0) * 60_000;
413
+ return {
414
+ occupiesFrom: new Date(start.getTime() - beforeMs),
415
+ occupiesUntil: new Date(end.getTime() + afterMs),
416
+ };
417
+ }
418
+
419
+ /** Solape de intervalos [aStart,aEnd) vs [bStart,bEnd) alineado al criterio SQL del backend. */
420
+ export function schedulingIntervalsOverlap(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
421
+ return aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime();
422
+ }
423
+
424
+ export function bookingOccupancyOverlapsWindow(
425
+ booking: Booking,
426
+ occupiesFrom: Date,
427
+ occupiesUntil: Date,
428
+ ): boolean {
429
+ const bStart = new Date(booking.occupies_from);
430
+ const bEnd = new Date(booking.occupies_until);
431
+ if (!Number.isFinite(bStart.getTime()) || !Number.isFinite(bEnd.getTime())) {
432
+ return false;
433
+ }
434
+ return schedulingIntervalsOverlap(occupiesFrom, occupiesUntil, bStart, bEnd);
435
+ }
436
+
437
+ export function blockedRangeOverlapsWindow(
438
+ range: BlockedRange,
439
+ occupiesFrom: Date,
440
+ occupiesUntil: Date,
441
+ resourceId: string,
442
+ ): boolean {
443
+ const rStart = new Date(range.start_at);
444
+ const rEnd = new Date(range.end_at);
445
+ if (!Number.isFinite(rStart.getTime()) || !Number.isFinite(rEnd.getTime())) {
446
+ return false;
447
+ }
448
+ const appliesToResource = !range.resource_id || range.resource_id === resourceId;
449
+ if (!appliesToResource) {
450
+ return false;
451
+ }
452
+ return schedulingIntervalsOverlap(occupiesFrom, occupiesUntil, rStart, rEnd);
453
+ }
454
+
455
+ /**
456
+ * Comprueba si algún recurso candidato admite la selección sin solapar ocupaciones existentes
457
+ * (incl. buffers del servicio) ni bloqueos. Evita abrir el modal con rangos que el backend
458
+ * rechazará con 409.
459
+ */
460
+ export function calendarSelectionAllowedWithBuffers(params: {
461
+ start: Date;
462
+ end: Date;
463
+ /** Obligatorio salvo cuando `occupancyIsExplicit` es true (solo se usa para buffers). */
464
+ service: Service | null;
465
+ resourceIds: readonly string[];
466
+ bookings: readonly Booking[];
467
+ blockedRanges: readonly BlockedRange[];
468
+ /** Ventana de ocupación = [start,end] sin ampliar por buffers (p. ej. arrastre de bloqueo). */
469
+ occupancyIsExplicit?: boolean;
470
+ excludeBookingId?: string | null;
471
+ excludeBlockedRangeId?: string | null;
472
+ }): boolean {
473
+ const {
474
+ start,
475
+ end,
476
+ service,
477
+ resourceIds,
478
+ bookings,
479
+ blockedRanges,
480
+ occupancyIsExplicit = false,
481
+ excludeBookingId,
482
+ excludeBlockedRangeId,
483
+ } = params;
484
+ if (!resourceIds.length) {
485
+ return false;
486
+ }
487
+ if (!occupancyIsExplicit && !service) {
488
+ return false;
489
+ }
490
+ const { occupiesFrom, occupiesUntil } = occupancyIsExplicit
491
+ ? { occupiesFrom: start, occupiesUntil: end }
492
+ : buildOccupancyWindowFromServiceRange(start, end, service!);
493
+ if (occupiesUntil.getTime() <= occupiesFrom.getTime()) {
494
+ return false;
495
+ }
496
+
497
+ for (const resourceId of resourceIds) {
498
+ let collides = false;
499
+ for (const booking of bookings) {
500
+ if (excludeBookingId && booking.id === excludeBookingId) {
501
+ continue;
502
+ }
503
+ if (!bookingBlocksCollisions(booking)) {
504
+ continue;
505
+ }
506
+ if (booking.resource_id !== resourceId) {
507
+ continue;
508
+ }
509
+ if (bookingOccupancyOverlapsWindow(booking, occupiesFrom, occupiesUntil)) {
510
+ collides = true;
511
+ break;
512
+ }
513
+ }
514
+ if (collides) {
515
+ continue;
516
+ }
517
+ for (const range of blockedRanges) {
518
+ if (excludeBlockedRangeId && range.id === excludeBlockedRangeId) {
519
+ continue;
520
+ }
521
+ if (blockedRangeOverlapsWindow(range, occupiesFrom, occupiesUntil, resourceId)) {
522
+ collides = true;
523
+ break;
524
+ }
525
+ }
526
+ if (!collides) {
527
+ return true;
528
+ }
529
+ }
530
+ return false;
531
+ }
package/src/styles.css CHANGED
@@ -17,14 +17,13 @@
17
17
  }
18
18
 
19
19
  .modules-scheduling__layout {
20
- display: grid;
21
- grid-template-columns: 1fr;
20
+ display: flex;
21
+ flex-direction: column;
22
22
  gap: var(--space-4);
23
- align-items: start;
23
+ align-items: stretch;
24
24
  }
25
25
 
26
- .modules-scheduling__calendar-card,
27
- .modules-scheduling__slots-card {
26
+ .modules-scheduling__calendar-card {
28
27
  min-width: 0;
29
28
  }
30
29
 
@@ -33,12 +32,6 @@
33
32
  min-height: 620px;
34
33
  }
35
34
 
36
- .modules-scheduling__slots-card {
37
- display: flex;
38
- flex-direction: column;
39
- gap: var(--space-3);
40
- }
41
-
42
35
  .modules-scheduling__calendar-event {
43
36
  display: flex;
44
37
  flex-direction: column;
@@ -94,6 +87,14 @@
94
87
  white-space: normal;
95
88
  }
96
89
 
90
+ .modules-scheduling__calendar-event--free-slot {
91
+ cursor: pointer;
92
+ }
93
+
94
+ .modules-scheduling__calendar-event--free-slot strong {
95
+ font-size: 0.78rem;
96
+ }
97
+
97
98
  .modules-scheduling__slots-date {
98
99
  display: flex;
99
100
  align-items: center;
@@ -590,10 +591,6 @@
590
591
  }
591
592
 
592
593
  @media (max-width: 1100px) {
593
- .modules-scheduling__layout {
594
- grid-template-columns: 1fr;
595
- }
596
-
597
594
  .modules-scheduling__calendar-card .modules-calendar {
598
595
  height: calc(100vh - 240px);
599
596
  min-height: 540px;