From 270165130713bad22e0b2ac0d2aa365693987ac3 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Sat, 3 Jan 2026 07:34:09 +0000 Subject: [PATCH] Bug 37707: (follow-up) Add textual feedback for booking lead/trail period conflicts This patch adds a feedback message container below the calendar that provides contextual information about lead/trail period requirements and conflicts, improving accessibility and user understanding. The feedback updates dynamically on hover, showing: - Info messages when hovering selectable dates - Error messages when hovering dates disabled due to conflicts - Specific explanations distinguishing between conflict types (e.g., new lead vs existing trail, new trail vs existing lead) - Multiple conflict messages when both lead and trail periods conflict - Specific messages for past dates and insufficient lead time Key improvements: - Standardized terminology consistently uses "lead period" and "trail period" - Proper internationalization using __() for all user-facing strings - DRY code with detectPeriodConflict() helper function - Styling moved from inline JavaScript to SCSS using Bootstrap alert classes - Empty/zero periods are handled gracefully (not mentioned in messages) - Accurate conflict detection prioritizes what the hovered date IS over what it conflicts with This complements the visual color indicators by providing explicit textual explanations, improving accessibility and user understanding. 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 an existing booking for January 10-15 (protected: Jan 8-18) 4. Open the booking calendar and observe feedback area below calendar 5. Test past date and insufficient lead time messages: - Hover on today's date * Verify message: "Cannot select: Date is in the past" - Hover on tomorrow (with 2-day lead period) * Verify message: "Cannot select: Insufficient lead time (2 days required before start)" - Hover on the day after tomorrow * Verify message: "Cannot select: Insufficient lead time (2 days required before start)" * Should NOT show "conflicts with existing booking" if no booking exists 6. Test existing booking conflict messages: - Hover over Jan 12 (part of existing booking) * Verify message: "Cannot select: This date is part of an existing booking" - Hover over Jan 8 (existing booking's lead period) * Verify message: "Cannot select: This date is part of an existing booking's lead period" - Hover over Jan 16 (existing booking's trail period) * Verify message: "Cannot select: This date is part of an existing booking's trail period" 7. Test lead/trail period conflict messages: - Try to select Jan 19-20 as booking dates (2-day lead = Jan 17-18) - Hover over Jan 19 when it appears disabled * Verify error message: "Cannot select: Lead period (2 days before start) conflicts with existing booking's trail period" 8. Test info messages for valid dates: - Hover over Jan 21 (no conflicts, with lead period configured) * Verify info message: "Selecting start date. Lead period: 2 days before start" - Configure booking with NO lead period (0 or blank) - Hover over a valid date * Verify message: "Selecting start date" (no mention of lead period) 9. Test multiple conflict messages: - Position calendar to hover between two bookings where both lead and trail conflict * Verify message shows both conflicts separated by semicolon * Example: "Cannot select: lead period conflicts with existing booking's trail period; trail period conflicts with existing booking's lead period" 10. Verify all feedback messages: - Are properly translated (check .po files if applicable) - Use consistent "lead period" and "trail period" terminology - Are clear and helpful for librarians - Update color coding (red for errors, blue for info) --- .../intranet-tmpl/prog/css/src/bookings.scss | 2 +- .../prog/css/src/staff-global.scss | 13 + .../prog/js/modals/place_booking.js | 400 +++++++++++++++++- 3 files changed, 408 insertions(+), 7 deletions(-) diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/bookings.scss b/koha-tmpl/intranet-tmpl/prog/css/src/bookings.scss index 3f72fbf488f..0f9a7da6fea 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/bookings.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/bookings.scss @@ -10,4 +10,4 @@ ); } } -} \ No newline at end of file +} 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 a75fca56e89..8e3dec110a8 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -1103,6 +1103,19 @@ $leadClashTrail: #e5d4f5 !default; } } +// Booking conflict feedback message styles +.booking-conflict-feedback { + // Override Bootstrap's default alert padding and sizing for compact display + padding: 0.5rem 0.75rem; + margin-top: 0.5rem; + margin-bottom: 0 !important; // Override Bootstrap's default 1rem margin-bottom + min-height: 1.25rem; + text-align: center; + + // Bootstrap's .alert-danger, .alert-warning, and .alert-info classes + // provide the color schemes via CSS variables when combined with .alert +} + .input-warning { background-color: #ff9; border-color: #900; 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 dc04dc17d49..a95632600e6 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js +++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js @@ -922,6 +922,17 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { ); } + // Create feedback message container below the calendar + let feedbackDiv = periodPicker.calendarContainer.querySelector( + ".booking-conflict-feedback" + ); + if (!feedbackDiv) { + feedbackDiv = document.createElement("div"); + feedbackDiv.className = + "booking-conflict-feedback alert d-none"; + periodPicker.calendarContainer.appendChild(feedbackDiv); + } + // Add hints for days before the start range and after the end range periodPicker.calendarContainer.addEventListener( "mouseover", @@ -1058,6 +1069,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } }); + // Work through all days in view and add classes appropraitely based on hovered date periodPicker.calendarContainer .querySelectorAll(".flatpickr-day") .forEach(function (dayElem) { @@ -1085,7 +1097,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { "existingBookingTrailEnd" ); - // Apply new booking's lead/trail period classes + // Apply proposed booking's lead/trail period classes dayElem.classList.toggle( "leadRangeStart", elemDate.isSame(leadStart) @@ -1113,7 +1125,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { elemDate.isSame(trailEnd) ); - // BIDIRECTIONAL: Show closest existing booking's trail period + // Show closest preceding existing booking's trail period if (closestBeforeBooking && trailDays > 0) { const existingTrailStart = closestBeforeBooking.end.add( @@ -1159,7 +1171,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } - // BIDIRECTIONAL: Show closest existing booking's lead period + // Show closest following existing booking's lead period if (closestAfterBooking && leadDays > 0) { const existingLeadStart = closestAfterBooking.start.subtract( @@ -1203,7 +1215,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } - // Check for conflicts with flatpickr-disabled dates (original behavior) + // Check for conflicts with flatpickr-disabled dates if ( dayElem.classList.contains( "flatpickr-disabled" @@ -1238,7 +1250,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } - // BIDIRECTIONAL: Check for conflicts with existing booking's trail period + // Check for conflicts with existing booking's trail period if ( !periodPicker.selectedDates[0] && dayElem.classList.contains( @@ -1254,7 +1266,7 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } - // BIDIRECTIONAL: Check for conflicts with existing booking's lead period + // Check for conflicts with existing booking's lead period if ( dayElem.classList.contains( "existingBookingLead" @@ -1278,6 +1290,25 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { ); }); + // Additional check for hovering directly on existing booking's lead/trail periods + // If hovering on an existing booking's lead period when selecting start date, block selection + if ( + !periodPicker.selectedDates[0] && + target.classList.contains("existingBookingLead") + ) { + leadDisable = true; + } + + // If hovering on an existing booking's trail period when selecting end date, block selection + if ( + periodPicker.selectedDates[0] && + target.classList.contains( + "existingBookingTrail" + ) + ) { + trailDisable = true; + } + if (leadDisable) { target.classList.add("leadDisable"); } @@ -1291,6 +1322,363 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { true ); } + + // Helper function to detect what a period (lead or trail) conflicts with + // Returns an object with conflict type flags + function detectPeriodConflict( + startDate, + endDate, + calendarContainer + ) { + const conflict = { + withTrail: false, + withLead: false, + withBooking: false, + }; + + for ( + let checkDate = startDate; + checkDate.isSameOrBefore(endDate); + checkDate = checkDate.add(1, "day") + ) { + const dayElem = + calendarContainer.querySelector( + `.flatpickr-day[aria-label="${checkDate.format("MMMM D, YYYY")}"]` + ); + if (dayElem) { + if ( + dayElem.classList.contains( + "existingBookingTrail" + ) + ) { + conflict.withTrail = true; + break; + } + if ( + dayElem.classList.contains( + "existingBookingLead" + ) + ) { + conflict.withLead = true; + break; + } + if ( + dayElem.classList.contains( + "flatpickr-disabled" + ) + ) { + conflict.withBooking = true; + break; + } + } + } + + return conflict; + } + + // Update feedback message + const feedbackDiv = + periodPicker.calendarContainer.querySelector( + ".booking-conflict-feedback" + ); + if (feedbackDiv) { + let message = ""; + let messageType = "info"; // info, warning, error + + // Determine what the hovered date is (needed for both error and info messages) + const today = dayjs().startOf("day"); + const isDisabled = + target.classList.contains( + "flatpickr-disabled" + ); + const isInExistingLead = + target.classList.contains( + "existingBookingLead" + ); + const isInExistingTrail = + target.classList.contains( + "existingBookingTrail" + ); + + // Generate appropriate feedback messages based on conflicts + if (leadDisable || trailDisable) { + messageType = "error"; + + // CASE 1: Both lead and trail conflicts (selecting start between two bookings) + if ( + leadDisable && + trailDisable && + !periodPicker.selectedDates[0] + ) { + const leadConflict = + detectPeriodConflict( + leadStart, + leadEnd, + periodPicker.calendarContainer + ); + const trailConflict = + detectPeriodConflict( + trailStart.add(1, "day"), + trailEnd, + periodPicker.calendarContainer + ); + + let leadMsg = ""; + let trailMsg = ""; + + if (leadConflict.withTrail) { + leadMsg = __( + "lead period conflicts with existing booking's trail period" + ); + } else if (leadConflict.withLead) { + leadMsg = __( + "lead period conflicts with existing booking's lead period" + ); + } else if (leadConflict.withBooking) { + leadMsg = __( + "lead period conflicts with existing booking" + ); + } + + if (trailConflict.withLead) { + trailMsg = __( + "trail period conflicts with existing booking's lead period" + ); + } else if (trailConflict.withTrail) { + trailMsg = __( + "trail period conflicts with existing booking's trail period" + ); + } else if (trailConflict.withBooking) { + trailMsg = __( + "trail period conflicts with existing booking" + ); + } + + if (leadMsg && trailMsg) { + message = + __("Cannot select") + + ": " + + leadMsg + + "; " + + trailMsg; + } else if (leadMsg) { + message = + __("Cannot select") + + ": " + + leadMsg; + } else if (trailMsg) { + message = + __("Cannot select") + + ": " + + trailMsg; + } else { + message = __( + "Cannot select: Multiple conflicts with existing bookings" + ); + } + } + // CASE 2: Lead period conflict only (selecting start date) + else if ( + leadDisable && + !periodPicker.selectedDates[0] + ) { + if (hoverDate.isBefore(today)) { + message = __( + "Cannot select: Date is in the past" + ); + } else if ( + leadDays > 0 && + leadStart.isSameOrBefore(today) + ) { + message = + __("Cannot select") + + ": " + + __( + "Insufficient lead time (%s days required before start)" + ).format(leadDays); + } else if (isDisabled) { + message = __( + "Cannot select: This date is part of an existing booking" + ); + } else if (isInExistingLead) { + message = __( + "Cannot select: This date is part of an existing booking's lead period" + ); + } else if (isInExistingTrail) { + message = __( + "Cannot select: This date is part of an existing booking's trail period" + ); + } else if (leadDays > 0) { + const conflict = + detectPeriodConflict( + leadStart, + leadEnd, + periodPicker.calendarContainer + ); + + if (conflict.withTrail) { + message = + __("Cannot select") + + ": " + + __( + "Lead period (%s days before start) conflicts with existing booking's trail period" + ).format(leadDays); + } else if (conflict.withLead) { + message = + __("Cannot select") + + ": " + + __( + "Lead period (%s days before start) conflicts with existing booking's lead period" + ).format(leadDays); + } else if (conflict.withBooking) { + message = + __("Cannot select") + + ": " + + __( + "Lead period (%s days before start) conflicts with existing booking" + ).format(leadDays); + } else { + message = + __("Cannot select") + + ": " + + __( + "Lead period (%s days before start) conflicts with existing booking" + ).format(leadDays); + } + } else { + message = __( + "Cannot select: Conflicts with existing booking" + ); + } + } + // CASE 3: Trail period conflict only (selecting end date) + else if (trailDisable) { + if (isDisabled) { + message = __( + "Cannot select: This date is part of an existing booking" + ); + } else if (isInExistingLead) { + message = __( + "Cannot select: This date is part of an existing booking's lead period" + ); + } else if (isInExistingTrail) { + message = __( + "Cannot select: This date is part of an existing booking's trail period" + ); + } else if (trailDays > 0) { + const conflict = + detectPeriodConflict( + trailStart.add(1, "day"), + trailEnd, + periodPicker.calendarContainer + ); + + if (conflict.withLead) { + message = + __("Cannot select") + + ": " + + __( + "Trail period (%s days after return) conflicts with existing booking's lead period" + ).format(trailDays); + } else if (conflict.withTrail) { + message = + __("Cannot select") + + ": " + + __( + "Trail period (%s days after return) conflicts with existing booking's trail period" + ).format(trailDays); + } else if (conflict.withBooking) { + message = + __("Cannot select") + + ": " + + __( + "Trail period (%s days after return) conflicts with existing booking" + ).format(trailDays); + } else { + message = + __("Cannot select") + + ": " + + __( + "Trail period (%s days after return) conflicts with existing booking" + ).format(trailDays); + } + } else { + message = __( + "Cannot select: Conflicts with existing booking" + ); + } + } + } else { + // Show helpful info messages when no conflicts + if (!periodPicker.selectedDates[0]) { + if (leadDays > 0) { + message = + __("Selecting start date") + + ". " + + __( + "Lead period: %s days before start" + ).format(leadDays); + } else { + message = __( + "Selecting start date" + ); + } + messageType = "info"; + } else { + if (trailDays > 0) { + message = + __("Selecting end date") + + ". " + + __( + "Trail period: %s days after return" + ).format(trailDays); + } else { + message = __("Selecting end date"); + } + messageType = "info"; + } + + // Show additional context if hovering over existing booking periods + if (isInExistingLead) { + message += + " • " + + __( + "Hovering existing booking's lead period" + ); + } else if (isInExistingTrail) { + message += + " • " + + __( + "Hovering existing booking's trail period" + ); + } + } + + feedbackDiv.textContent = message; + feedbackDiv.classList.remove( + "alert-danger", + "alert-warning", + "alert-info" + ); + + if (message) { + feedbackDiv.classList.remove("d-none"); + // Apply appropriate Bootstrap alert class based on message type + if (messageType === "error") { + feedbackDiv.classList.add( + "alert-danger" + ); + } else if (messageType === "warning") { + feedbackDiv.classList.add( + "alert-warning" + ); + } else { + feedbackDiv.classList.add("alert-info"); + } + } else { + feedbackDiv.classList.add("d-none"); + } + } } } ); -- 2.52.0