@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,373 @@
1
+ import type { EventInput } from '@fullcalendar/core';
2
+ import type {
3
+ SchedulingBookingCreateEditor,
4
+ SchedulingBookingCreateResourceOption,
5
+ SchedulingBookingModalState,
6
+ SchedulingBookingRecurrenceDraft,
7
+ } from './SchedulingBookingModal';
8
+ import type { AvailabilityRule, Booking, Branch, CalendarEvent, Resource, Service, TimeSlot } from './types';
9
+
10
+ export type SchedulingBusinessHours = {
11
+ daysOfWeek: number[];
12
+ startTime: string;
13
+ endTime: string;
14
+ };
15
+
16
+ type BuildCreateModalStateParams = {
17
+ start: Date;
18
+ startAt: string;
19
+ end?: Date | null;
20
+ endAt?: string | null;
21
+ slots: readonly TimeSlot[];
22
+ selectedService: Service | null;
23
+ selectedResource: Resource | null;
24
+ filteredResources: readonly Resource[];
25
+ selectedBranch: Branch | null;
26
+ };
27
+
28
+ export function slotDurationMinutes(slot: TimeSlot): number {
29
+ const start = new Date(slot.start_at).getTime();
30
+ const end = new Date(slot.end_at).getTime();
31
+ return Math.max(0, Math.round((end - start) / 60000));
32
+ }
33
+
34
+ function pad(value: number): string {
35
+ return String(value).padStart(2, '0');
36
+ }
37
+
38
+ export function toDateInputValue(value: string): string {
39
+ const parsed = new Date(value);
40
+ if (Number.isNaN(parsed.getTime())) {
41
+ return value.slice(0, 10);
42
+ }
43
+ return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}`;
44
+ }
45
+
46
+ export function toTimeInputValue(value: string): string {
47
+ const parsed = new Date(value);
48
+ if (Number.isNaN(parsed.getTime())) {
49
+ return value;
50
+ }
51
+ return `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`;
52
+ }
53
+
54
+ export function buildCreateEditorFromSlot(slot: TimeSlot): SchedulingBookingCreateEditor {
55
+ return {
56
+ date: toDateInputValue(slot.start_at),
57
+ startTime: toTimeInputValue(slot.start_at),
58
+ endTime: toTimeInputValue(slot.end_at),
59
+ resourceId: slot.resource_id,
60
+ };
61
+ }
62
+
63
+ export function buildDefaultRecurrenceDraft(slot: TimeSlot): SchedulingBookingRecurrenceDraft {
64
+ const weekday = new Date(slot.start_at).getUTCDay();
65
+ return {
66
+ mode: 'none',
67
+ frequency: 'weekly',
68
+ interval: '1',
69
+ count: '8',
70
+ byWeekday: [weekday],
71
+ };
72
+ }
73
+
74
+ export function resolveBookingDisplayTitle(booking: Booking): string {
75
+ const metadataTitle = booking.metadata?.title;
76
+ return typeof metadataTitle === 'string' && metadataTitle.trim() ? metadataTitle.trim() : booking.customer_name;
77
+ }
78
+
79
+ export function buildCreateResourceOptions(resources: readonly Resource[], slot: TimeSlot): SchedulingBookingCreateResourceOption[] {
80
+ const merged = resources.some((resource) => resource.id === slot.resource_id)
81
+ ? resources
82
+ : [
83
+ ...resources,
84
+ {
85
+ id: slot.resource_id,
86
+ org_id: '',
87
+ branch_id: '',
88
+ code: slot.resource_id,
89
+ name: slot.resource_name,
90
+ kind: 'generic',
91
+ capacity: 1,
92
+ timezone: slot.timezone,
93
+ active: true,
94
+ created_at: '',
95
+ updated_at: '',
96
+ },
97
+ ];
98
+ return merged.map((resource) => ({
99
+ id: resource.id,
100
+ name: resource.name,
101
+ timezone: resource.timezone,
102
+ }));
103
+ }
104
+
105
+ function slotIdentity(slot: TimeSlot): string {
106
+ return `${slot.resource_id}:${slot.start_at}:${slot.end_at}`;
107
+ }
108
+
109
+ export function buildSlotIdentity(resourceId: string, startAt: string, endAt: string): string {
110
+ return `${resourceId}:${startAt}:${endAt}`;
111
+ }
112
+
113
+ type ClockWindow = {
114
+ start: string;
115
+ end: string;
116
+ };
117
+
118
+ function normalizeClock(value: string): string {
119
+ return value.slice(0, 5);
120
+ }
121
+
122
+ function toClockMinutes(value: string): number {
123
+ const [hours, minutes] = normalizeClock(value).split(':').map((piece) => Number(piece));
124
+ return hours * 60 + minutes;
125
+ }
126
+
127
+ function intersectClockWindows(left: readonly ClockWindow[], right: readonly ClockWindow[]): ClockWindow[] {
128
+ if (!left.length || !right.length) {
129
+ return [];
130
+ }
131
+ const intersections: ClockWindow[] = [];
132
+ for (const leftWindow of left) {
133
+ const leftStart = toClockMinutes(leftWindow.start);
134
+ const leftEnd = toClockMinutes(leftWindow.end);
135
+ for (const rightWindow of right) {
136
+ const start = Math.max(leftStart, toClockMinutes(rightWindow.start));
137
+ const end = Math.min(leftEnd, toClockMinutes(rightWindow.end));
138
+ if (end <= start) {
139
+ continue;
140
+ }
141
+ intersections.push({
142
+ start: `${String(Math.floor(start / 60)).padStart(2, '0')}:${String(start % 60).padStart(2, '0')}`,
143
+ end: `${String(Math.floor(end / 60)).padStart(2, '0')}:${String(end % 60).padStart(2, '0')}`,
144
+ });
145
+ }
146
+ }
147
+ return intersections;
148
+ }
149
+
150
+ export function buildSchedulingBusinessHours(
151
+ rules: readonly AvailabilityRule[],
152
+ selectedResourceId: string | null,
153
+ ): SchedulingBusinessHours[] {
154
+ const activeRules = rules.filter((rule) => rule.active);
155
+ const businessHours: SchedulingBusinessHours[] = [];
156
+
157
+ for (let weekday = 0; weekday <= 6; weekday += 1) {
158
+ const branchWindows = activeRules
159
+ .filter((rule) => rule.weekday === weekday && rule.kind === 'branch')
160
+ .map((rule) => ({ start: normalizeClock(rule.start_time), end: normalizeClock(rule.end_time) }));
161
+
162
+ if (!branchWindows.length) {
163
+ continue;
164
+ }
165
+
166
+ let activeWindows = branchWindows;
167
+ if (selectedResourceId) {
168
+ const resourceWindows = activeRules
169
+ .filter((rule) => rule.weekday === weekday && rule.kind === 'resource' && rule.resource_id === selectedResourceId)
170
+ .map((rule) => ({ start: normalizeClock(rule.start_time), end: normalizeClock(rule.end_time) }));
171
+ if (resourceWindows.length) {
172
+ activeWindows = intersectClockWindows(branchWindows, resourceWindows);
173
+ }
174
+ }
175
+
176
+ for (const window of activeWindows) {
177
+ businessHours.push({
178
+ daysOfWeek: [weekday],
179
+ startTime: window.start,
180
+ endTime: window.end,
181
+ });
182
+ }
183
+ }
184
+
185
+ return businessHours;
186
+ }
187
+
188
+ function buildCreateSlotOptions(slot: TimeSlot, slots: readonly TimeSlot[]): TimeSlot[] {
189
+ const merged = [slot, ...slots];
190
+ const unique = new Map<string, TimeSlot>();
191
+ for (const item of merged) {
192
+ unique.set(slotIdentity(item), item);
193
+ }
194
+ return Array.from(unique.values()).sort((left, right) => {
195
+ const startCompare = left.start_at.localeCompare(right.start_at);
196
+ if (startCompare !== 0) {
197
+ return startCompare;
198
+ }
199
+ return left.resource_name.localeCompare(right.resource_name);
200
+ });
201
+ }
202
+
203
+ export function buildSchedulingCalendarEvents(
204
+ bookings: readonly Booking[],
205
+ scheduleServices: readonly Service[],
206
+ filteredResources: readonly Resource[],
207
+ eventColor: (status: Booking['status']) => string,
208
+ ): CalendarEvent[] {
209
+ return bookings.map((booking) => {
210
+ const serviceName = scheduleServices.find((service) => service.id === booking.service_id)?.name;
211
+ const resourceName = filteredResources.find((resource) => resource.id === booking.resource_id)?.name;
212
+ return {
213
+ id: booking.id,
214
+ kind: 'booking',
215
+ sourceId: booking.id,
216
+ sourceType: 'booking',
217
+ title: resolveBookingDisplayTitle(booking),
218
+ start_at: booking.start_at,
219
+ end_at: booking.end_at,
220
+ color: eventColor(booking.status),
221
+ status: booking.status,
222
+ serviceName,
223
+ resourceName,
224
+ sourceBooking: booking,
225
+ };
226
+ });
227
+ }
228
+
229
+ export function buildFullCalendarEventInputs(calendarEvents: readonly CalendarEvent[]): EventInput[] {
230
+ return calendarEvents.map((calendarEvent) => ({
231
+ id: calendarEvent.id,
232
+ title: calendarEvent.title,
233
+ start: calendarEvent.start_at,
234
+ end: calendarEvent.end_at,
235
+ color: calendarEvent.color,
236
+ extendedProps: {
237
+ calendarEvent,
238
+ },
239
+ }));
240
+ }
241
+
242
+ export function buildSchedulingDetailsModalState(
243
+ booking: Booking,
244
+ scheduleServices: readonly Service[],
245
+ filteredResources: readonly Resource[],
246
+ ): SchedulingBookingModalState {
247
+ return {
248
+ open: true,
249
+ mode: 'details',
250
+ booking,
251
+ service: scheduleServices.find((service) => service.id === booking.service_id),
252
+ resourceName: filteredResources.find((resource) => resource.id === booking.resource_id)?.name,
253
+ };
254
+ }
255
+
256
+ export function buildSchedulingCreateModalStateFromSlot(
257
+ slot: TimeSlot,
258
+ selectedService: Service | null,
259
+ slots: readonly TimeSlot[],
260
+ resources: readonly Resource[],
261
+ resourceName?: string,
262
+ ): SchedulingBookingModalState {
263
+ return {
264
+ open: true,
265
+ mode: 'create',
266
+ slot,
267
+ slotOptions: buildCreateSlotOptions(slot, slots),
268
+ resourceOptions: buildCreateResourceOptions(resources, slot),
269
+ editor: buildCreateEditorFromSlot(slot),
270
+ validationMessage: null,
271
+ service: selectedService ?? undefined,
272
+ resourceName: resourceName ?? slot.resource_name,
273
+ draft: {
274
+ title: '',
275
+ customerName: '',
276
+ customerPhone: '',
277
+ customerEmail: '',
278
+ notes: '',
279
+ recurrence: buildDefaultRecurrenceDraft(slot),
280
+ },
281
+ };
282
+ }
283
+
284
+ export function buildSchedulingCreateModalStateFromStart({
285
+ start,
286
+ startAt,
287
+ end,
288
+ endAt,
289
+ slots,
290
+ selectedService,
291
+ selectedResource,
292
+ filteredResources,
293
+ selectedBranch,
294
+ }: BuildCreateModalStateParams): SchedulingBookingModalState | null {
295
+ if (!selectedService) {
296
+ return null;
297
+ }
298
+
299
+ const matchingSlot =
300
+ slots.find((slot) => slot.start_at === startAt && (!endAt || slot.end_at === endAt)) ??
301
+ slots.find((slot) => slot.start_at === startAt);
302
+ if (matchingSlot) {
303
+ return buildSchedulingCreateModalStateFromSlot(
304
+ matchingSlot,
305
+ selectedService,
306
+ slots,
307
+ filteredResources,
308
+ matchingSlot.resource_name,
309
+ );
310
+ }
311
+
312
+ const fallbackResource = selectedResource ?? filteredResources[0];
313
+ if (!fallbackResource) {
314
+ return null;
315
+ }
316
+
317
+ const provisionalEnd =
318
+ end && end.getTime() > start.getTime()
319
+ ? end
320
+ : new Date(start.getTime() + selectedService.default_duration_minutes * 60_000);
321
+
322
+ return buildSchedulingCreateModalStateFromSlot(
323
+ {
324
+ resource_id: fallbackResource.id,
325
+ resource_name: fallbackResource.name,
326
+ start_at: start.toISOString(),
327
+ end_at: provisionalEnd.toISOString(),
328
+ occupies_from: start.toISOString(),
329
+ occupies_until: provisionalEnd.toISOString(),
330
+ timezone: fallbackResource.timezone ?? selectedBranch?.timezone ?? 'UTC',
331
+ remaining: 1,
332
+ conflict_count: 0,
333
+ granularity_minutes: selectedService.slot_granularity_minutes,
334
+ },
335
+ selectedService,
336
+ slots,
337
+ filteredResources,
338
+ fallbackResource.name,
339
+ );
340
+ }
341
+
342
+ /** Slot sintético desde el editor cuando no hay coincidencia en la lista de slots del API (rango libre). */
343
+ export function buildSyntheticTimeSlotFromEditor(
344
+ editor: SchedulingBookingCreateEditor,
345
+ service: Service,
346
+ resource: Resource | null | undefined,
347
+ branch: Branch | null,
348
+ ): TimeSlot | null {
349
+ if (!resource) {
350
+ return null;
351
+ }
352
+ const startWall = `${editor.date}T${normalizeClock(editor.startTime)}:00`;
353
+ const endWall = `${editor.date}T${normalizeClock(editor.endTime)}:00`;
354
+ const startMs = new Date(startWall).getTime();
355
+ const endMs = new Date(endWall).getTime();
356
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
357
+ return null;
358
+ }
359
+ const startAt = new Date(startMs).toISOString();
360
+ const endAt = new Date(endMs).toISOString();
361
+ return {
362
+ resource_id: resource.id,
363
+ resource_name: resource.name,
364
+ start_at: startAt,
365
+ end_at: endAt,
366
+ occupies_from: startAt,
367
+ occupies_until: endAt,
368
+ timezone: resource.timezone ?? branch?.timezone ?? 'UTC',
369
+ remaining: 1,
370
+ conflict_count: 0,
371
+ granularity_minutes: service.slot_granularity_minutes,
372
+ };
373
+ }