From fd9c17bd2d9cce7be7e7b7f0232cfafc20ea105b Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Fri, 2 Jan 2026 15:13:59 +0000 Subject: [PATCH] Bug 37707: Add bidirectional lead/trail period conflict detection Enhance the booking modal to detect and prevent lead/trail period conflicts in both directions: - New booking's lead period vs existing booking's trail period - New booking's trail period vs existing booking's lead period Visual feedback: - Highlight existing booking's lead periods (before booking starts) - Highlight existing booking's trail periods (after booking ends) - Add CSS classes for start/end boundaries with rounded corners User feedback: - Show contextual messages explaining why dates are blocked - Differentiate between lead period, trail period, and booking conflicts - Display informational messages when hovering available dates The conflict detection uses mathematical date range comparisons that work across month boundaries, not just visible calendar dates. Signed-off-by: Paul Derscheid Signed-off-by: Nick Clemens --- .../intranet-tmpl/prog/css/src/bookings.scss | 2 +- .../prog/css/src/staff-global.scss | 201 +++++- .../prog/js/modals/place_booking.js | 651 +++++++++++++++++- 3 files changed, 815 insertions(+), 39 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 ebb964719da..23c42d00425 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -884,26 +884,33 @@ input { $dayLeadBackground: #fad2cf !default; $dayTrailBackground: #fcdcb3 !default; +$existingLeadBackground: #e8f0d4 !default; +$existingTrailBackground: #d0e6f5 !default; +$trailClashLead: #d0f0e0 !default; +$leadClashTrail: #e5d4f5 !default; .flatpickr-day { - &.leadRangeStart { border-radius: 50px 0 0 50px; } &.leadRange { - box-shadow: -2.5 * $dayMargin 0 0 $dayLeadBackground, 2.5 * $dayMargin 0 0 $dayLeadBackground; + box-shadow: + -2.5 * $dayMargin 0 0 $dayLeadBackground, + 2.5 * $dayMargin 0 0 $dayLeadBackground; background: $dayLeadBackground; &.flatpickr-disabled { $darkLeadBackground: darken($dayLeadBackground, 10); - box-shadow: -2.5 * $dayMargin 0 0 $darkLeadBackground, 2.5 * $dayMargin 0 0 $darkLeadBackground; + box-shadow: + -2.5 * $dayMargin 0 0 $darkLeadBackground, + 2.5 * $dayMargin 0 0 $darkLeadBackground; background: $darkLeadBackground; } } &.selected { - &.leadRangeEnd { - border-radius: 0; - } + &.leadRangeEnd { + border-radius: 0; + } } &:hover, @@ -914,11 +921,15 @@ $dayTrailBackground: #fcdcb3 !default; } &.trailRange { - box-shadow: -2.5 * $dayMargin 0 0 $dayTrailBackground, 2.5 * $dayMargin 0 0 $dayTrailBackground; + box-shadow: + -2.5 * $dayMargin 0 0 $dayTrailBackground, + 2.5 * $dayMargin 0 0 $dayTrailBackground; background: $dayTrailBackground; &.flatpickr-disabled { $darkTrailBackground: darken($dayTrailBackground, 10); - box-shadow: -2.5 * $dayMargin 0 0 $darkTrailBackground, 2.5 * $dayMargin 0 0 $darkTrailBackground; + box-shadow: + -2.5 * $dayMargin 0 0 $darkTrailBackground, + 2.5 * $dayMargin 0 0 $darkTrailBackground; background: $darkTrailBackground; } } @@ -928,16 +939,182 @@ $dayTrailBackground: #fcdcb3 !default; } &.leadDisable { - color: #aaa !important; /* Gray out the text */ - background: #f5f5f5 !important; /* Light background */ + color: #aaa !important; /* Gray out the text */ + background: #f5f5f5 !important; /* Light background */ cursor: not-allowed !important; } &.trailDisable { - color: #aaa !important; /* Gray out the text */ - background: #f5f5f5 !important; /* Light background */ + color: #aaa !important; /* Gray out the text */ + background: #f5f5f5 !important; /* Light background */ cursor: not-allowed !important; } + + /* BIDIRECTIONAL ENHANCEMENT: Visual indicators for existing bookings' protected periods */ + + /* Existing booking's lead period (pastel green - preparation time before their booking) */ + &.existingBookingLeadStart { + border-radius: 50px 0 0 50px; + + /* Remove right rounding if it meets new booking trail start */ + &.trailRange { + border-radius: 0; + } + + /* Special case: leadRangeEnd + trailRangeStart on existingBookingLeadStart */ + /* Left shadow should be pink to match the leadRange to the left */ + &.leadRangeEnd.trailRangeStart { + box-shadow: + -2.5 * $dayMargin 0 0 $dayLeadBackground, + 2.5 * $dayMargin 0 0 $existingLeadBackground !important; + } + + /* Special case: trailRangeEnd on existingBookingLeadStart */ + /* Right shadow should be pastel green to match the existingBookingLead to the right */ + &.trailRangeEnd { + background: $existingLeadBackground !important; /* Green base fills the corners */ + border-radius: 0 !important; /* Right-side radius */ + position: relative; + z-index: 1; + + box-shadow: + -2.5 * $dayMargin 0 0 $trailClashLead, + 2.5 * $dayMargin 0 0 $existingLeadBackground !important; + + &::before { + content: ""; + position: absolute; + top: 0; + left: -1px; + right: 0px; + bottom: 0; + background: $trailClashLead; /* Olive collision color */ + border-radius: 0 50px 50px 0; + z-index: -1; + } + + display: flex; + align-items: center; + justify-content: center; + } + } + + &.existingBookingLead { + background: $existingLeadBackground; /* Pastel green */ + box-shadow: + -2.5 * $dayMargin 0 0 $existingLeadBackground, + 2.5 * $dayMargin 0 0 $existingLeadBackground; + } + + /* New booking trail overlaps with existing booking lead */ + &.trailRange.existingBookingLead { + background: $trailClashLead !important; /* Light olive (orange + green merge) */ + box-shadow: + -2.5 * $dayMargin 0 0 $trailClashLead, + 2.5 * $dayMargin 0 0 $trailClashLead; + } + + &.trailRangeEnd.existingBookingLead { + background: $existingLeadBackground !important; /* Base fills corners with Existing Lead Green */ + border-radius: 0 !important; + position: relative; + z-index: 1; + + /* Shadows: Left matches the previous zone, Right matches the Green zone */ + box-shadow: + -2.5 * $dayMargin 0 0 $trailClashLead, + 2.5 * $dayMargin 0 0 $existingLeadBackground !important; + + &::before { + content: ""; + position: absolute; + top: 0; + right: 0; + left: -1px; + bottom: 0; + background: $trailClashLead; /* Light olive overlap color */ + border-radius: 0 50px 50px 0; + z-index: -1; + } + + display: flex; + align-items: center; + justify-content: center; + } + + /* Existing booking's trail period (pastel blue - processing time after their booking) */ + &.existingBookingTrailEnd { + border-radius: 0 50px 50px 0; + + /* Remove left rounding if it meets new booking lead end */ + &.leadRange { + border-radius: 0; + } + + /* Special case: leadRangeEnd + trailRangeStart on existingBookingTrailEnd */ + /* Left shadow should be lavender to match the leadRange.existingBookingTrail to the left */ + &.leadRangeEnd.trailRangeStart { + box-shadow: + -2.5 * $dayMargin 0 0 $leadClashTrail, + 2.5 * $dayMargin 0 0 $existingTrailBackground !important; + } + } + + &.existingBookingTrail { + background: $existingTrailBackground; /* Pastel blue */ + box-shadow: + -2.5 * $dayMargin 0 0 $existingTrailBackground, + 2.5 * $dayMargin 0 0 $existingTrailBackground; + } + + /* New booking lead overlaps with existing booking trail */ + &.leadRange.existingBookingTrail { + background: $leadClashTrail !important; /* Lavender (pink + blue merge) */ + box-shadow: + -2.5 * $dayMargin 0 0 $leadClashTrail, + 2.5 * $dayMargin 0 0 $leadClashTrail; + } + + &.leadRangeStart.existingBookingTrail { + background: $existingTrailBackground !important; /* Blue base fills the top/bottom left corners */ + border-radius: 0 !important; /* Base cell must stay square */ + position: relative; + z-index: 1; + + /* Shadows: Left is Blue, Right is Purple */ + box-shadow: + -2.5 * $dayMargin 0 0 $existingTrailBackground, + 2.5 * $dayMargin 0 0 $leadClashTrail !important; + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: -1px; + bottom: 0; + background: $leadClashTrail; /* Lavender */ + border-radius: 50px 0 0 50px; + z-index: -1; + } + + display: flex; + align-items: center; + justify-content: center; + } +} + +// 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 { 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 e4562de2e15..ed0fe2aebca 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js +++ b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js @@ -1072,6 +1072,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", @@ -1087,15 +1098,188 @@ $("#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"); - const leadEnd = startDate ? startDate : hoverDate; - const trailStart = hoverDate; - const trailEnd = hoverDate.add(trailDays, "day"); + const leadEnd = startDate + ? startDate.subtract(1, "day") + : hoverDate.subtract(1, "day"); + const trailStart = startDate + ? hoverDate.add(1, "day") + : hoverDate.add(1, "day"); + const trailEnd = startDate + ? hoverDate.add(trailDays, "day") + : 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; + + // Track conflict reasons for messaging + let leadConflictReason = { + withTrail: false, + withLead: false, + withBooking: false, + }; + let trailConflictReason = { + withTrail: false, + withLead: false, + withBooking: 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 + if (!periodPicker.selectedDates[0]) { + // Check overlap with existing booking's trail period + if ( + leadStart.isSameOrBefore( + existingTrailEnd + ) && + leadEnd.isSameOrAfter( + existingTrailStart + ) + ) { + leadDisable = true; + leadConflictReason.withTrail = true; + } + // Check overlap with existing booking's lead period + else if ( + leadStart.isSameOrBefore( + existingLeadEnd + ) && + leadEnd.isSameOrAfter(existingLeadStart) + ) { + leadDisable = true; + leadConflictReason.withLead = true; + } + // Check overlap with existing booking itself + else if ( + leadStart.isSameOrBefore(bookingEnd) && + leadEnd.isSameOrAfter(bookingStart) + ) { + leadDisable = true; + leadConflictReason.withBooking = true; + } + } + + // Check if new booking's TRAIL period overlaps with existing booking + if (periodPicker.selectedDates[0]) { + // Check overlap with existing booking's lead period + if ( + trailStart.isSameOrBefore( + existingLeadEnd + ) && + trailEnd.isSameOrAfter( + existingLeadStart + ) + ) { + trailDisable = true; + trailConflictReason.withLead = true; + } + // Check overlap with existing booking's trail period + else if ( + trailStart.isSameOrBefore( + existingTrailEnd + ) && + trailEnd.isSameOrAfter( + existingTrailStart + ) + ) { + trailDisable = true; + trailConflictReason.withTrail = true; + } + // Check overlap with existing booking itself + else if ( + trailStart.isSameOrBefore(bookingEnd) && + trailEnd.isSameOrAfter(bookingStart) + ) { + trailDisable = true; + trailConflictReason.withBooking = 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, + }; + } + } + }); + + // Work through all days in view and add classes appropraitely based on hovered date periodPicker.calendarContainer .querySelectorAll(".flatpickr-day") .forEach(function (dayElem) { @@ -1103,33 +1287,156 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { dayElem.dateObj ).startOf("day"); - dayElem.classList.toggle( - "leadRangeStart", - elemDate.isSame(leadStart) + // Clear existing booking lead/trail classes (including start/end) + dayElem.classList.remove( + "existingBookingLead" ); - dayElem.classList.toggle( - "leadRange", - elemDate.isSameOrAfter(leadStart) && - elemDate.isBefore(leadEnd) + dayElem.classList.remove( + "existingBookingLeadStart" ); - dayElem.classList.toggle( - "leadRangeEnd", - elemDate.isSame(leadEnd) + dayElem.classList.remove( + "existingBookingLeadEnd" ); - dayElem.classList.toggle( - "trailRangeStart", - elemDate.isSame(trailStart) + dayElem.classList.remove( + "existingBookingTrail" ); - dayElem.classList.toggle( - "trailRange", - elemDate.isAfter(trailStart) && - elemDate.isSameOrBefore(trailEnd) + dayElem.classList.remove( + "existingBookingTrailStart" ); - dayElem.classList.toggle( - "trailRangeEnd", - elemDate.isSame(trailEnd) + dayElem.classList.remove( + "existingBookingTrailEnd" ); - // If we're overlapping a disabled date, disable our hoverDate + + // Apply proposed booking's lead/trail period classes + // Only apply lead classes if lead period > 0 + if (leadDays > 0) { + dayElem.classList.toggle( + "leadRangeStart", + elemDate.isSame(leadStart) + ); + dayElem.classList.toggle( + "leadRange", + elemDate.isSameOrAfter(leadStart) && + elemDate.isSameOrBefore(leadEnd) + ); + dayElem.classList.toggle( + "leadRangeEnd", + elemDate.isSame(leadEnd) + ); + } + + // Only apply trail classes if trail period > 0 + if (trailDays > 0) { + dayElem.classList.toggle( + "trailRangeStart", + elemDate.isSame(trailStart) + ); + dayElem.classList.toggle( + "trailRange", + elemDate.isSameOrAfter( + trailStart + ) && + elemDate.isSameOrBefore( + trailEnd + ) + ); + dayElem.classList.toggle( + "trailRangeEnd", + elemDate.isSame(trailEnd) + ); + } + + // Show closest preceding 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" + ); + // Add start/end classes for rounded borders + if ( + elemDate.isSame( + existingTrailStart + ) + ) { + dayElem.classList.add( + "existingBookingTrailStart" + ); + } + if ( + elemDate.isSame( + existingTrailEnd + ) + ) { + dayElem.classList.add( + "existingBookingTrailEnd" + ); + } + } + } + + // Show closest following 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" + ); + // Add start/end classes for rounded borders + if ( + elemDate.isSame( + existingLeadStart + ) + ) { + dayElem.classList.add( + "existingBookingLeadStart" + ); + } + if ( + elemDate.isSame(existingLeadEnd) + ) { + dayElem.classList.add( + "existingBookingLeadEnd" + ); + } + } + } + + // Check for conflicts with flatpickr-disabled dates if ( dayElem.classList.contains( "flatpickr-disabled" @@ -1138,12 +1445,15 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { if ( !periodPicker.selectedDates[0] && elemDate.isSameOrAfter(leadStart) && - elemDate.isBefore(leadEnd) + elemDate.isSameOrBefore(leadEnd) ) { leadDisable = true; } if ( - elemDate.isAfter(trailStart) && + periodPicker.selectedDates[0] && + elemDate.isSameOrAfter( + trailStart + ) && elemDate.isSameOrBefore(trailEnd) ) { // Only consider this a conflict if the disabled date is within the max date range @@ -1163,6 +1473,41 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { } } } + + // 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.isSameOrBefore(leadEnd) + ) { + leadDisable = true; + } + } + + // Check for conflicts with existing booking's lead period + if ( + periodPicker.selectedDates[0] && + dayElem.classList.contains( + "existingBookingLead" + ) + ) { + // New booking's trail period overlaps with existing booking's lead + if ( + elemDate.isSameOrAfter( + trailStart + ) && + elemDate.isSameOrBefore(trailEnd) + ) { + trailDisable = true; + } + } + dayElem.classList.remove("leadDisable"); dayElem.classList.remove("trailDisable"); dayElem.removeEventListener( @@ -1172,6 +1517,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"); } @@ -1185,6 +1549,241 @@ $("#placeBookingModal").on("show.bs.modal", function (e) { true ); } + + // 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"; + + // When selecting START date (no date selected yet) + if (!periodPicker.selectedDates[0]) { + // Check direct state first (what IS this date?) + if (hoverDate.isBefore(today)) { + message = __( + "Cannot select: date is in the past" + ); + } 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" + ); + } + // Then check calculated lead period conflicts + else if ( + leadDays > 0 && + leadStart.isSameOrBefore(today) + ) { + message = + __("Cannot select") + + ": " + + __( + "insufficient lead time (%s days required before start)" + ).format(leadDays); + } else if (leadDays > 0) { + // Use mathematical conflict detection (works across month boundaries) + if (leadConflictReason.withTrail) { + message = + __("Cannot select") + + ": " + + __( + "lead period (%s days before start) conflicts with an existing booking's trail period" + ).format(leadDays); + } else if ( + leadConflictReason.withLead + ) { + message = + __("Cannot select") + + ": " + + __( + "lead period (%s days before start) conflicts with an existing booking's lead period" + ).format(leadDays); + } else if ( + leadConflictReason.withBooking + ) { + message = + __("Cannot select") + + ": " + + __( + "lead period (%s days before start) conflicts with an existing booking" + ).format(leadDays); + } + } else { + message = __( + "Cannot select: conflicts with an existing booking" + ); + } + } + // When selecting END date (start date already selected) + else if (periodPicker.selectedDates[0]) { + // Check direct state first (what IS this date?) + 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" + ); + } + // Then check calculated trail period conflicts + else if (trailDays > 0) { + // Use mathematical conflict detection (works across month boundaries) + if (trailConflictReason.withLead) { + message = + __("Cannot select") + + ": " + + __( + "trail period (%s days after return) conflicts with an existing booking's lead period" + ).format(trailDays); + } else if ( + trailConflictReason.withTrail + ) { + message = + __("Cannot select") + + ": " + + __( + "trail period (%s days after return) conflicts with an existing booking's trail period" + ).format(trailDays); + } else if ( + trailConflictReason.withBooking + ) { + message = + __("Cannot select") + + ": " + + __( + "trail period (%s days after return) conflicts with an existing booking" + ).format(trailDays); + } + } else { + message = __( + "Cannot select: conflicts with existing an booking" + ); + } + } + } else { + // Show helpful info messages when no conflicts + if (!periodPicker.selectedDates[0]) { + // When selecting start date, show both lead and trail info + if (leadDays > 0 && trailDays > 0) { + message = + __("Selecting start date") + + ". " + + __( + "Lead period: %s days before start" + ).format(leadDays) + + ". " + + __( + "Trail period: %s days after return" + ).format(trailDays); + } else if (leadDays > 0) { + message = + __("Selecting start date") + + ". " + + __( + "Lead period: %s days before start" + ).format(leadDays); + } else if (trailDays > 0) { + message = + __("Selecting start date") + + ". " + + __( + "Trail period: %s days after return" + ).format(trailDays); + } 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.39.5