@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 +1 -1
- package/src/SchedulingCalendar.test.tsx +48 -10
- package/src/SchedulingCalendarBoard.tsx +152 -118
- package/src/schedulingCalendarLogic.collision.test.ts +158 -0
- package/src/schedulingCalendarLogic.ts +159 -1
- package/src/styles.css +12 -15
package/package.json
CHANGED
|
@@ -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<{
|
|
210
|
-
|
|
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: {
|
|
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: {
|
|
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
|
-
{
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
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
|
-
}, [
|
|
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
|
-
() => [
|
|
911
|
-
|
|
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
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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(
|
|
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
|
|
1331
|
-
{copy.timelineDescription
|
|
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 {
|
|
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:
|
|
21
|
-
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
22
|
gap: var(--space-4);
|
|
23
|
-
align-items:
|
|
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;
|