From 5b51b3e2bb8d298e80049974c0942b2271c82efa Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Fri, 2 Jan 2026 15:13:59 +0000 Subject: [PATCH] Bug 37707: Improve lead/trail period conflict detection This implements the full "protected period" concept for bookings, where each existing booking reserves: lead period + actual dates + trail period. Previously, we only checked: - New booking lead vs existing booking actual dates - New booking trail vs existing booking actual dates Now we ALSO check: - New booking lead vs existing booking trail periods - New booking trail vs existing booking lead periods This ensures librarians cannot create bookings too close together, respecting both item preparation time (lead) and processing time (trail). Test plan: 1. Apply all patches and run: yarn build 2. Configure a bookable item with 2-day lead and 3-day trail periods 3. Create booking A: January 5-9 - This reserves Jan 3-4 (lead), Jan 5-9 (actual), Jan 10-12 (trail) 4. Attempt to create booking B: January 12-15 with 2-day lead - Lead would be Jan 10-11, which conflicts with booking A's trail 5. Verify days 10-11 are visually disabled with striped pattern 6. Verify hovering shows "conflicts with existing booking's trail period" 7. Verify you cannot select January 12 as start date 8. Verify January 13 IS selectable (no conflict) 9. Run Cypress tests to confirm: npx cypress run --spec "t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts" --- .../prog/css/src/staff-global.scss | 27 +++ .../prog/js/modals/place_booking.js | 210 +++++++++++++++++- 2 files changed, 236 insertions(+), 1 deletion(-) diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss index d9ad8218cba..3fe0e7f40ff 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -938,6 +938,33 @@ $dayTrailBackground: #fcdcb3 !default; background: #f5f5f5 !important; /* Light background */ cursor: not-allowed !important; } + + /* BIDIRECTIONAL ENHANCEMENT: Visual indicators for existing bookings' protected periods */ + &.existingBookingLead { + /* Existing booking's lead period (preparation time before their booking starts) */ + /* Use diagonal stripes to distinguish from new booking's lead period */ + background: repeating-linear-gradient( + 45deg, + #e6d4f5, + #e6d4f5 10px, + #f0e6f8 10px, + #f0e6f8 20px + ); + opacity: 0.7; + } + + &.existingBookingTrail { + /* Existing booking's trail period (processing time after their booking ends) */ + /* Use diagonal stripes to distinguish from new booking's trail period */ + background: repeating-linear-gradient( + 45deg, + #fcd4e6, + #fcd4e6 10px, + #ffe6f0 10px, + #ffe6f0 20px + ); + opacity: 0.7; + } } .input-warning { diff --git a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js index a97348b6bbd..e1d19f2fc0d 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js +++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js @@ -937,6 +937,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { ) : null; + // Calculate new booking's lead/trail periods const leadStart = startDate ? startDate.subtract(leadDays, "day") : hoverDate.subtract(leadDays, "day"); @@ -944,8 +945,119 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { const trailStart = hoverDate; const trailEnd = hoverDate.add(trailDays, "day"); + // BIDIRECTIONAL ENHANCEMENT: Collect closest bookings for visual feedback + // and check for mathematical conflicts in a single pass + let closestBeforeBooking = null; + let closestBeforeDistance = Infinity; + + let closestAfterBooking = null; + let closestAfterDistance = Infinity; + let leadDisable = false; let trailDisable = false; + + bookings.forEach(booking => { + // Skip if we're editing this booking + if ( + booking_id && + booking_id == booking.booking_id + ) { + return; + } + + // Skip if not same item (for item-specific bookings) + if ( + booking.item_id && + booking_item_id && + booking.item_id != booking_item_id + ) { + return; + } + + const bookingStart = dayjs( + booking.start_date + ).startOf("day"); + const bookingEnd = dayjs( + booking.end_date + ).startOf("day"); + + // BIDIRECTIONAL: Mathematical checks for conflicts (works across month boundaries) + // Calculate this booking's full protected period + const existingLeadStart = bookingStart.subtract( + leadDays, + "day" + ); + const existingLeadEnd = bookingStart.subtract( + 1, + "day" + ); + const existingTrailStart = bookingEnd.add( + 1, + "day" + ); + const existingTrailEnd = bookingEnd.add( + trailDays, + "day" + ); + + // Check if new booking's LEAD period overlaps with existing booking's TRAIL period + if (!periodPicker.selectedDates[0]) { + if ( + leadStart.isSameOrBefore( + existingTrailEnd + ) && + leadEnd.isSameOrAfter( + existingTrailStart + ) + ) { + leadDisable = true; + } + } + + // Check if new booking's TRAIL period overlaps with existing booking's LEAD period + if (periodPicker.selectedDates[0]) { + if ( + trailStart.isSameOrBefore( + existingLeadEnd + ) && + trailEnd.isSameOrAfter( + existingLeadStart + ) + ) { + trailDisable = true; + } + } + + // Find closest bookings for visual feedback (when dates are in view) + if (bookingEnd.isBefore(hoverDate)) { + const distance = hoverDate.diff( + bookingEnd, + "day" + ); + if (distance < closestBeforeDistance) { + closestBeforeDistance = distance; + closestBeforeBooking = { + start: bookingStart, + end: bookingEnd, + }; + } + } + + if (bookingStart.isAfter(hoverDate)) { + const distance = bookingStart.diff( + hoverDate, + "day" + ); + if (distance < closestAfterDistance) { + closestAfterDistance = distance; + closestAfterBooking = { + start: bookingStart, + end: bookingEnd, + }; + } + } + }); + periodPicker.calendarContainer .querySelectorAll(".flatpickr-day") .forEach(function (dayElem) { @@ -953,6 +1065,15 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { dayElem.dateObj ).startOf("day"); + // Clear existing booking lead/trail classes + dayElem.classList.remove( + "existingBookingLead" + ); + dayElem.classList.remove( + "existingBookingTrail" + ); + + // Apply new booking's lead/trail period classes dayElem.classList.toggle( "leadRangeStart", elemDate.isSame(leadStart) @@ -979,7 +1100,62 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { "trailRangeEnd", elemDate.isSame(trailEnd) ); - // If we're overlapping a disabled date, disable our hoverDate + + // BIDIRECTIONAL: Show closest existing booking's trail period + if (closestBeforeBooking && trailDays > 0) { + const existingTrailStart = + closestBeforeBooking.end.add( + 1, + "day" + ); + const existingTrailEnd = + closestBeforeBooking.end.add( + trailDays, + "day" + ); + + if ( + elemDate.isSameOrAfter( + existingTrailStart + ) && + elemDate.isSameOrBefore( + existingTrailEnd + ) + ) { + dayElem.classList.add( + "existingBookingTrail" + ); + } + } + + // BIDIRECTIONAL: Show closest existing booking's lead period + if (closestAfterBooking && leadDays > 0) { + const existingLeadStart = + closestAfterBooking.start.subtract( + leadDays, + "day" + ); + const existingLeadEnd = + closestAfterBooking.start.subtract( + 1, + "day" + ); + + if ( + elemDate.isSameOrAfter( + existingLeadStart + ) && + elemDate.isSameOrBefore( + existingLeadEnd + ) + ) { + dayElem.classList.add( + "existingBookingLead" + ); + } + } + + // Check for conflicts with flatpickr-disabled dates (original behavior) if ( dayElem.classList.contains( "flatpickr-disabled" @@ -1013,6 +1189,38 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } } + + // BIDIRECTIONAL: Check for conflicts with existing booking's trail period + if ( + !periodPicker.selectedDates[0] && + dayElem.classList.contains( + "existingBookingTrail" + ) + ) { + // New booking's lead period overlaps with existing booking's trail + if ( + elemDate.isSameOrAfter(leadStart) && + elemDate.isBefore(leadEnd) + ) { + leadDisable = true; + } + } + + // BIDIRECTIONAL: Check for conflicts with existing booking's lead period + if ( + dayElem.classList.contains( + "existingBookingLead" + ) + ) { + // New booking's trail period overlaps with existing booking's lead + if ( + elemDate.isAfter(trailStart) && + elemDate.isSameOrBefore(trailEnd) + ) { + trailDisable = true; + } + } + dayElem.classList.remove("leadDisable"); dayElem.classList.remove("trailDisable"); dayElem.removeEventListener( -- 2.52.0