View | Details | Raw Unified | Return to bug 41129
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt (-1 / +1 lines)
Lines 205-211 Link Here
205
                    timeline = new vis.Timeline(container, bookingsSet, itemsSet, options);
205
                    timeline = new vis.Timeline(container, bookingsSet, itemsSet, options);
206
                },
206
                },
207
                function (jqXHR, textStatus, errorThrown) {
207
                function (jqXHR, textStatus, errorThrown) {
208
                    console.log("Fetch failed");
208
                    console.warn("Timeline data fetch failed:", textStatus, errorThrown);
209
                }
209
                }
210
            );
210
            );
211
211
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue (-44 / +23 lines)
Lines 767-773 onUnmounted(() => { Link Here
767
</script>
767
</script>
768
768
769
<style>
769
<style>
770
/* Global variables for external libraries (flatpickr) and cross-block usage */
770
/* Design System: CSS Custom Properties */
771
:root {
771
:root {
772
    /* Success colors for constraint highlighting */
772
    /* Success colors for constraint highlighting */
773
    --booking-success-hue: 134;
773
    --booking-success-hue: 134;
Lines 776-837 onUnmounted(() => { Link Here
776
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
776
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
777
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
777
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
778
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
778
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
779
    --booking-constraint-marker: hsl(var(--booking-success-hue), 61%, 41%);
779
780
780
    /* Border width used by flatpickr */
781
    /* Border width */
781
    --booking-border-width: 1px;
782
    --booking-border-width: 1px;
782
783
783
    /* Variables used by second style block (booking markers, calendar states) */
784
    /* Booking markers */
784
    --booking-marker-size: max(4px, 0.25em);
785
    --booking-marker-size: max(4px, 0.25em);
785
    --booking-marker-grid-gap: 0.25rem;
786
    --booking-marker-grid-gap: 0.25rem;
786
    --booking-marker-grid-offset: -0.75rem;
787
    --booking-marker-grid-offset: -0.75rem;
787
788
788
    /* Color hues used in second style block */
789
    /* Color hues */
789
    --booking-warning-hue: 45;
790
    --booking-warning-hue: 45;
790
    --booking-danger-hue: 354;
791
    --booking-danger-hue: 354;
791
    --booking-info-hue: 195;
792
    --booking-info-hue: 195;
792
    --booking-neutral-hue: 210;
793
    --booking-neutral-hue: 210;
793
    --booking-holiday-hue: 0;
794
    --booking-holiday-hue: 0;
794
795
795
    /* Colors derived from hues (used in second style block) */
796
    /* Colors derived from hues */
796
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
797
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
797
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
798
    --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%);
799
    --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%);
800
801
    /* Spacing used in second style block */
802
    --booking-space-xs: 0.125rem;
803
804
    /* Typography used in second style block */
805
    --booking-text-xs: 0.7rem;
806
807
    /* Border radius used in second style block and other components */
808
    --booking-border-radius-sm: 0.25rem;
809
    --booking-border-radius-md: 0.5rem;
810
    --booking-border-radius-full: 50%;
811
}
812
813
/* Design System: CSS Custom Properties (First Style Block Only) */
814
:root {
815
    /* Colors not used in second style block */
816
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
798
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
817
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
799
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
818
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
800
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
819
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
801
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
802
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
803
    --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%);
804
    --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%);
820
805
821
    /* Spacing Scale (first block only) */
806
    /* Spacing Scale */
807
    --booking-space-xs: 0.125rem;
822
    --booking-space-sm: 0.25rem; /* 4px */
808
    --booking-space-sm: 0.25rem; /* 4px */
823
    --booking-space-md: 0.5rem; /* 8px */
809
    --booking-space-md: 0.5rem; /* 8px */
824
    --booking-space-lg: 1rem; /* 16px */
810
    --booking-space-lg: 1rem; /* 16px */
825
    --booking-space-xl: 1.5rem; /* 24px */
811
    --booking-space-xl: 1.5rem; /* 24px */
826
    --booking-space-2xl: 2rem; /* 32px */
812
    --booking-space-2xl: 2rem; /* 32px */
827
813
828
    /* Typography Scale (first block only) */
814
    /* Typography Scale */
815
    --booking-text-xs: 0.7rem;
829
    --booking-text-sm: 0.8125rem;
816
    --booking-text-sm: 0.8125rem;
830
    --booking-text-base: 1rem;
817
    --booking-text-base: 1rem;
831
    --booking-text-lg: 1.1rem;
818
    --booking-text-lg: 1.1rem;
832
    --booking-text-xl: 1.3rem;
819
    --booking-text-xl: 1.3rem;
833
    --booking-text-2xl: 2rem;
820
    --booking-text-2xl: 2rem;
834
821
822
    /* Border radius */
823
    --booking-border-radius-sm: 0.25rem;
824
    --booking-border-radius-md: 0.5rem;
825
    --booking-border-radius-full: 50%;
826
835
    /* Layout */
827
    /* Layout */
836
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
828
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
837
    --booking-input-min-width: 15rem;
829
    --booking-input-min-width: 15rem;
Lines 939-963 hr { Link Here
939
    margin-left: var(--booking-space-lg);
931
    margin-left: var(--booking-space-lg);
940
}
932
}
941
933
942
/* Legend colors match actual calendar markers exactly */
934
/* === Date Picker & Marker System === */
943
.calendar-legend .booking-marker-dot--booked {
944
    background: var(--booking-warning-bg) !important;
945
}
946
947
.calendar-legend .booking-marker-dot--checked-out {
948
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
949
}
950
951
.calendar-legend .booking-marker-dot--lead {
952
    background: hsl(var(--booking-info-hue), 60%, 85%) !important;
953
}
954
935
955
.calendar-legend .booking-marker-dot--trail {
956
    background: var(--booking-warning-bg) !important;
957
}
958
</style>
959
960
<style>
961
.booking-date-picker {
936
.booking-date-picker {
962
    display: flex;
937
    display: flex;
963
    align-items: stretch;
938
    align-items: stretch;
Lines 1053-1058 hr { Link Here
1053
    background: var(--booking-holiday-bg);
1028
    background: var(--booking-holiday-bg);
1054
}
1029
}
1055
1030
1031
.booking-marker-dot--constraint {
1032
    background: var(--booking-constraint-marker);
1033
}
1034
1056
/* Calendar Day States */
1035
/* Calendar Day States */
1057
.booked {
1036
.booked {
1058
    background: var(--booking-warning-bg) !important;
1037
    background: var(--booking-warning-bg) !important;
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue (-31 / +1 lines)
Lines 68-75 Link Here
68
            {{ $__("Closed") }}
68
            {{ $__("Closed") }}
69
            <span
69
            <span
70
                v-if="dateRangeConstraint && hasSelectedDates"
70
                v-if="dateRangeConstraint && hasSelectedDates"
71
                class="booking-marker-dot ml-3"
71
                class="booking-marker-dot booking-marker-dot--constraint ml-3"
72
                style="background-color: #28a745"
73
            ></span>
72
            ></span>
74
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
73
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
75
                {{ $__("Required end date") }}
74
                {{ $__("Required end date") }}
Lines 302-336 const clearDateRange = (): void => { Link Here
302
    margin-top: var(--booking-space-lg);
301
    margin-top: var(--booking-space-lg);
303
}
302
}
304
303
305
.booking-marker-dot {
306
    display: inline-block;
307
    width: calc(var(--booking-marker-size) * 3);
308
    height: calc(var(--booking-marker-size) * 3);
309
    border-radius: var(--booking-border-radius-full);
310
    margin-right: var(--booking-space-sm);
311
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
312
}
313
314
.booking-marker-dot--booked {
315
    background-color: var(--booking-warning-bg);
316
}
317
318
.booking-marker-dot--lead {
319
    background-color: hsl(var(--booking-info-hue), 60%, 85%);
320
}
321
322
.booking-marker-dot--trail {
323
    background-color: var(--booking-warning-bg);
324
}
325
326
.booking-marker-dot--checked-out {
327
    background-color: hsl(var(--booking-danger-hue), 60%, 85%);
328
}
329
330
.booking-marker-dot--holiday {
331
    background-color: var(--booking-holiday-bg);
332
}
333
334
.alert {
304
.alert {
335
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
305
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
336
    border: var(--booking-border-width) solid transparent;
306
    border: var(--booking-border-width) solid transparent;
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue (-21 lines)
Lines 31-37 Link Here
31
</template>
31
</template>
32
32
33
<script setup lang="ts">
33
<script setup lang="ts">
34
import { defineProps, withDefaults } from "vue";
35
import { $__ } from "../../i18n";
34
import { $__ } from "../../i18n";
36
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
35
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
37
import type { CalendarMarker } from "./types/bookings";
36
import type { CalendarMarker } from "./types/bookings";
Lines 72-95 withDefaults( Link Here
72
    margin: 0 var(--booking-space-xs) 0 0;
71
    margin: 0 var(--booking-space-xs) 0 0;
73
    vertical-align: middle;
72
    vertical-align: middle;
74
}
73
}
75
76
.booking-marker-dot--booked {
77
    background: var(--booking-warning-bg);
78
}
79
80
.booking-marker-dot--checked-out {
81
    background: hsl(var(--booking-danger-hue), 60%, 85%);
82
}
83
84
.booking-marker-dot--lead {
85
    background: hsl(var(--booking-info-hue), 60%, 85%);
86
}
87
88
.booking-marker-dot--trail {
89
    background: var(--booking-warning-bg);
90
}
91
92
.booking-marker-dot--holiday {
93
    background: var(--booking-holiday-bg);
94
}
95
</style>
74
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs (-4 / +1 lines)
Lines 87-95 export function useAvailability(storeRefs, optionsRef) { Link Here
87
    const disableFnRef = computed(
87
    const disableFnRef = computed(
88
        () => availability.value.disable || (() => false)
88
        () => availability.value.disable || (() => false)
89
    );
89
    );
90
    const unavailableByDateRef = computed(
91
        () => availability.value.unavailableByDate || {}
92
    );
93
90
94
    return { availability, disableFnRef, unavailableByDateRef };
91
    return { availability, disableFnRef };
95
}
92
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js (-3 / +3 lines)
Lines 161-167 export async function fetchPatrons(term, page = 1) { Link Here
161
            if (errorData.error) {
161
            if (errorData.error) {
162
                error.message += ` - ${errorData.error}`;
162
                error.message += ` - ${errorData.error}`;
163
            }
163
            }
164
        } catch (e) {}
164
        } catch (_) { /* response body may not be JSON */ }
165
165
166
        throw error;
166
        throw error;
167
    }
167
    }
Lines 349-355 export async function createBooking(bookingData) { Link Here
349
            if (errorData.error) {
349
            if (errorData.error) {
350
                errorMessage += ` - ${errorData.error}`;
350
                errorMessage += ` - ${errorData.error}`;
351
            }
351
            }
352
        } catch (e) {}
352
        } catch (_) { /* response body may not be JSON */ }
353
        /** @type {Error & { status?: number }} */
353
        /** @type {Error & { status?: number }} */
354
        const error = Object.assign(new Error(errorMessage), {
354
        const error = Object.assign(new Error(errorMessage), {
355
            status: response.status,
355
            status: response.status,
Lines 405-411 export async function updateBooking(bookingId, bookingData) { Link Here
405
            if (errorData.error) {
405
            if (errorData.error) {
406
                errorMessage += ` - ${errorData.error}`;
406
                errorMessage += ` - ${errorData.error}`;
407
            }
407
            }
408
        } catch (e) {}
408
        } catch (_) { /* response body may not be JSON */ }
409
        /** @type {Error & { status?: number }} */
409
        /** @type {Error & { status?: number }} */
410
        const error = Object.assign(new Error(errorMessage), {
410
        const error = Object.assign(new Error(errorMessage), {
411
            status: response.status,
411
            status: response.status,
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs (-42 / +42 lines)
Lines 413-460 function ensureFeedbackBar(fp) { Link Here
413
    return /** @type {HTMLDivElement} */ (bar);
413
    return /** @type {HTMLDivElement} */ (bar);
414
}
414
}
415
415
416
/** @type {number|null} */
417
let _feedbackHideTimer = null;
418
419
/**
420
 * Update the feedback bar inside the flatpickr calendar container.
421
 * Hides are deferred so that rapid mouseout→mouseover between adjacent
422
 * days doesn't trigger a visible flicker.
423
 *
424
 * @param {HTMLDivElement} bar
425
 * @param {{ message: string, variant: string } | null} feedback
426
 */
427
function updateFeedbackBar(bar, feedback) {
428
    if (!feedback) {
429
        if (_feedbackHideTimer == null) {
430
            _feedbackHideTimer = setTimeout(() => {
431
                _feedbackHideTimer = null;
432
                bar.classList.remove(
433
                    "booking-hover-feedback--visible",
434
                    "booking-hover-feedback--info",
435
                    "booking-hover-feedback--warning",
436
                    "booking-hover-feedback--danger"
437
                );
438
            }, 16);
439
        }
440
        return;
441
    }
442
    if (_feedbackHideTimer != null) {
443
        clearTimeout(_feedbackHideTimer);
444
        _feedbackHideTimer = null;
445
    }
446
    bar.textContent = feedback.message;
447
    bar.classList.remove(
448
        "booking-hover-feedback--info",
449
        "booking-hover-feedback--warning",
450
        "booking-hover-feedback--danger"
451
    );
452
    bar.classList.add(
453
        "booking-hover-feedback--visible",
454
        `booking-hover-feedback--${feedback.variant}`
455
    );
456
}
457
458
/**
416
/**
459
 * Create Flatpickr `onDayCreate` handler.
417
 * Create Flatpickr `onDayCreate` handler.
460
 *
418
 *
Lines 478-483 export function createOnDayCreate( Link Here
478
    tooltipX,
436
    tooltipX,
479
    tooltipY
437
    tooltipY
480
) {
438
) {
439
    /** @type {number|null} */
440
    let _feedbackHideTimer = null;
441
442
    /**
443
     * Update the feedback bar inside the flatpickr calendar container.
444
     * Hides are deferred so that rapid mouseout→mouseover between adjacent
445
     * days doesn't trigger a visible flicker.
446
     *
447
     * @param {HTMLDivElement} bar
448
     * @param {{ message: string, variant: string } | null} feedback
449
     */
450
    function updateFeedbackBar(bar, feedback) {
451
        if (!feedback) {
452
            if (_feedbackHideTimer == null) {
453
                _feedbackHideTimer = setTimeout(() => {
454
                    _feedbackHideTimer = null;
455
                    bar.classList.remove(
456
                        "booking-hover-feedback--visible",
457
                        "booking-hover-feedback--info",
458
                        "booking-hover-feedback--warning",
459
                        "booking-hover-feedback--danger"
460
                    );
461
                }, 16);
462
            }
463
            return;
464
        }
465
        if (_feedbackHideTimer != null) {
466
            clearTimeout(_feedbackHideTimer);
467
            _feedbackHideTimer = null;
468
        }
469
        bar.textContent = feedback.message;
470
        bar.classList.remove(
471
            "booking-hover-feedback--info",
472
            "booking-hover-feedback--warning",
473
            "booking-hover-feedback--danger"
474
        );
475
        bar.classList.add(
476
            "booking-hover-feedback--visible",
477
            `booking-hover-feedback--${feedback.variant}`
478
        );
479
    }
480
481
    return function (
481
    return function (
482
        ...[
482
        ...[
483
            ,
483
            ,
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs (-16 / +3 lines)
Lines 122-140 export function fixDateAvailability( Link Here
122
    }
122
    }
123
}
123
}
124
124
125
/**
126
 * Fix incorrect target-end unavailability via a CSS-based override.
127
 * Wrapper for backward compatibility.
128
 *
129
 * @param {import('flatpickr/dist/types/instance').Instance} _instance
130
 * @param {NodeListOf<Element>|Element[]} dayElements
131
 * @param {Date} targetEndDate
132
 * @returns {void}
133
 */
134
function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) {
135
    fixDateAvailability(dayElements, targetEndDate, "target end date");
136
}
137
138
/**
125
/**
139
 * Apply constraint highlighting to the Flatpickr calendar.
126
 * Apply constraint highlighting to the Flatpickr calendar.
140
 *
127
 *
Lines 259-268 export function applyCalendarHighlighting(instance, highlightingData) { Link Here
259
246
260
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
247
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
261
            applyClickPrevention(instance);
248
            applyClickPrevention(instance);
262
            fixTargetEndDateAvailability(
249
            fixDateAvailability(
263
                instance,
264
                dayElements,
250
                dayElements,
265
                highlightingData.targetEndDate
251
                highlightingData.targetEndDate,
252
                "target end date"
266
            );
253
            );
267
254
268
            const targetEndElem = Array.from(dayElements).find(
255
            const targetEndElem = Array.from(dayElements).find(
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs (-166 lines)
Lines 6-12 Link Here
6
 */
6
 */
7
7
8
import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs";
8
import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs";
9
import { MAX_SEARCH_DAYS } from "../constants.mjs";
10
9
11
/**
10
/**
12
 * Event types for the sweep line algorithm
11
 * Event types for the sweep line algorithm
Lines 161-329 export class SweepLineProcessor { Link Here
161
160
162
        return unavailableByDate;
161
        return unavailableByDate;
163
    }
162
    }
164
165
    /**
166
     * Process intervals and return aggregated statistics
167
     * @param {Array} intervals
168
     * @param {Date|import("dayjs").Dayjs} viewStart
169
     * @param {Date|import("dayjs").Dayjs} viewEnd
170
     * @returns {Object} Statistics about the date range
171
     */
172
    getDateRangeStatistics(intervals, viewStart, viewEnd) {
173
        const stats = {
174
            totalDays: 0,
175
            daysWithBookings: 0,
176
            daysWithCheckouts: 0,
177
            fullyBookedDays: 0,
178
            peakBookingCount: 0,
179
            peakDate: null,
180
            itemUtilization: new Map(),
181
        };
182
183
        const startDate = BookingDate.from(viewStart).toDayjs();
184
        const endDate = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day");
185
186
        stats.totalDays = endDate.diff(startDate, "day") + 1;
187
188
        for (
189
            let date = startDate;
190
            date.isSameOrBefore(endDate, "day");
191
            date = date.add(1, "day")
192
        ) {
193
            const dayStart = date.valueOf();
194
            const dayEnd = date.endOf("day").valueOf();
195
196
            let bookingCount = 0;
197
            let checkoutCount = 0;
198
            const itemsInUse = new Set();
199
200
            intervals.forEach(interval => {
201
                if (interval.start <= dayEnd && interval.end >= dayStart) {
202
                    if (interval.type === "booking") {
203
                        bookingCount++;
204
                        itemsInUse.add(interval.itemId);
205
                    } else if (interval.type === "checkout") {
206
                        checkoutCount++;
207
                        itemsInUse.add(interval.itemId);
208
                    }
209
                }
210
            });
211
212
            if (bookingCount > 0) stats.daysWithBookings++;
213
            if (checkoutCount > 0) stats.daysWithCheckouts++;
214
215
            const totalCount = bookingCount + checkoutCount;
216
            if (totalCount > stats.peakBookingCount) {
217
                stats.peakBookingCount = totalCount;
218
                stats.peakDate = date.format("YYYY-MM-DD");
219
            }
220
221
            itemsInUse.forEach(itemId => {
222
                if (!stats.itemUtilization.has(itemId)) {
223
                    stats.itemUtilization.set(itemId, 0);
224
                }
225
                stats.itemUtilization.set(
226
                    itemId,
227
                    stats.itemUtilization.get(itemId) + 1
228
                );
229
            });
230
        }
231
232
        return stats;
233
    }
234
235
    /**
236
     * Find the next available date for a specific item
237
     * @param {Array} intervals
238
     * @param {string} itemId
239
     * @param {Date|import('dayjs').Dayjs} startDate
240
     * @param {number} maxDaysToSearch
241
     * @returns {Date|null}
242
     */
243
    findNextAvailableDate(
244
        intervals,
245
        itemId,
246
        startDate,
247
        maxDaysToSearch = MAX_SEARCH_DAYS
248
    ) {
249
        const start = BookingDate.from(startDate).toDayjs();
250
        const itemIntervals = intervals.filter(
251
            interval => interval.itemId === itemId
252
        );
253
254
        itemIntervals.sort((a, b) => a.start - b.start);
255
256
        for (let i = 0; i < maxDaysToSearch; i++) {
257
            const checkDate = start.add(i, "day");
258
            const dateStart = checkDate.valueOf();
259
            const dateEnd = checkDate.endOf("day").valueOf();
260
261
            const isAvailable = !itemIntervals.some(
262
                interval =>
263
                    interval.start <= dateEnd && interval.end >= dateStart
264
            );
265
266
            if (isAvailable) {
267
                return checkDate.toDate();
268
            }
269
        }
270
271
        return null;
272
    }
273
274
    /**
275
     * Find gaps (available periods) for an item
276
     * @param {Array} intervals
277
     * @param {string} itemId
278
     * @param {Date|import('dayjs').Dayjs} viewStart
279
     * @param {Date|import('dayjs').Dayjs} viewEnd
280
     * @param {number} minGapDays - Minimum gap size to report
281
     * @returns {Array<{start: Date, end: Date, days: number}>}
282
     */
283
    findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) {
284
        const gaps = [];
285
        const itemIntervals = intervals
286
            .filter(interval => interval.itemId === itemId)
287
            .sort((a, b) => a.start - b.start);
288
289
        const rangeStart = BookingDate.from(viewStart).valueOf();
290
        const rangeEnd = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day").valueOf();
291
292
        let lastEnd = rangeStart;
293
294
        itemIntervals.forEach(interval => {
295
            if (interval.end < rangeStart || interval.start > rangeEnd) {
296
                return;
297
            }
298
299
            const gapStart = Math.max(lastEnd, rangeStart);
300
            const gapEnd = Math.min(interval.start, rangeEnd);
301
302
            if (gapEnd > gapStart) {
303
                const gapDays = BookingDate.from(gapEnd).diff(BookingDate.from(gapStart), "day");
304
                if (gapDays >= minGapDays) {
305
                    gaps.push({
306
                        start: new Date(gapStart),
307
                        end: new Date(gapEnd - 1), // End of previous day
308
                        days: gapDays,
309
                    });
310
                }
311
            }
312
313
            lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day
314
        });
315
316
        if (lastEnd < rangeEnd) {
317
            const gapDays = BookingDate.from(rangeEnd).diff(BookingDate.from(lastEnd), "day");
318
            if (gapDays >= minGapDays) {
319
                gaps.push({
320
                    start: new Date(lastEnd),
321
                    end: new Date(rangeEnd),
322
                    days: gapDays,
323
                });
324
            }
325
        }
326
327
        return gaps;
328
    }
329
}
163
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs (-4 lines)
Lines 7-16 Link Here
7
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
7
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
8
export const CONSTRAINT_MODE_NORMAL = "normal";
8
export const CONSTRAINT_MODE_NORMAL = "normal";
9
9
10
// Selection semantics (logging, diagnostics)
11
export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE";
12
export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM";
13
14
// UI class names (used across calendar/adapters/composables)
10
// UI class names (used across calendar/adapters/composables)
15
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
11
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
16
    "booking-constrained-range-marker";
12
    "booking-constrained-range-marker";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs (-20 lines)
Lines 18-40 export function debounce(fn, delay) { Link Here
18
        timeout = setTimeout(() => fn.apply(this, args), delay);
18
        timeout = setTimeout(() => fn.apply(this, args), delay);
19
    };
19
    };
20
}
20
}
21
22
/**
23
 * Creates a throttled version of a function that only invokes
24
 * at most once per `limit` milliseconds.
25
 *
26
 * @template {(...args: any[]) => any} T
27
 * @param {T} fn - The function to throttle
28
 * @param {number} limit - Minimum time between invocations in milliseconds
29
 * @returns {(...args: Parameters<T>) => void}
30
 */
31
export function throttle(fn, limit) {
32
    let inThrottle;
33
    return function (...args) {
34
        if (!inThrottle) {
35
            fn.apply(this, args);
36
            inThrottle = true;
37
            setTimeout(() => (inThrottle = false), limit);
38
        }
39
    };
40
}
(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (+198 lines)
Lines 892-895 describe("Booking Modal Basic Tests", () => { Link Here
892
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
892
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
893
        );
893
        );
894
    });
894
    });
895
896
    it("should reset modal state after canceling", () => {
897
        cy.visit(
898
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
899
        );
900
901
        cy.get("booking-modal-island .modal").should("exist");
902
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
903
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
904
            "be.visible"
905
        );
906
907
        // Fill some fields
908
        cy.vueSelect(
909
            "booking_patron",
910
            testData.patron.cardnumber,
911
            `${testData.patron.surname} ${testData.patron.firstname}`
912
        );
913
        cy.vueSelectShouldBeEnabled("pickup_library_id");
914
        cy.vueSelectByIndex("pickup_library_id", 0);
915
916
        // Close modal and wait for Bootstrap transition to fully complete
917
        cy.get("booking-modal-island .modal .btn-close").first().click();
918
        cy.get("booking-modal-island .modal").should("not.be.visible");
919
        cy.get("body").should("not.have.class", "modal-open");
920
921
        // Reopen and verify state is reset
922
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
923
        cy.get("booking-modal-island .modal.show", { timeout: 10000 }).should(
924
            "be.visible"
925
        );
926
927
        cy.vueSelectShouldBeEnabled("booking_patron");
928
        cy.vueSelectShouldBeDisabled("pickup_library_id");
929
        cy.vueSelectShouldBeDisabled("booking_itemtype");
930
        cy.vueSelectShouldBeDisabled("booking_item_id");
931
        cy.get("#booking_period").should("be.disabled");
932
        cy.get('button[form="form-booking"][type="submit"]').should(
933
            "be.disabled"
934
        );
935
    });
936
937
    it("should show capacity warning for zero-day circulation rules", () => {
938
        cy.intercept(
939
            "GET",
940
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
941
        ).as("getPickupLocations");
942
        cy.intercept("GET", "/api/v1/circulation_rules*", {
943
            body: [
944
                {
945
                    library_id: testData.libraries[0].library_id,
946
                    item_type_id: "BK",
947
                    patron_category_id: testData.patron.category_id,
948
                    issuelength: 0,
949
                    renewalsallowed: 0,
950
                    renewalperiod: 0,
951
                    bookings_lead_period: 0,
952
                    bookings_trail_period: 0,
953
                    calculated_period_days: 0,
954
                },
955
            ],
956
        }).as("getCirculationRules");
957
958
        cy.visit(
959
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
960
        );
961
962
        cy.get("booking-modal-island .modal").should("exist");
963
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
964
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
965
            "be.visible"
966
        );
967
968
        cy.vueSelect(
969
            "booking_patron",
970
            testData.patron.cardnumber,
971
            `${testData.patron.surname} ${testData.patron.firstname}`
972
        );
973
        cy.wait("@getPickupLocations");
974
975
        cy.vueSelectShouldBeEnabled("pickup_library_id");
976
        cy.vueSelectByIndex("pickup_library_id", 0);
977
978
        cy.vueSelectShouldBeEnabled("booking_item_id");
979
        cy.vueSelectByIndex("booking_item_id", 1);
980
        cy.wait("@getCirculationRules");
981
982
        cy.get("booking-modal-island .modal .alert-warning")
983
            .scrollIntoView()
984
            .should("be.visible")
985
            .and("contain", "Bookings are not permitted");
986
        cy.get("#booking_period").should("be.disabled");
987
        cy.get('button[form="form-booking"][type="submit"]').should(
988
            "be.disabled"
989
        );
990
    });
991
992
    it("should show error on 409 conflict response", () => {
993
        cy.intercept(
994
            "GET",
995
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
996
        ).as("getPickupLocations");
997
        cy.intercept("GET", "/api/v1/circulation_rules*").as(
998
            "getCirculationRules"
999
        );
1000
        cy.intercept("POST", "/api/v1/bookings", {
1001
            statusCode: 409,
1002
            body: { error: "Booking conflict detected" },
1003
        }).as("conflictBooking");
1004
1005
        cy.visit(
1006
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
1007
        );
1008
1009
        cy.get("booking-modal-island .modal").should("exist");
1010
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1011
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
1012
            "be.visible"
1013
        );
1014
1015
        cy.vueSelect(
1016
            "booking_patron",
1017
            testData.patron.cardnumber,
1018
            `${testData.patron.surname} ${testData.patron.firstname}`
1019
        );
1020
        cy.wait("@getPickupLocations");
1021
1022
        cy.vueSelectShouldBeEnabled("pickup_library_id");
1023
        cy.vueSelectByIndex("pickup_library_id", 0);
1024
1025
        cy.vueSelectShouldBeEnabled("booking_item_id");
1026
        cy.vueSelectByIndex("booking_item_id", 1);
1027
        cy.wait("@getCirculationRules");
1028
1029
        cy.get("#booking_period").should("not.be.disabled");
1030
        const startDate = dayjs().add(5, "day");
1031
        const endDate = dayjs().add(10, "day");
1032
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
1033
1034
        cy.get('button[form="form-booking"][type="submit"]')
1035
            .should("not.be.disabled")
1036
            .click();
1037
        cy.wait("@conflictBooking");
1038
1039
        cy.get("booking-modal-island .modal .alert-danger").should("exist");
1040
        cy.get("booking-modal-island .modal").should("be.visible");
1041
    });
1042
1043
    it("should show error on 500 server error response", () => {
1044
        cy.intercept(
1045
            "GET",
1046
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
1047
        ).as("getPickupLocations");
1048
        cy.intercept("GET", "/api/v1/circulation_rules*").as(
1049
            "getCirculationRules"
1050
        );
1051
        cy.intercept("POST", "/api/v1/bookings", {
1052
            statusCode: 500,
1053
            body: { error: "Internal server error" },
1054
        }).as("serverError");
1055
1056
        cy.visit(
1057
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
1058
        );
1059
1060
        cy.get("booking-modal-island .modal").should("exist");
1061
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1062
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
1063
            "be.visible"
1064
        );
1065
1066
        cy.vueSelect(
1067
            "booking_patron",
1068
            testData.patron.cardnumber,
1069
            `${testData.patron.surname} ${testData.patron.firstname}`
1070
        );
1071
        cy.wait("@getPickupLocations");
1072
1073
        cy.vueSelectShouldBeEnabled("pickup_library_id");
1074
        cy.vueSelectByIndex("pickup_library_id", 0);
1075
1076
        cy.vueSelectShouldBeEnabled("booking_item_id");
1077
        cy.vueSelectByIndex("booking_item_id", 1);
1078
        cy.wait("@getCirculationRules");
1079
1080
        cy.get("#booking_period").should("not.be.disabled");
1081
        const startDate = dayjs().add(5, "day");
1082
        const endDate = dayjs().add(10, "day");
1083
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
1084
1085
        cy.get('button[form="form-booking"][type="submit"]')
1086
            .should("not.be.disabled")
1087
            .click();
1088
        cy.wait("@serverError");
1089
1090
        cy.get("booking-modal-island .modal .alert-danger").should("exist");
1091
        cy.get("booking-modal-island .modal").should("be.visible");
1092
    });
895
});
1093
});
(-)a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts (-18 / +35 lines)
Lines 554-579 describe("Booking Modal Date Picker Tests", () => { Link Here
554
            });
554
            });
555
        });
555
        });
556
556
557
        // For dates in the currently visible month, also verify the DOM class
557
        // For dates in the currently visible month, also verify the DOM class.
558
        expectedBoldDates
558
        // Query calendarContainer directly to avoid navigation races (the
559
            .filter(d => d.month() === clearZoneStart.month())
559
        // booking modal's onMonthChange handler can jump the calendar away).
560
            .forEach(boldDate => {
560
        cy.get("@dateTestFlatpickr").should($el => {
561
                cy.get("@dateTestFlatpickr")
561
            const fp = $el[0]._flatpickr;
562
                    .getFlatpickrDate(boldDate.toDate())
562
            // Ensure calendar shows the start date's month
563
                    .should("have.class", "booking-loan-boundary");
563
            if (fp.currentMonth !== clearZoneStart.month() || fp.currentYear !== clearZoneStart.year()) {
564
                cy.log(
564
                fp.jumpToDate(clearZoneStart.toDate());
565
                    `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'booking-loan-boundary' class (bold)`
565
                throw new Error("Jumped to target month, retrying assertion");
566
                );
566
            }
567
            });
568
567
569
        // Verify that only expected dates are bold in the current view
568
            expectedBoldDates
570
        cy.get(".flatpickr-day.booking-loan-boundary").each($el => {
569
                .filter(d => d.month() === clearZoneStart.month())
571
            const ariaLabel = $el.attr("aria-label");
570
                .forEach(boldDate => {
572
            const date = dayjs(ariaLabel, "MMMM D, YYYY");
571
                    const label = `${boldDate.format("MMMM D, YYYY")}`;
573
            const isExpected = expectedBoldDates.some(expected =>
572
                    const el = fp.calendarContainer.querySelector(
574
                date.isSame(expected, "day")
573
                        `.flatpickr-day[aria-label="${label}"]`
574
                    );
575
                    expect(el, `Day ${boldDate.format("YYYY-MM-DD")} should exist`).to.not.be.null;
576
                    expect(
577
                        el.classList.contains("booking-loan-boundary"),
578
                        `Day ${boldDate.format("YYYY-MM-DD")} should have booking-loan-boundary class`
579
                    ).to.be.true;
580
                });
581
582
            // Verify no unexpected bold dates in the current view
583
            const boldElements = fp.calendarContainer.querySelectorAll(
584
                ".flatpickr-day.booking-loan-boundary"
575
            );
585
            );
576
            expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true;
586
            boldElements.forEach(boldEl => {
587
                const ariaLabel = boldEl.getAttribute("aria-label");
588
                const date = dayjs(ariaLabel, "MMMM D, YYYY");
589
                const isExpected = expectedBoldDates.some(expected =>
590
                    date.isSame(expected, "day")
591
                );
592
                expect(isExpected, `Unexpected bold date: ${ariaLabel}`).to.be.true;
593
            });
577
        });
594
        });
578
595
579
        cy.log(
596
        cy.log(
(-)a/t/cypress/integration/Circulation/bookingsModal_spec.ts (-873 lines)
Lines 1-873 Link Here
1
import dayjs = require("dayjs");
2
3
interface BookingTestContext {
4
    biblio: any;
5
    patron: any;
6
    bookingsToCleanup: Array<{ booking_id: number }>;
7
}
8
9
interface BookingInterceptOverrides {
10
    bookableItems?: Cypress.RouteHandler;
11
    loadBookings?: Cypress.RouteHandler;
12
    loadCheckouts?: Cypress.RouteHandler;
13
    pickupLocations?: Cypress.RouteHandler;
14
    searchPatrons?: Cypress.RouteHandler;
15
    circulationRules?: Cypress.RouteHandler;
16
}
17
18
const ensureBookableInventory = (biblio: any) => {
19
    const itemTypeId = biblio.item_type.item_type_id;
20
    const itemNumbers = biblio.items.map((item: any) => item.item_id);
21
22
    const updateItemType = cy.task("query", {
23
        sql: "UPDATE itemtypes SET bookable=1 WHERE itemtype=?",
24
        values: [itemTypeId],
25
    });
26
27
    if (!itemNumbers.length) {
28
        return updateItemType;
29
    }
30
31
    const placeholders = itemNumbers.map(() => "?").join(",");
32
    return updateItemType.then(() =>
33
        cy.task("query", {
34
            sql: `UPDATE items SET bookable=1 WHERE itemnumber IN (${placeholders})`,
35
            values: itemNumbers,
36
        })
37
    );
38
};
39
40
const ensureBookingCapacity = ({
41
    libraryId,
42
    categoryId,
43
    itemTypeId,
44
}: {
45
    libraryId: string;
46
    categoryId: string;
47
    itemTypeId: string;
48
}) => {
49
    const rules = [
50
        { name: "issuelength", value: 7 },
51
        { name: "renewalsallowed", value: 1 },
52
        { name: "renewalperiod", value: 7 },
53
        { name: "bookings_lead_period", value: 0 },
54
        { name: "bookings_trail_period", value: 0 },
55
    ];
56
    const ruleNames = rules.map(rule => rule.name);
57
    const deletePlaceholders = ruleNames.map(() => "?").join(",");
58
    const insertPlaceholders = rules.map(() => "(?, ?, ?, ?, ?)").join(",");
59
    const insertValues: Array<string | number> = [];
60
    rules.forEach(rule => {
61
        insertValues.push(
62
            libraryId,
63
            categoryId,
64
            itemTypeId,
65
            rule.name,
66
            String(rule.value)
67
        );
68
    });
69
70
    return cy
71
        .task("query", {
72
            sql: `DELETE FROM circulation_rules WHERE branchcode=? AND categorycode=? AND itemtype=? AND rule_name IN (${deletePlaceholders})`,
73
            values: [libraryId, categoryId, itemTypeId, ...ruleNames],
74
        })
75
        .then(() =>
76
            cy.task("query", {
77
                sql: `INSERT INTO circulation_rules (branchcode, categorycode, itemtype, rule_name, rule_value) VALUES ${insertPlaceholders}`,
78
                values: insertValues,
79
            })
80
        );
81
};
82
83
const deleteBookings = (bookingIds: number[]) => {
84
    if (!bookingIds.length) {
85
        return cy.wrap(null);
86
    }
87
88
    const placeholders = bookingIds.map(() => "?").join(",");
89
    return cy.task("query", {
90
        sql: `DELETE FROM bookings WHERE booking_id IN (${placeholders})`,
91
        values: bookingIds,
92
    });
93
};
94
95
const waitForBookingIslandReady = () => {
96
    cy.get("booking-modal-island").should("exist");
97
    cy.get("booking-modal-island .modal").should("exist");
98
    return cy.wait(100);
99
};
100
101
const ensurePatronSearchQueryBuilder = () =>
102
    cy.window().then(win => {
103
        const globalWin = win as Window & {
104
            buildPatronSearchQuery?: (
105
                term: string,
106
                options?: Record<string, any>
107
            ) => any;
108
        };
109
        if (typeof globalWin.buildPatronSearchQuery === "function") return;
110
        globalWin.buildPatronSearchQuery = (
111
            term: string,
112
            options: Record<string, any> = {}
113
        ) => {
114
            if (!term) return [];
115
            const table_prefix = options.table_prefix || "me";
116
            const search_fields = options.search_fields
117
                ? options.search_fields
118
                      .split(",")
119
                      .map((field: string) => field.trim())
120
                : ["surname", "firstname", "cardnumber", "userid"];
121
122
            const queries: Record<string, unknown>[] = [];
123
            search_fields.forEach(field => {
124
                queries.push({
125
                    [`${table_prefix}.${field}`]: {
126
                        like: `%${term}%`,
127
                    },
128
                });
129
            });
130
131
            return [{ "-or": queries }];
132
        };
133
    });
134
135
const prepareBookingModalPage = () =>
136
    waitForBookingIslandReady().then(() => ensurePatronSearchQueryBuilder());
137
138
const setDateRange = (startDate: dayjs.Dayjs, endDate: dayjs.Dayjs) => {
139
    cy.get(".modal.show #booking_period").click({ force: true });
140
    cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible");
141
142
    const startDateStr = startDate.format("MMMM D, YYYY");
143
    cy.get(".flatpickr-calendar")
144
        .find(`.flatpickr-day[aria-label="${startDateStr}"]`)
145
        .click({ force: true });
146
147
    const endDateStr = endDate.format("MMMM D, YYYY");
148
    cy.get(".flatpickr-calendar")
149
        .find(`.flatpickr-day[aria-label="${endDateStr}"]`)
150
        .click({ force: true });
151
152
    cy.wait(500);
153
};
154
155
const interceptBookingModalData = (
156
    biblionumber: number | string,
157
    overrides: BookingInterceptOverrides = {}
158
) => {
159
    const itemsUrl = `/api/v1/biblios/${biblionumber}/items*`;
160
    if (overrides.bookableItems) {
161
        cy.intercept("GET", itemsUrl, overrides.bookableItems).as(
162
            "bookableItems"
163
        );
164
    } else {
165
        cy.intercept("GET", itemsUrl).as("bookableItems");
166
    }
167
168
    const bookingsUrl = `/api/v1/biblios/${biblionumber}/bookings*`;
169
    if (overrides.loadBookings) {
170
        cy.intercept("GET", bookingsUrl, overrides.loadBookings).as(
171
            "loadBookings"
172
        );
173
    } else {
174
        cy.intercept("GET", bookingsUrl).as("loadBookings");
175
    }
176
177
    const checkoutsUrl = `/api/v1/biblios/${biblionumber}/checkouts*`;
178
    if (overrides.loadCheckouts) {
179
        cy.intercept("GET", checkoutsUrl, overrides.loadCheckouts).as(
180
            "loadCheckouts"
181
        );
182
    } else {
183
        cy.intercept("GET", checkoutsUrl).as("loadCheckouts");
184
    }
185
186
    const pickupsUrl = `/api/v1/biblios/${biblionumber}/pickup_locations*`;
187
    if (overrides.pickupLocations) {
188
        cy.intercept("GET", pickupsUrl, overrides.pickupLocations).as(
189
            "pickupLocations"
190
        );
191
    } else {
192
        cy.intercept("GET", pickupsUrl).as("pickupLocations");
193
    }
194
195
    const searchUrl = /\/api\/v1\/patrons\?.*q=.*/;
196
    if (overrides.searchPatrons) {
197
        cy.intercept("GET", searchUrl, overrides.searchPatrons).as(
198
            "searchPatrons"
199
        );
200
    } else {
201
        cy.intercept("GET", searchUrl).as("searchPatrons");
202
    }
203
204
    const rulesUrl = /\/api\/v1\/circulation_rules.*/;
205
    if (overrides.circulationRules) {
206
        cy.intercept("GET", rulesUrl, overrides.circulationRules).as(
207
            "circulationRules"
208
        );
209
    } else {
210
        cy.intercept("GET", rulesUrl).as("circulationRules");
211
    }
212
};
213
214
const openBookingModalFromList = (biblionumber: number | string) => {
215
    cy.window().its("openBookingModal").should("be.a", "function");
216
    cy.contains("button[data-booking-modal]", /Place booking/i).should(
217
        "be.visible"
218
    );
219
220
    cy.window().then(win => {
221
        const globalWin = win as Window & {
222
            openBookingModal?: (props: Record<string, any>) => void;
223
        };
224
        if (typeof globalWin.openBookingModal === "function") {
225
            globalWin.openBookingModal({ biblionumber: String(biblionumber) });
226
        } else {
227
            throw new Error("window.openBookingModal is not available");
228
        }
229
    });
230
231
    cy.wait(["@bookableItems", "@loadBookings"], { timeout: 20000 });
232
    cy.get(".modal", { timeout: 15000 })
233
        .should("exist")
234
        .and("have.class", "show")
235
        .and("be.visible");
236
};
237
238
const selectVsOption = (text: string) => {
239
    cy.get(".modal.show .vs__dropdown-menu li:not(.vs__no-options)", {
240
        timeout: 10000,
241
    })
242
        .contains(text)
243
        .scrollIntoView()
244
        .click({ force: true });
245
};
246
247
const selectPatronAndInventory = (
248
    patron: any,
249
    pickupLibrary: any,
250
    item: any,
251
    options: { skipItemSelection?: boolean } = {}
252
) => {
253
    const searchTerm = patron.cardnumber.slice(0, 4);
254
255
    cy.get(".modal.show #booking_patron")
256
        .clear()
257
        .type(searchTerm, { delay: 0 });
258
    cy.wait("@searchPatrons");
259
    cy.wait(200);
260
    selectVsOption(patron.cardnumber);
261
262
    cy.wait("@pickupLocations");
263
    cy.get(".modal.show #pickup_library_id").should(
264
        "not.have.attr",
265
        "disabled"
266
    );
267
    cy.get(".modal.show #pickup_library_id").click({ force: true });
268
    cy.wait(200);
269
    selectVsOption(pickupLibrary.name);
270
271
    if (!options.skipItemSelection) {
272
        cy.get(".modal.show #booking_item_id").click({ force: true });
273
        cy.wait(200);
274
        selectVsOption(item.external_id);
275
        cy.wait("@circulationRules");
276
    }
277
};
278
279
describe("BookingModal integration", () => {
280
    // Prevent app-level warnings (e.g. from console.warn → error in e2e.js) from failing tests
281
    Cypress.on("uncaught:exception", () => false);
282
283
    beforeEach(function (this: BookingTestContext) {
284
        cy.login();
285
        cy.title().should("eq", "Koha staff interface");
286
287
        return cy
288
            .task("insertSampleBiblio", { item_count: 1 })
289
            .then(biblio => {
290
                this.biblio = biblio;
291
                return ensureBookableInventory(biblio);
292
            })
293
            .then(() =>
294
                cy.task("insertSamplePatron", {
295
                    library: this.biblio.libraries[0],
296
                })
297
            )
298
            .then(patron => {
299
                this.patron = patron;
300
                this.bookingsToCleanup = [];
301
                return ensureBookingCapacity({
302
                    libraryId: this.biblio.libraries[0].library_id,
303
                    categoryId: patron.patron.category_id,
304
                    itemTypeId: this.biblio.item_type.item_type_id,
305
                });
306
            });
307
    });
308
309
    afterEach(function (this: BookingTestContext) {
310
        const bookingIds =
311
            this.bookingsToCleanup?.map(booking => booking.booking_id) || [];
312
        if (bookingIds.length) {
313
            deleteBookings(bookingIds);
314
        }
315
316
        const cleanupTargets = [];
317
        if (this.patron) cleanupTargets.push(this.patron);
318
        if (this.biblio) cleanupTargets.push(this.biblio);
319
        if (cleanupTargets.length) {
320
            cy.task("deleteSampleObjects", cleanupTargets);
321
        }
322
    });
323
324
    it("Creates a booking", function (this: BookingTestContext) {
325
        const ctx = this as BookingTestContext;
326
        const biblionumber = ctx.biblio.biblio.biblio_id;
327
        const patron = ctx.patron.patron;
328
        const pickupLibrary = ctx.biblio.libraries[0];
329
        const item = ctx.biblio.items[0];
330
        const startDate = dayjs().add(3, "day").startOf("day");
331
        const endDate = startDate.add(2, "day");
332
333
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
334
        cy.get("#bookings_table").should("exist");
335
        prepareBookingModalPage();
336
337
        interceptBookingModalData(biblionumber);
338
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
339
340
        openBookingModalFromList(biblionumber);
341
342
        cy.get(".modal-title").should("contain", "Place booking");
343
        cy.contains(".step-header", "Select Patron").should("exist");
344
        cy.get("button[form='form-booking']").should("be.disabled");
345
        cy.get("#pickup_library_id").should("have.attr", "disabled");
346
347
        selectPatronAndInventory(patron, pickupLibrary, item);
348
        cy.get("#booking_period").should("not.be.disabled");
349
        setDateRange(startDate, endDate);
350
        cy.wait(1000);
351
352
        cy.get("button[form='form-booking']", { timeout: 15000 })
353
            .should("not.be.disabled")
354
            .contains("Place booking")
355
            .click();
356
357
        cy.wait("@createBooking").then(({ response }) => {
358
            expect(response?.statusCode).to.eq(201);
359
            if (response?.body) {
360
                ctx.bookingsToCleanup.push(response.body);
361
            }
362
        });
363
364
        cy.get("body").should("not.have.class", "modal-open");
365
        cy.contains("#bookings_table tbody tr", item.external_id).should(
366
            "exist"
367
        );
368
        cy.contains("#bookings_table tbody tr", patron.cardnumber).should(
369
            "exist"
370
        );
371
    });
372
373
    it("Updates a booking", function (this: BookingTestContext) {
374
        const ctx = this as BookingTestContext;
375
        const biblionumber = ctx.biblio.biblio.biblio_id;
376
        const patron = ctx.patron.patron;
377
        const pickupLibrary = ctx.biblio.libraries[0];
378
        const item = ctx.biblio.items[0];
379
        const initialStart = dayjs().add(5, "day").startOf("day");
380
        const initialEnd = initialStart.add(1, "day");
381
        const updatedStart = initialStart.add(3, "day");
382
        const updatedEnd = updatedStart.add(2, "day");
383
384
        cy.task("apiPost", {
385
            endpoint: "/api/v1/bookings",
386
            body: {
387
                biblio_id: biblionumber,
388
                patron_id: patron.patron_id,
389
                pickup_library_id: pickupLibrary.library_id,
390
                item_id: item.item_id,
391
                start_date: initialStart.toISOString(),
392
                end_date: initialEnd.toISOString(),
393
            },
394
        }).then(booking => {
395
            ctx.bookingsToCleanup.push(booking);
396
            cy.wrap(booking).as("existingBookingRecord");
397
        });
398
399
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
400
        cy.get("#bookings_table").should("exist");
401
        prepareBookingModalPage();
402
403
        cy.get("@existingBookingRecord").then((booking: any) => {
404
            interceptBookingModalData(biblionumber);
405
            cy.intercept("GET", /\/api\/v1\/patrons\/\d+/).as(
406
                "prefillPatron"
407
            );
408
            cy.intercept("GET", /\/api\/v1\/circulation_rules.*/).as(
409
                "circulationRules"
410
            );
411
            cy.intercept("PUT", `/api/v1/bookings/${booking.booking_id}`).as(
412
                "updateBooking"
413
            );
414
415
            cy.contains("#bookings_table tbody tr", `(${booking.booking_id})`, {
416
                timeout: 10000,
417
            })
418
                .should("exist")
419
                .find("button.edit-action")
420
                .click();
421
        });
422
423
        cy.wait(["@bookableItems", "@loadBookings"]);
424
        cy.wait("@prefillPatron").its("response.statusCode").should("eq", 200);
425
        cy.wait("@pickupLocations", { timeout: 20000 });
426
        cy.wait("@circulationRules", { timeout: 20000 });
427
        cy.get(".modal.show", { timeout: 10000 }).should("be.visible");
428
429
        cy.get(".modal-title").should("contain", "Edit booking");
430
        cy.get("button[form='form-booking']").should(
431
            "contain",
432
            "Update booking"
433
        );
434
        cy.contains(".vs__selected", patron.cardnumber).should("exist");
435
        cy.contains(".vs__selected", pickupLibrary.name).should("exist");
436
        cy.contains(".vs__selected", item.external_id).should("exist");
437
438
        cy.get("#booking_period").then($input => {
439
            const fp = ($input[0] as any)?._flatpickr;
440
            expect(fp?.selectedDates?.length).to.eq(2);
441
            expect(dayjs(fp.selectedDates[0]).isSame(initialStart, "day")).to.be
442
                .true;
443
        });
444
445
        setDateRange(updatedStart, updatedEnd);
446
        cy.wait(1000);
447
448
        cy.get("button[form='form-booking']", { timeout: 30000 })
449
            .should("not.be.disabled")
450
            .and("contain", "Update booking")
451
            .click();
452
453
        cy.wait("@updateBooking").its("response.statusCode").should("eq", 200);
454
455
        cy.get("@existingBookingRecord").then((booking: any) => {
456
            cy.task("query", {
457
                sql: "SELECT start_date, end_date FROM bookings WHERE booking_id=?",
458
                values: [booking.booking_id],
459
            }).then(rows => {
460
                expect(rows).to.have.length(1);
461
                expect(dayjs(rows[0].start_date).isSame(updatedStart, "day")).to
462
                    .be.true;
463
                expect(dayjs(rows[0].end_date).isSame(updatedEnd, "day")).to.be
464
                    .true;
465
            });
466
        });
467
    });
468
469
    it("Resets the modal state after canceling", function (this: BookingTestContext) {
470
        const ctx = this as BookingTestContext;
471
        const biblionumber = ctx.biblio.biblio.biblio_id;
472
        const patron = ctx.patron.patron;
473
        const pickupLibrary = ctx.biblio.libraries[0];
474
        const item = ctx.biblio.items[0];
475
        const startDate = dayjs().add(2, "day").startOf("day");
476
        const endDate = startDate.add(1, "day");
477
478
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
479
        cy.get("#bookings_table").should("exist");
480
        prepareBookingModalPage();
481
482
        interceptBookingModalData(biblionumber);
483
484
        openBookingModalFromList(biblionumber);
485
        cy.get("button[form='form-booking']").should("be.disabled");
486
        cy.get("#pickup_library_id").should("have.attr", "disabled");
487
488
        selectPatronAndInventory(patron, pickupLibrary, item);
489
        setDateRange(startDate, endDate);
490
        cy.wait(1000);
491
492
        cy.get("button[form='form-booking']", { timeout: 15000 }).should(
493
            "not.be.disabled"
494
        );
495
496
        cy.contains(".modal.show button", /Cancel/i).click();
497
        cy.get(".modal.show").should("not.exist");
498
        cy.get("body").should("not.have.class", "modal-open");
499
500
        openBookingModalFromList(biblionumber);
501
        cy.get(".modal.show").within(() => {
502
            cy.get("#booking_patron").should("have.value", "");
503
            cy.get("#pickup_library_id").should("have.attr", "disabled");
504
            cy.get("#booking_period").should("have.value", "");
505
            cy.get("button[form='form-booking']").should("be.disabled");
506
        });
507
    });
508
509
    it("Shows capacity warning for zero-day circulation rules", function (this: BookingTestContext) {
510
        const ctx = this as BookingTestContext;
511
        const biblionumber = ctx.biblio.biblio.biblio_id;
512
        const patron = ctx.patron.patron;
513
        const pickupLibrary = ctx.biblio.libraries[0];
514
        const item = ctx.biblio.items[0];
515
516
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
517
        cy.get("#bookings_table").should("exist");
518
        prepareBookingModalPage();
519
520
        interceptBookingModalData(biblionumber, {
521
            circulationRules: req => {
522
                req.reply({
523
                    statusCode: 200,
524
                    body: [
525
                        {
526
                            library_id: pickupLibrary.library_id,
527
                            item_type_id: item.item_type_id,
528
                            patron_category_id: patron.category_id,
529
                            issuelength: 0,
530
                            renewalsallowed: 0,
531
                            renewalperiod: 0,
532
                            bookings_lead_period: 0,
533
                            bookings_trail_period: 0,
534
                            calculated_period_days: 0,
535
                        },
536
                    ],
537
                });
538
            },
539
        });
540
541
        openBookingModalFromList(biblionumber);
542
        selectPatronAndInventory(patron, pickupLibrary, item);
543
544
        cy.get(".modal.show .alert-warning")
545
            .scrollIntoView()
546
            .should("be.visible")
547
            .and("contain", "Bookings are not permitted for this combination");
548
        cy.get("#booking_period").should("be.disabled");
549
        cy.get("button[form='form-booking']").should("be.disabled");
550
    });
551
552
    it("Creates a booking without selecting a specific item", function (this: BookingTestContext) {
553
        const ctx = this as BookingTestContext;
554
        const biblionumber = ctx.biblio.biblio.biblio_id;
555
        const patron = ctx.patron.patron;
556
        const pickupLibrary = ctx.biblio.libraries[0];
557
        const startDate = dayjs().add(3, "day").startOf("day");
558
        const endDate = startDate.add(2, "day");
559
560
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
561
        cy.get("#bookings_table").should("exist");
562
        prepareBookingModalPage();
563
564
        interceptBookingModalData(biblionumber);
565
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
566
567
        openBookingModalFromList(biblionumber);
568
569
        selectPatronAndInventory(patron, pickupLibrary, null, {
570
            skipItemSelection: true,
571
        });
572
        cy.get("#booking_period").should("not.be.disabled");
573
        setDateRange(startDate, endDate);
574
        cy.wait(1000);
575
576
        cy.get("button[form='form-booking']", { timeout: 15000 })
577
            .should("not.be.disabled")
578
            .contains("Place booking")
579
            .click();
580
581
        cy.wait("@createBooking").then(({ response }) => {
582
            expect(response?.statusCode).to.eq(201);
583
            // With only 1 bookable item the modal auto-assigns item_id client-side.
584
            // With 2+ items the server performs optimal selection.
585
            // Either way the response must contain a resolved item_id.
586
            expect(response?.body.item_id).to.not.be.null;
587
            if (response?.body) {
588
                ctx.bookingsToCleanup.push(response.body);
589
            }
590
        });
591
592
        cy.get("body").should("not.have.class", "modal-open");
593
    });
594
595
    it("Shows error message when booking creation fails with 400", function (this: BookingTestContext) {
596
        const ctx = this as BookingTestContext;
597
        const biblionumber = ctx.biblio.biblio.biblio_id;
598
        const patron = ctx.patron.patron;
599
        const pickupLibrary = ctx.biblio.libraries[0];
600
        const item = ctx.biblio.items[0];
601
        const startDate = dayjs().add(3, "day").startOf("day");
602
        const endDate = startDate.add(2, "day");
603
604
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
605
        cy.get("#bookings_table").should("exist");
606
        prepareBookingModalPage();
607
608
        interceptBookingModalData(biblionumber);
609
        cy.intercept("POST", "/api/v1/bookings", {
610
            statusCode: 400,
611
            body: {
612
                error: "Invalid booking period",
613
            },
614
        }).as("createBooking");
615
616
        openBookingModalFromList(biblionumber);
617
        selectPatronAndInventory(patron, pickupLibrary, item);
618
        setDateRange(startDate, endDate);
619
        cy.wait(1000);
620
621
        cy.get("button[form='form-booking']", { timeout: 15000 })
622
            .should("not.be.disabled")
623
            .click();
624
625
        cy.wait("@createBooking");
626
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
627
            .scrollIntoView()
628
            .should("be.visible");
629
        cy.get(".modal.show").should("exist");
630
    });
631
632
    it("Shows error message when booking creation fails with 409 conflict", function (this: BookingTestContext) {
633
        const ctx = this as BookingTestContext;
634
        const biblionumber = ctx.biblio.biblio.biblio_id;
635
        const patron = ctx.patron.patron;
636
        const pickupLibrary = ctx.biblio.libraries[0];
637
        const item = ctx.biblio.items[0];
638
        const startDate = dayjs().add(3, "day").startOf("day");
639
        const endDate = startDate.add(2, "day");
640
641
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
642
        cy.get("#bookings_table").should("exist");
643
        prepareBookingModalPage();
644
645
        interceptBookingModalData(biblionumber);
646
        cy.intercept("POST", "/api/v1/bookings", {
647
            statusCode: 409,
648
            body: {
649
                error: "Booking conflict detected",
650
            },
651
        }).as("createBooking");
652
653
        openBookingModalFromList(biblionumber);
654
        selectPatronAndInventory(patron, pickupLibrary, item);
655
        setDateRange(startDate, endDate);
656
        cy.wait(1000);
657
658
        cy.get("button[form='form-booking']", { timeout: 15000 })
659
            .should("not.be.disabled")
660
            .click();
661
662
        cy.wait("@createBooking");
663
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
664
            .scrollIntoView()
665
            .should("be.visible");
666
        cy.get(".modal.show").should("exist");
667
    });
668
669
    it("Shows error message when booking creation fails with 500", function (this: BookingTestContext) {
670
        const ctx = this as BookingTestContext;
671
        const biblionumber = ctx.biblio.biblio.biblio_id;
672
        const patron = ctx.patron.patron;
673
        const pickupLibrary = ctx.biblio.libraries[0];
674
        const item = ctx.biblio.items[0];
675
        const startDate = dayjs().add(3, "day").startOf("day");
676
        const endDate = startDate.add(2, "day");
677
678
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
679
        cy.get("#bookings_table").should("exist");
680
        prepareBookingModalPage();
681
682
        interceptBookingModalData(biblionumber);
683
        cy.intercept("POST", "/api/v1/bookings", {
684
            statusCode: 500,
685
            body: {
686
                error: "Internal server error",
687
            },
688
        }).as("createBooking");
689
690
        openBookingModalFromList(biblionumber);
691
        selectPatronAndInventory(patron, pickupLibrary, item);
692
        setDateRange(startDate, endDate);
693
        cy.wait(1000);
694
695
        cy.get("button[form='form-booking']", { timeout: 15000 })
696
            .should("not.be.disabled")
697
            .click();
698
699
        cy.wait("@createBooking");
700
        cy.get(".modal.show .alert-danger, .modal.show .alert-warning")
701
            .scrollIntoView()
702
            .should("be.visible");
703
        cy.get(".modal.show").should("exist");
704
    });
705
706
    it("Shows message when patron search returns no results", function (this: BookingTestContext) {
707
        const ctx = this as BookingTestContext;
708
        const biblionumber = ctx.biblio.biblio.biblio_id;
709
710
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
711
        cy.get("#bookings_table").should("exist");
712
        prepareBookingModalPage();
713
714
        interceptBookingModalData(biblionumber, {
715
            searchPatrons: {
716
                statusCode: 200,
717
                body: [],
718
                headers: {
719
                    "X-Base-Total-Count": "0",
720
                    "X-Total-Count": "0",
721
                },
722
            },
723
        });
724
725
        openBookingModalFromList(biblionumber);
726
727
        cy.get(".modal.show #booking_patron")
728
            .clear()
729
            .type("NONEXISTENT", { delay: 0 });
730
        cy.wait("@searchPatrons");
731
        cy.wait(500);
732
733
        cy.get(".modal.show .vs__no-options").should("be.visible");
734
    });
735
736
    it("Shows no options when no pickup locations are available", function (this: BookingTestContext) {
737
        const ctx = this as BookingTestContext;
738
        const biblionumber = ctx.biblio.biblio.biblio_id;
739
        const patron = ctx.patron.patron;
740
741
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
742
        cy.get("#bookings_table").should("exist");
743
        prepareBookingModalPage();
744
745
        interceptBookingModalData(biblionumber, {
746
            pickupLocations: {
747
                statusCode: 200,
748
                body: [],
749
                headers: {
750
                    "X-Base-Total-Count": "0",
751
                    "X-Total-Count": "0",
752
                },
753
            },
754
        });
755
756
        openBookingModalFromList(biblionumber);
757
758
        const searchTerm = patron.cardnumber.slice(0, 4);
759
        cy.get(".modal.show #booking_patron")
760
            .clear()
761
            .type(searchTerm, { delay: 0 });
762
        cy.wait("@searchPatrons");
763
        cy.wait(200);
764
        selectVsOption(patron.cardnumber);
765
766
        cy.wait("@pickupLocations");
767
        cy.wait(500);
768
769
        cy.get(".modal.show #pickup_library_id").click({ force: true });
770
        cy.wait(200);
771
        cy.get(".modal.show .vs__no-options").should("be.visible");
772
    });
773
774
    it("Shows conflicting dates when an item is already booked", function (this: BookingTestContext) {
775
        const ctx = this as BookingTestContext;
776
        const biblionumber = ctx.biblio.biblio.biblio_id;
777
        const patron = ctx.patron.patron;
778
        const pickupLibrary = ctx.biblio.libraries[0];
779
        const item = ctx.biblio.items[0];
780
        const conflictStart = dayjs().add(5, "day").startOf("day");
781
        const conflictEnd = conflictStart.add(2, "day");
782
783
        cy.task("apiPost", {
784
            endpoint: "/api/v1/bookings",
785
            body: {
786
                biblio_id: biblionumber,
787
                patron_id: patron.patron_id,
788
                pickup_library_id: pickupLibrary.library_id,
789
                item_id: item.item_id,
790
                start_date: conflictStart.toISOString(),
791
                end_date: conflictEnd.toISOString(),
792
            },
793
        }).then(booking => {
794
            ctx.bookingsToCleanup.push(booking);
795
        });
796
797
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
798
        cy.get("#bookings_table").should("exist");
799
        prepareBookingModalPage();
800
801
        interceptBookingModalData(biblionumber);
802
803
        openBookingModalFromList(biblionumber);
804
        selectPatronAndInventory(patron, pickupLibrary, item);
805
806
        cy.get(".modal.show #booking_period").click({ force: true });
807
        cy.get(".flatpickr-calendar", { timeout: 5000 }).should("be.visible");
808
809
        const conflictDateStr = conflictStart.format("MMMM D, YYYY");
810
        cy.get(".flatpickr-calendar")
811
            .find(`.flatpickr-day[aria-label="${conflictDateStr}"]`)
812
            .should("have.class", "flatpickr-disabled");
813
    });
814
815
    it("Creates a booking when item type is auto-selected", function (this: BookingTestContext) {
816
        const ctx = this as BookingTestContext;
817
        const biblionumber = ctx.biblio.biblio.biblio_id;
818
        const patron = ctx.patron.patron;
819
        const pickupLibrary = ctx.biblio.libraries[0];
820
        const startDate = dayjs().add(3, "day").startOf("day");
821
        const endDate = startDate.add(2, "day");
822
823
        cy.visit(`/cgi-bin/koha/bookings/list.pl?biblionumber=${biblionumber}`);
824
        cy.get("#bookings_table").should("exist");
825
        prepareBookingModalPage();
826
827
        interceptBookingModalData(biblionumber);
828
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
829
830
        openBookingModalFromList(biblionumber);
831
832
        const searchTerm = patron.cardnumber.slice(0, 4);
833
        cy.get(".modal.show #booking_patron")
834
            .clear()
835
            .type(searchTerm, { delay: 0 });
836
        cy.wait("@searchPatrons");
837
        cy.wait(200);
838
        selectVsOption(patron.cardnumber);
839
840
        cy.wait("@pickupLocations");
841
        cy.get(".modal.show #pickup_library_id").should(
842
            "not.have.attr",
843
            "disabled"
844
        );
845
        cy.get(".modal.show #pickup_library_id").click({ force: true });
846
        cy.wait(200);
847
        selectVsOption(pickupLibrary.name);
848
849
        cy.wait("@circulationRules");
850
        cy.wait(500);
851
852
        cy.get("#booking_period").should("not.be.disabled");
853
        setDateRange(startDate, endDate);
854
        cy.wait(1000);
855
856
        cy.get("button[form='form-booking']", { timeout: 15000 })
857
            .should("not.be.disabled")
858
            .contains("Place booking")
859
            .click();
860
861
        cy.wait("@createBooking").then(({ response }) => {
862
            expect(response?.statusCode).to.eq(201);
863
            // With auto-selected item type and 1 item, the modal auto-assigns.
864
            // The response must contain a resolved item_id.
865
            expect(response?.body.item_id).to.not.be.null;
866
            if (response?.body) {
867
                ctx.bookingsToCleanup.push(response.body);
868
            }
869
        });
870
871
        cy.get("body").should("not.have.class", "modal-open");
872
    });
873
});
(-)a/t/cypress/support/flatpickr.js (-182 lines)
Lines 196-235 Cypress.Commands.add( Link Here
196
    }
196
    }
197
);
197
);
198
198
199
/**
200
 * Helper to close an open Flatpickr calendar.
201
 */
202
Cypress.Commands.add("closeFlatpickr", { prevSubject: true }, subject => {
203
    return cy.wrap(subject).then($input => {
204
        // Wait for flatpickr to be initialized and then close it
205
        return cy
206
            .wrap($input)
207
            .should($el => {
208
                expect($el[0]).to.have.property("_flatpickr");
209
            })
210
            .then(() => {
211
                $input[0]._flatpickr.close();
212
                return cy.wrap(subject);
213
            });
214
    });
215
});
216
217
/**
218
 * Helper to navigate to a specific month and year in a Flatpickr calendar.
219
 */
220
Cypress.Commands.add(
221
    "navigateToFlatpickrMonth",
222
    { prevSubject: true },
223
    (subject, targetDate, timeout = 10000) => {
224
        return ensureCalendarIsOpen(cy.wrap(subject), timeout).then($input => {
225
            const dayjsDate = dayjs(targetDate);
226
            return navigateToMonthAndYear(dayjsDate, $input, timeout).then(() =>
227
                cy.wrap($input)
228
            );
229
        });
230
    }
231
);
232
233
/**
199
/**
234
 * Helper to get the Flatpickr mode ('single', 'range', 'multiple').
200
 * Helper to get the Flatpickr mode ('single', 'range', 'multiple').
235
 */
201
 */
Lines 408-415 Cypress.Commands.add( Link Here
408
    }
374
    }
409
);
375
);
410
376
411
// --- Enhanced Assertion Commands ---
412
413
/**
377
/**
414
 * Helper to get a specific Flatpickr day element by its date.
378
 * Helper to get a specific Flatpickr day element by its date.
415
 */
379
 */
Lines 445-595 Cypress.Commands.add( Link Here
445
    }
409
    }
446
);
410
);
447
411
448
/**
449
 * Assertion helper to check if a Flatpickr date is disabled.
450
 */
451
Cypress.Commands.add(
452
    "flatpickrDateShouldBeDisabled",
453
    { prevSubject: true },
454
    (subject, date) => {
455
        return cy
456
            .wrap(subject)
457
            .getFlatpickrDate(date)
458
            .should("have.class", "flatpickr-disabled")
459
            .then(() => cy.wrap(subject));
460
    }
461
);
462
463
/**
464
 * Assertion helper to check if a Flatpickr date is enabled.
465
 */
466
Cypress.Commands.add(
467
    "flatpickrDateShouldBeEnabled",
468
    { prevSubject: true },
469
    (subject, date) => {
470
        return cy
471
            .wrap(subject)
472
            .getFlatpickrDate(date)
473
            .should("not.have.class", "flatpickr-disabled")
474
            .then(() => cy.wrap(subject));
475
    }
476
);
477
478
/**
479
 * Assertion helper to check if a Flatpickr date is selected.
480
 */
481
Cypress.Commands.add(
482
    "flatpickrDateShouldBeSelected",
483
    { prevSubject: true },
484
    (subject, date) => {
485
        return cy
486
            .wrap(subject)
487
            .getFlatpickrDate(date)
488
            .should("have.class", "selected")
489
            .then(() => cy.wrap(subject));
490
    }
491
);
492
493
/**
494
 * Assertion helper to check if a Flatpickr date is not selected.
495
 */
496
Cypress.Commands.add(
497
    "flatpickrDateShouldNotBeSelected",
498
    { prevSubject: true },
499
    (subject, date) => {
500
        return cy
501
            .wrap(subject)
502
            .getFlatpickrDate(date)
503
            .should("not.have.class", "selected")
504
            .then(() => cy.wrap(subject));
505
    }
506
);
507
508
/**
509
 * Assertion helper to check if a Flatpickr date is today.
510
 */
511
Cypress.Commands.add(
512
    "flatpickrDateShouldBeToday",
513
    { prevSubject: true },
514
    (subject, date) => {
515
        return cy
516
            .wrap(subject)
517
            .getFlatpickrDate(date)
518
            .should("have.class", "today")
519
            .then(() => cy.wrap(subject));
520
    }
521
);
522
523
/**
524
 * Assertion helper to check if a Flatpickr date is in a range (has inRange class).
525
 */
526
Cypress.Commands.add(
527
    "flatpickrDateShouldBeInRange",
528
    { prevSubject: true },
529
    (subject, date) => {
530
        return cy
531
            .wrap(subject)
532
            .getFlatpickrDate(date)
533
            .should("have.class", "inRange")
534
            .then(() => cy.wrap(subject));
535
    }
536
);
537
538
/**
539
 * Assertion helper to check if a Flatpickr date is the start of a range.
540
 */
541
Cypress.Commands.add(
542
    "flatpickrDateShouldBeRangeStart",
543
    { prevSubject: true },
544
    (subject, date) => {
545
        return cy
546
            .wrap(subject)
547
            .getFlatpickrDate(date)
548
            .should("have.class", "startRange")
549
            .then(() => cy.wrap(subject));
550
    }
551
);
552
553
/**
554
 * Assertion helper to check if a Flatpickr date is the end of a range.
555
 */
556
Cypress.Commands.add(
557
    "flatpickrDateShouldBeRangeEnd",
558
    { prevSubject: true },
559
    (subject, date) => {
560
        return cy
561
            .wrap(subject)
562
            .getFlatpickrDate(date)
563
            .should("have.class", "endRange")
564
            .then(() => cy.wrap(subject));
565
    }
566
);
567
568
/**
569
 * Helper to get the selected dates from a Flatpickr instance.
570
 * Returns the selected dates as an array of YYYY-MM-DD formatted strings.
571
 */
572
Cypress.Commands.add(
573
    "getFlatpickrSelectedDates",
574
    { prevSubject: true },
575
    subject => {
576
        return cy.wrap(subject).then($input => {
577
            const fpInstance = $input[0]._flatpickr;
578
            if (!fpInstance) {
579
                throw new Error(
580
                    `Flatpickr: Cannot find flatpickr instance on element. Make sure it's initialized with flatpickr.`
581
                );
582
            }
583
584
            const selectedDates = fpInstance.selectedDates.map(date =>
585
                dayjs(date).format("YYYY-MM-DD")
586
            );
587
588
            return selectedDates;
589
        });
590
    }
591
);
592
593
/**
412
/**
594
 * Helper to clear a Flatpickr input by setting its value to empty.
413
 * Helper to clear a Flatpickr input by setting its value to empty.
595
 * Works with hidden inputs by using the Flatpickr API directly.
414
 * Works with hidden inputs by using the Flatpickr API directly.
596
- 

Return to bug 41129