From 4ddb425743d4d09cf0bb6d97f800ec297d2d8bd1 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 17 Sep 2025 18:21:47 +0200 Subject: [PATCH] Bug 41129: Introduce Vue booking modal and Bootstrap 5 modal wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the jQuery/TT-based place_booking modal with a Vue 3 SFC component mounted via the island architecture pattern. Core components: - Add BookingModal.vue ( + diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc deleted file mode 100644 index 217c69460db..00000000000 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc +++ /dev/null @@ -1,66 +0,0 @@ -[% USE ItemTypes %] - - - diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt index b744f730a4a..1b4a07ca33b 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt @@ -62,13 +62,11 @@ - [% Asset.js("js/modals/place_booking.js") | $raw %] [% Asset.js("js/cancel_booking_modal.js") | $raw %] [% Asset.js("js/combobox.js") | $raw %] [% Asset.js("js/additional-filters.js") | $raw %] + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue new file mode 100644 index 00000000000..166f0a28139 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue new file mode 100644 index 00000000000..d9b3d9ba4f7 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue @@ -0,0 +1,1124 @@ + + + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue new file mode 100644 index 00000000000..fe0db8ba682 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue @@ -0,0 +1,44 @@ + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue new file mode 100644 index 00000000000..2c8f1807fe0 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue @@ -0,0 +1,352 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue new file mode 100644 index 00000000000..b3d615769fa --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue new file mode 100644 index 00000000000..fb880ab301e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs new file mode 100644 index 00000000000..ae60ee8de64 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs @@ -0,0 +1,95 @@ +import { computed } from "vue"; +import { isoArrayToDates } from "../lib/booking/BookingDate.mjs"; +import { + calculateDisabledDates, + toEffectiveRules, +} from "../lib/booking/availability.mjs"; + +/** + * Central availability computation. + * + * Date type policy: + * - Input: storeRefs.selectedDateRange is ISO[]; this composable converts to Date[] + * - Output: `disableFnRef` for Flatpickr, `unavailableByDateRef` for calendar markers + * + * @param {{ + * bookings: import('../types/bookings').RefLike, + * checkouts: import('../types/bookings').RefLike, + * bookableItems: import('../types/bookings').RefLike, + * bookingItemId: import('../types/bookings').RefLike, + * bookingId: import('../types/bookings').RefLike, + * selectedDateRange: import('../types/bookings').RefLike, + * circulationRules: import('../types/bookings').RefLike, + * holidays: import('../types/bookings').RefLike + * }} storeRefs + * @param {import('../types/bookings').RefLike} optionsRef + * @returns {{ availability: import('vue').ComputedRef, disableFnRef: import('vue').ComputedRef, unavailableByDateRef: import('vue').ComputedRef }} + */ +export function useAvailability(storeRefs, optionsRef) { + const { + bookings, + checkouts, + bookableItems, + bookingItemId, + bookingId, + selectedDateRange, + circulationRules, + holidays, + } = storeRefs; + + const inputsReady = computed( + () => + Array.isArray(bookings.value) && + Array.isArray(checkouts.value) && + Array.isArray(bookableItems.value) && + (bookableItems.value?.length ?? 0) > 0 + ); + + const availability = computed(() => { + if (!inputsReady.value) + return { disable: () => true, unavailableByDate: {} }; + + const effectiveRules = toEffectiveRules( + circulationRules.value, + optionsRef.value || {} + ); + + const selectedDatesArray = isoArrayToDates( + selectedDateRange.value || [] + ); + + // Support on-demand unavailable map for current calendar view + let calcOptions = { + holidays: holidays?.value || [], + }; + if (optionsRef && optionsRef.value) { + const { visibleStartDate, visibleEndDate } = optionsRef.value; + if (visibleStartDate && visibleEndDate) { + calcOptions.onDemand = true; + calcOptions.visibleStartDate = visibleStartDate; + calcOptions.visibleEndDate = visibleEndDate; + } + } + + return calculateDisabledDates( + bookings.value, + checkouts.value, + bookableItems.value, + bookingItemId.value, + bookingId.value, + selectedDatesArray, + effectiveRules, + undefined, + calcOptions + ); + }); + + const disableFnRef = computed( + () => availability.value.disable || (() => false) + ); + const unavailableByDateRef = computed( + () => availability.value.unavailableByDate || {} + ); + + return { availability, disableFnRef, unavailableByDateRef }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs new file mode 100644 index 00000000000..4d24c68cebb --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs @@ -0,0 +1,80 @@ +/** + * Vue composable for reactive booking validation + * Provides reactive computed properties that automatically update when store changes + */ + +import { computed } from "vue"; +import { storeToRefs } from "pinia"; +import { + canProceedToStep3, + canSubmitBooking, +} from "../lib/booking/validation.mjs"; +import { handleBookingDateChange } from "../lib/booking/availability.mjs"; + +/** + * Composable for booking validation with reactive state + * @param {Object} store - Pinia booking store instance + * @returns {Object} Reactive validation properties and methods + */ +export function useBookingValidation(store) { + const { + bookingPatron, + pickupLibraryId, + bookingItemtypeId, + itemTypes, + bookingItemId, + bookableItems, + selectedDateRange, + bookings, + checkouts, + circulationRules, + bookingId, + } = storeToRefs(store); + + const canProceedToStep3Computed = computed(() => { + return canProceedToStep3({ + showPatronSelect: store.showPatronSelect, + bookingPatron: bookingPatron.value, + showItemDetailsSelects: store.showItemDetailsSelects, + showPickupLocationSelect: store.showPickupLocationSelect, + pickupLibraryId: pickupLibraryId.value, + bookingItemtypeId: bookingItemtypeId.value, + itemtypeOptions: itemTypes.value, + bookingItemId: bookingItemId.value, + bookableItems: bookableItems.value, + }); + }); + + const canSubmitComputed = computed(() => { + const validationData = { + showPatronSelect: store.showPatronSelect, + bookingPatron: bookingPatron.value, + showItemDetailsSelects: store.showItemDetailsSelects, + showPickupLocationSelect: store.showPickupLocationSelect, + pickupLibraryId: pickupLibraryId.value, + bookingItemtypeId: bookingItemtypeId.value, + itemtypeOptions: itemTypes.value, + bookingItemId: bookingItemId.value, + bookableItems: bookableItems.value, + }; + return canSubmitBooking(validationData, selectedDateRange.value); + }); + + const validateDates = selectedDates => { + return handleBookingDateChange( + selectedDates, + circulationRules.value, + bookings.value, + checkouts.value, + bookableItems.value, + bookingItemId.value, + bookingId.value + ); + }; + + return { + canProceedToStep3: canProceedToStep3Computed, + canSubmit: canSubmitComputed, + validateDates, + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs new file mode 100644 index 00000000000..d51e155a6bb --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs @@ -0,0 +1,152 @@ +import { computed } from "vue"; + +const $__ = globalThis.$__ || (str => str); + +/** + * Centralized capacity guard for booking period availability. + * Determines whether circulation rules yield a positive booking period, + * derives a context-aware message, and drives a global warning state. + * + * @param {Object} options + * @param {import('vue').Ref>} options.circulationRules + * @param {import('vue').Ref<{patron_category_id: string|null, item_type_id: string|null, library_id: string|null}|null>} options.circulationRulesContext + * @param {import('vue').Ref<{ bookings: boolean; checkouts: boolean; bookableItems: boolean; circulationRules: boolean }>} options.loading + * @param {import('vue').Ref>} options.bookableItems + * @param {import('vue').Ref} options.bookingPatron + * @param {import('vue').Ref} options.bookingItemId + * @param {import('vue').Ref} options.bookingItemtypeId + * @param {import('vue').Ref} options.pickupLibraryId + * @param {boolean} options.showPatronSelect + * @param {boolean} options.showItemDetailsSelects + * @param {boolean} options.showPickupLocationSelect + * @param {string|null} options.dateRangeConstraint + */ +export function useCapacityGuard(options) { + const { + circulationRules, + circulationRulesContext, + loading, + bookableItems, + showPatronSelect, + showItemDetailsSelects, + showPickupLocationSelect, + dateRangeConstraint, + } = options; + + const hasPositiveCapacity = computed(() => { + const rules = circulationRules.value?.[0] || {}; + const issuelength = Number(rules.issuelength) || 0; + const renewalperiod = Number(rules.renewalperiod) || 0; + const renewalsallowed = Number(rules.renewalsallowed) || 0; + const withRenewals = issuelength + renewalperiod * renewalsallowed; + + const calculatedDays = + rules.calculated_period_days != null + ? Number(rules.calculated_period_days) || 0 + : null; + + if (dateRangeConstraint === "issuelength") return issuelength > 0; + if (dateRangeConstraint === "issuelength_with_renewals") + return withRenewals > 0; + + if (calculatedDays != null) return calculatedDays > 0; + return issuelength > 0 || withRenewals > 0; + }); + + const zeroCapacityMessage = computed(() => { + const rules = circulationRules.value?.[0] || {}; + const issuelength = rules.issuelength; + const hasExplicitZero = + issuelength != null && Number(issuelength) === 0; + const hasNull = issuelength === null || issuelength === undefined; + + if (hasExplicitZero) { + if ( + showPatronSelect && + showItemDetailsSelects && + showPickupLocationSelect + ) { + return $__( + "Bookings are not permitted for this combination of patron category, item type, and pickup location. The circulation rules set the booking period to zero days." + ); + } + if (showItemDetailsSelects && showPickupLocationSelect) { + return $__( + "Bookings are not permitted for this item type at the selected pickup location. The circulation rules set the booking period to zero days." + ); + } + if (showItemDetailsSelects) { + return $__( + "Bookings are not permitted for this item type. The circulation rules set the booking period to zero days." + ); + } + return $__( + "Bookings are not permitted for this item. The circulation rules set the booking period to zero days." + ); + } + + if (hasNull) { + const suggestions = []; + if (showItemDetailsSelects) suggestions.push($__("item type")); + if (showPickupLocationSelect) + suggestions.push($__("pickup location")); + if (showPatronSelect) suggestions.push($__("patron")); + + if (suggestions.length > 0) { + const suggestionText = suggestions.join($__(" or ")); + return $__( + "No circulation rule is defined for this combination. Try a different %s." + ).replace("%s", suggestionText); + } + } + + const both = showItemDetailsSelects && showPickupLocationSelect; + if (both) { + return $__( + "No valid booking period is available with the current selection. Try a different item type or pickup location." + ); + } + if (showItemDetailsSelects) { + return $__( + "No valid booking period is available with the current selection. Try a different item type." + ); + } + if (showPickupLocationSelect) { + return $__( + "No valid booking period is available with the current selection. Try a different pickup location." + ); + } + return $__( + "No valid booking period is available for this record with your current settings. Please try again later or contact your library." + ); + }); + + const showCapacityWarning = computed(() => { + const dataReady = + !loading.value?.bookings && + !loading.value?.checkouts && + !loading.value?.bookableItems; + const hasItems = (bookableItems.value?.length ?? 0) > 0; + const hasRules = (circulationRules.value?.length ?? 0) > 0; + + const context = circulationRulesContext.value; + const hasCompleteContext = + context && + context.patron_category_id != null && + context.item_type_id != null && + context.library_id != null; + + const rulesReady = !loading.value?.circulationRules; + + return ( + dataReady && + rulesReady && + hasItems && + hasRules && + hasCompleteContext && + !hasPositiveCapacity.value + ); + }); + + return { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs new file mode 100644 index 00000000000..1204fdc07f9 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs @@ -0,0 +1,93 @@ +import { computed } from "vue"; +import { BookingDate } from "../lib/booking/BookingDate.mjs"; +import { + toEffectiveRules, + findFirstBlockingDate, +} from "../lib/booking/availability.mjs"; +import { calculateConstraintHighlighting } from "../lib/booking/highlighting.mjs"; +import { subDays } from "../lib/booking/BookingDate.mjs"; + +/** + * Provides reactive constraint highlighting data for the calendar based on + * selected start date, circulation rules, and constraint options. + * + * This composable also clamps the highlighting range to respect actual + * availability - if all items become unavailable before the theoretical + * end date, the highlighting stops at the last available date. + * + * @param {import('../types/bookings').BookingStoreLike} store + * @param {import('../types/bookings').RefLike|undefined} constraintOptionsRef + * @returns {{ + * highlightingData: import('vue').ComputedRef + * }} + */ +export function useConstraintHighlighting(store, constraintOptionsRef) { + const highlightingData = computed(() => { + const startISO = store.selectedDateRange?.[0]; + if (!startISO) return null; + const opts = constraintOptionsRef?.value ?? {}; + const effectiveRules = toEffectiveRules(store.circulationRules, opts); + const baseHighlighting = calculateConstraintHighlighting( + BookingDate.from(startISO).toDate(), + effectiveRules, + opts + ); + if (!baseHighlighting) return null; + + const holidays = store.holidays || []; + + // Check if there's a blocking date that should clamp the highlighting range + const hasRequiredData = + Array.isArray(store.bookings) && + Array.isArray(store.checkouts) && + Array.isArray(store.bookableItems) && + store.bookableItems.length > 0; + + if (!hasRequiredData) { + return { + ...baseHighlighting, + holidays, + }; + } + + const { firstBlockingDate } = findFirstBlockingDate( + baseHighlighting.startDate, + baseHighlighting.targetEndDate, + store.bookings, + store.checkouts, + store.bookableItems, + store.bookingItemId, + store.bookingId, + effectiveRules + ); + + // If a blocking date was found within the range, clamp targetEndDate + if (firstBlockingDate) { + const clampedEndDate = subDays(firstBlockingDate, 1).toDate(); + + // Only clamp if it's actually earlier than the theoretical end + if (clampedEndDate < baseHighlighting.targetEndDate) { + // Filter blocked intermediate dates to only include those within the new range + const clampedBlockedDates = + baseHighlighting.blockedIntermediateDates.filter( + date => date <= clampedEndDate + ); + + return { + ...baseHighlighting, + targetEndDate: clampedEndDate, + blockedIntermediateDates: clampedBlockedDates, + holidays, + _clampedDueToAvailability: true, + }; + } + } + + return { + ...baseHighlighting, + holidays, + }; + }); + + return { highlightingData }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs new file mode 100644 index 00000000000..3bfe3ae0eef --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs @@ -0,0 +1,307 @@ +import { onMounted, onUnmounted, watch } from "vue"; +import flatpickr from "flatpickr"; +import { isoArrayToDates } from "../lib/booking/BookingDate.mjs"; +import { useBookingStore } from "../../../stores/bookings.js"; +import { + applyCalendarHighlighting, + clearCalendarHighlighting, +} from "../lib/adapters/calendar/highlighting.mjs"; +import { + createOnDayCreate, + createOnClose, + createOnChange, +} from "../lib/adapters/calendar/events.mjs"; +import { getVisibleCalendarDates } from "../lib/adapters/calendar/visibility.mjs"; +import { buildMarkerGrid } from "../lib/adapters/calendar/markers.mjs"; +import { + getCurrentLanguageCode, + preloadFlatpickrLocale, +} from "../lib/adapters/calendar/locale.mjs"; +import { + CLASS_FLATPICKR_DAY, + CLASS_BOOKING_MARKER_GRID, +} from "../lib/booking/constants.mjs"; +import { + getBookingMarkersForDate, + aggregateMarkersByType, +} from "../lib/booking/markers.mjs"; +import { useConstraintHighlighting } from "./useConstraintHighlighting.mjs"; +import { win } from "../lib/adapters/globals.mjs"; +import { calendarLogger } from "../lib/booking/logger.mjs"; + +/** + * Creates a ref-like accessor for tooltip properties. + * Supports both consolidated tooltip object and legacy individual refs. + * @param {Object} tooltip - Consolidated tooltip reactive object + * @param {string} prop - Property name (markers, visible, x, y) + * @param {Object} legacyRef - Legacy individual ref (fallback) + * @param {*} defaultValue - Default value if neither is provided + */ +function createTooltipAccessor(tooltip, prop, legacyRef, defaultValue) { + if (tooltip) { + return { + get value() { + return tooltip[prop]; + }, + set value(v) { + tooltip[prop] = v; + }, + }; + } + if (legacyRef) return legacyRef; + return { value: defaultValue }; +} + +/** + * Flatpickr integration for the bookings calendar. + * + * Date type policy: + * - Store holds ISO strings in selectedDateRange (single source of truth) + * - Flatpickr works with Date objects; we convert at the boundary + * - API receives ISO strings + * + * @param {{ value: HTMLInputElement|null }} elRef - ref to the input element + * @param {Object} options + * @param {import('../types/bookings').BookingStoreLike} [options.store] - booking store (defaults to pinia store) + * @param {import('../types/bookings').RefLike} options.disableFnRef - ref to disable fn + * @param {import('../types/bookings').RefLike} options.constraintOptionsRef + * @param {(msg: string) => void} options.setError - set error message callback + * @param {import('vue').Ref<{visibleStartDate?: Date|null, visibleEndDate?: Date|null}>} [options.visibleRangeRef] + * @param {{markers: Array, visible: boolean, x: number, y: number}} [options.tooltip] - Consolidated tooltip state (preferred) + * @param {import('../types/bookings').RefLike} [options.tooltipMarkersRef] - Legacy: individual markers ref + * @param {import('../types/bookings').RefLike} [options.tooltipVisibleRef] - Legacy: individual visible ref + * @param {import('../types/bookings').RefLike} [options.tooltipXRef] - Legacy: individual x ref + * @param {import('../types/bookings').RefLike} [options.tooltipYRef] - Legacy: individual y ref + * @returns {{ clear: () => void, getInstance: () => import('../types/bookings').FlatpickrInstanceWithHighlighting | null }} + */ +export function useFlatpickr(elRef, options) { + const store = options.store || useBookingStore(); + + const disableFnRef = options.disableFnRef; + const constraintOptionsRef = options.constraintOptionsRef; + const setError = options.setError; + const visibleRangeRef = options.visibleRangeRef; + + const tooltip = options.tooltip; + const tooltipMarkersRef = createTooltipAccessor( + tooltip, + "markers", + options.tooltipMarkersRef, + [] + ); + const tooltipVisibleRef = createTooltipAccessor( + tooltip, + "visible", + options.tooltipVisibleRef, + false + ); + const tooltipXRef = createTooltipAccessor( + tooltip, + "x", + options.tooltipXRef, + 0 + ); + const tooltipYRef = createTooltipAccessor( + tooltip, + "y", + options.tooltipYRef, + 0 + ); + + let fp = null; + + /** + * Creates a handler that updates visibleRangeRef when calendar view changes. + * Used for onReady, onMonthChange, and onYearChange events. + * @returns {import('flatpickr/dist/types/options').Hook} + */ + function createVisibleRangeHandler() { + return function (_selectedDates, _dateStr, instance) { + if (!visibleRangeRef || !instance) return; + try { + const visible = getVisibleCalendarDates(instance); + if (visible?.length > 0) { + visibleRangeRef.value = { + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + }; + } + } catch (e) { + calendarLogger.warn("useFlatpickr", "Failed to update visible range", e); + } + }; + } + + function toDateArrayFromStore() { + return isoArrayToDates(store.selectedDateRange || []); + } + + function setDisableOnInstance() { + if (!fp) return; + const disableFn = disableFnRef?.value; + fp.set("disable", [ + typeof disableFn === "function" ? disableFn : () => false, + ]); + } + + function syncInstanceDatesFromStore() { + if (!fp) return; + try { + const dates = toDateArrayFromStore(); + if (dates.length > 0) { + fp.setDate(dates, false); + if (dates[0] && fp.jumpToDate) fp.jumpToDate(dates[0]); + } else { + fp.clear(); + } + } catch (e) { + calendarLogger.warn("useFlatpickr", "Failed to sync dates from store", e); + } + } + + onMounted(async () => { + if (!elRef?.value) return; + + await preloadFlatpickrLocale(); + + const dateFormat = + typeof win("flatpickr_dateformat_string") === "string" + ? /** @type {string} */ (win("flatpickr_dateformat_string")) + : "d.m.Y"; + + const langCode = getCurrentLanguageCode(); + const locale = + langCode !== "en" + ? win("flatpickr")?.["l10ns"]?.[langCode] + : undefined; + + /** @type {Partial} */ + const baseConfig = { + mode: "range", + minDate: new Date().fp_incr(1), + disable: [() => false], + clickOpens: true, + dateFormat, + ...(locale && { locale }), + allowInput: false, + onChange: createOnChange(store, { + setError, + tooltipVisibleRef: tooltipVisibleRef || { value: false }, + constraintOptionsRef, + }), + onClose: createOnClose( + tooltipMarkersRef || { value: [] }, + tooltipVisibleRef || { value: false } + ), + onDayCreate: createOnDayCreate( + store, + tooltipMarkersRef || { value: [] }, + tooltipVisibleRef || { value: false }, + tooltipXRef || { value: 0 }, + tooltipYRef || { value: 0 } + ), + }; + + const updateVisibleRange = createVisibleRangeHandler(); + + fp = flatpickr(elRef.value, { + ...baseConfig, + onReady: [updateVisibleRange], + onMonthChange: [updateVisibleRange], + onYearChange: [updateVisibleRange], + }); + + setDisableOnInstance(); + syncInstanceDatesFromStore(); + }); + + if (disableFnRef) { + watch(disableFnRef, () => { + setDisableOnInstance(); + }); + } + + if (constraintOptionsRef) { + const { highlightingData } = useConstraintHighlighting( + store, + constraintOptionsRef + ); + watch( + () => highlightingData.value, + data => { + if (!fp) return; + if (!data) { + const instWithCache = + /** @type {import('../types/bookings').FlatpickrInstanceWithHighlighting} */ ( + fp + ); + instWithCache._constraintHighlighting = null; + clearCalendarHighlighting(fp); + return; + } + applyCalendarHighlighting(fp, data); + } + ); + } + + watch( + () => store.unavailableByDate, + () => { + if (!fp || !fp.calendarContainer) return; + try { + const dayElements = fp.calendarContainer.querySelectorAll( + `.${CLASS_FLATPICKR_DAY}` + ); + dayElements.forEach(dayElem => { + const existingGrids = dayElem.querySelectorAll( + `.${CLASS_BOOKING_MARKER_GRID}` + ); + existingGrids.forEach(grid => grid.remove()); + + /** @type {import('flatpickr/dist/types/instance').DayElement} */ + const el = + /** @type {import('flatpickr/dist/types/instance').DayElement} */ ( + dayElem + ); + if (!el.dateObj) return; + const markersForDots = getBookingMarkersForDate( + store.unavailableByDate, + el.dateObj, + store.bookableItems + ); + if (markersForDots.length > 0) { + const aggregated = + aggregateMarkersByType(markersForDots); + const grid = buildMarkerGrid(aggregated); + if (grid.hasChildNodes()) dayElem.appendChild(grid); + } + }); + } catch (e) { + calendarLogger.warn("useFlatpickr", "Failed to update marker grids", e); + } + }, + { deep: true } + ); + + watch( + () => store.selectedDateRange, + () => { + syncInstanceDatesFromStore(); + }, + { deep: true } + ); + + onUnmounted(() => { + if (fp?.destroy) fp.destroy(); + fp = null; + }); + + return { + clear() { + if (fp?.clear) fp.clear(); + }, + getInstance() { + return fp; + }, + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFormDefaults.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFormDefaults.mjs new file mode 100644 index 00000000000..b6626cef854 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFormDefaults.mjs @@ -0,0 +1,107 @@ +import { watch } from "vue"; +import { idsEqual } from "../lib/booking/id-utils.mjs"; + +/** + * Combined form defaults composable that handles auto-populating form fields. + * + * Responsibilities: + * - Set default pickup library based on OPAC settings, patron, or first item + * - Auto-derive item type from constrained types or selected item + * + * @param {Object} options + * @param {import('vue').Ref} options.bookingPickupLibraryId - Pickup library ref + * @param {import('vue').Ref} options.bookingPatron - Selected patron ref + * @param {import('vue').Ref} options.pickupLocations - Available pickup locations ref + * @param {import('vue').Ref} options.bookableItems - Available bookable items ref + * @param {import('vue').Ref} options.bookingItemtypeId - Selected item type ref + * @param {import('vue').Ref} options.bookingItemId - Selected item ref + * @param {import('vue').ComputedRef} options.constrainedItemTypes - Constrained item types computed + * @param {boolean|string|null} [options.opacDefaultBookingLibraryEnabled] - OPAC default library setting + * @param {string|null} [options.opacDefaultBookingLibrary] - OPAC default library value + * @returns {{ stopDefaultPickup: import('vue').WatchStopHandle, stopDerivedItemType: import('vue').WatchStopHandle }} + */ +export function useFormDefaults(options) { + const { + bookingPickupLibraryId, + bookingPatron, + pickupLocations, + bookableItems, + bookingItemtypeId, + bookingItemId, + constrainedItemTypes, + opacDefaultBookingLibraryEnabled = null, + opacDefaultBookingLibrary = null, + } = options; + + const stopDefaultPickup = watch( + [() => bookingPatron.value, () => pickupLocations.value], + ([patron, locations]) => { + if (bookingPickupLibraryId.value) return; + const list = Array.isArray(locations) ? locations : []; + + const enabled = + opacDefaultBookingLibraryEnabled === true || + String(opacDefaultBookingLibraryEnabled) === "1"; + const def = opacDefaultBookingLibrary ?? ""; + if (enabled && def && list.some(l => idsEqual(l.library_id, def))) { + bookingPickupLibraryId.value = def; + return; + } + + if (patron && list.length > 0) { + const patronLib = patron.library_id; + if (list.some(l => idsEqual(l.library_id, patronLib))) { + bookingPickupLibraryId.value = patronLib; + return; + } + } + + const items = Array.isArray(bookableItems.value) + ? bookableItems.value + : []; + if (items.length > 0 && list.length > 0) { + const homeLib = items[0]?.home_library_id; + if (list.some(l => idsEqual(l.library_id, homeLib))) { + bookingPickupLibraryId.value = homeLib; + } + } + }, + { immediate: true } + ); + + const stopDerivedItemType = watch( + [ + constrainedItemTypes, + () => bookingItemId.value, + () => bookableItems.value, + ], + ([types, itemId, items]) => { + if ( + !bookingItemtypeId.value && + Array.isArray(types) && + types.length === 1 + ) { + bookingItemtypeId.value = types[0].item_type_id; + return; + } + + if (!bookingItemtypeId.value && itemId) { + const item = (items || []).find(i => + idsEqual(i.item_id, itemId) + ); + if (item) { + bookingItemtypeId.value = + item.effective_item_type_id || + item.item_type_id || + null; + } + } + }, + { immediate: true } + ); + + return { + stopDefaultPickup, + stopDerivedItemType, + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs new file mode 100644 index 00000000000..c9fda97fc03 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs @@ -0,0 +1,105 @@ +import { watchEffect, ref, watch } from "vue"; +import { formatYMD, addMonths } from "../lib/booking/BookingDate.mjs"; + +/** + * Watch core selections and fetch pickup locations, circulation rules, and holidays. + * De-duplicates rules fetches by building a stable key from inputs. + * + * @param {Object} options + * @param {import('../types/bookings').StoreWithActions} options.store + * @param {import('../types/bookings').RefLike} options.bookingPatron + * @param {import('../types/bookings').RefLike} options.bookingPickupLibraryId + * @param {import('../types/bookings').RefLike} options.bookingItemtypeId + * @param {import('../types/bookings').RefLike>} options.constrainedItemTypes + * @param {import('../types/bookings').RefLike>} options.selectedDateRange + * @param {string|import('../types/bookings').RefLike} options.biblionumber + * @returns {{ lastRulesKey: import('vue').Ref }} + */ +export function useRulesFetcher(options) { + const { + store, + bookingPatron, + bookingPickupLibraryId, + bookingItemtypeId, + constrainedItemTypes, + selectedDateRange, + biblionumber, + } = options; + + const lastRulesKey = ref(null); + const lastHolidaysLibrary = ref(null); + + watchEffect( + () => { + const patronId = bookingPatron.value?.patron_id; + const biblio = + typeof biblionumber === "object" + ? biblionumber.value + : biblionumber; + + if (patronId && biblio) { + store.fetchPickupLocations(biblio, patronId); + } + + const patron = bookingPatron.value; + const derivedItemTypeId = + bookingItemtypeId.value ?? + (Array.isArray(constrainedItemTypes.value) && + constrainedItemTypes.value.length === 1 + ? constrainedItemTypes.value[0].item_type_id + : undefined); + + const rulesParams = { + patron_category_id: patron?.category_id, + item_type_id: derivedItemTypeId, + library_id: bookingPickupLibraryId.value, + }; + const key = buildRulesKey(rulesParams); + if (lastRulesKey.value !== key) { + lastRulesKey.value = key; + store.invalidateCalculatedDue(); + store.fetchCirculationRules(rulesParams); + } + }, + { flush: "post" } + ); + + watch( + () => bookingPickupLibraryId.value, + libraryId => { + if (libraryId === lastHolidaysLibrary.value) { + return; + } + lastHolidaysLibrary.value = libraryId; + + const today = new Date(); + const oneYearLater = addMonths(today, 12); + store.fetchHolidays( + libraryId, + formatYMD(today), + formatYMD(oneYearLater) + ); + }, + { immediate: true } + ); + + return { lastRulesKey }; +} + +/** + * Stable, explicit, order-preserving key builder to avoid JSON quirks + * + * @param {import('../types/bookings').RulesParams} params + * @returns {string} + * @exported for testability + */ +export function buildRulesKey(params) { + return [ + ["pc", params.patron_category_id], + ["it", params.item_type_id], + ["lib", params.library_id], + ] + .filter(([, v]) => v ?? v === 0) + .map(([k, v]) => `${k}=${String(v)}`) + .join("|"); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js new file mode 100644 index 00000000000..2d26f5a967c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js @@ -0,0 +1,272 @@ +/** + * @module opacBookingApi + * @description Service module for all OPAC booking-related API calls. + * All functions return promises and use async/await. + * + * ## Stub Functions + * + * Some functions are stubs that exist only for API compatibility with the + * staff interface booking module: + * + * - `fetchPatrons()` - Returns empty array. Patron search is not needed in OPAC + * because the logged-in patron is automatically used. + * + * These stubs allow the booking components to use the same store actions + * regardless of whether they're running in staff or OPAC context. + * + * ## Relationship with staff-interface.js + * + * This module mirrors the API of staff-interface.js but uses public API endpoints. + * The two files share ~60% similar code. If modifying one, check if the same + * change is needed in the other. + */ + +import { bookingValidation } from "../../booking/validation-messages.js"; + +/** + * Fetches bookable items for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch items for + * @returns {Promise>} Array of bookable items + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchBookableItems(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/items`, + { + headers: { + "x-koha-embed": "+strings", + }, + } + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_bookable_items_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches bookings for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch bookings for + * @returns {Promise>} Array of bookings + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchBookings(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/public/biblios/${encodeURIComponent( + biblionumber + )}/bookings?_per_page=-1&q={"status":{"-in":["new","pending","active"]}}` + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_bookings_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches checkouts for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch checkouts for + * @returns {Promise>} Array of checkouts + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchCheckouts(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/checkouts` + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_checkouts_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches a single patron by ID + * @param {number|string} patronId - The ID of the patron to fetch + * @returns {Promise} The patron object + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchPatron(patronId) { + const response = await fetch(`/api/v1/public/patrons/${patronId}`, { + headers: { "x-koha-embed": "library" }, + }); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_patron_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Searches for patrons - not used in OPAC + * @returns {Promise} + */ +export async function fetchPatrons() { + return []; +} + +/** + * Fetches pickup locations for a biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for + * @returns {Promise>} Array of pickup location objects + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchPickupLocations(biblionumber, patronId) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const params = new URLSearchParams({ + _order_by: "name", + _per_page: "-1", + }); + + if (patronId) { + params.append("patron_id", patronId); + } + + const response = await fetch( + `/api/v1/public/biblios/${encodeURIComponent( + biblionumber + )}/pickup_locations?${params.toString()}` + ); + + if (!response.ok) { + throw bookingValidation.validationError( + "fetch_pickup_locations_failed", + { + status: response.status, + statusText: response.statusText, + } + ); + } + + return await response.json(); +} + +/** + * Fetches circulation rules for booking constraints + * Now uses the enhanced circulation_rules endpoint with date calculation capabilities + * @param {Object} params - Parameters for circulation rules query + * @param {string|number} [params.patron_category_id] - Patron category ID + * @param {string|number} [params.item_type_id] - Item type ID + * @param {string|number} [params.library_id] - Library ID + * @param {string} [params.start_date] - Start date for calculations (ISO format) + * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules) + * @param {boolean} [params.calculate_dates] - Whether to calculate dates (defaults to true for bookings) + * @returns {Promise} Object containing circulation rules with calculated dates + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchCirculationRules(params = {}) { + const filteredParams = {}; + for (const key in params) { + if ( + params[key] !== null && + params[key] !== undefined && + params[key] !== "" + ) { + filteredParams[key] = params[key]; + } + } + + if (filteredParams.calculate_dates === undefined) { + filteredParams.calculate_dates = true; + } + + if (!filteredParams.rules) { + filteredParams.rules = + "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod"; + } + + const urlParams = new URLSearchParams(); + Object.entries(filteredParams).forEach(([k, v]) => { + if (v === undefined || v === null) return; + urlParams.set(k, String(v)); + }); + + const response = await fetch( + `/api/v1/public/circulation_rules?${urlParams.toString()}` + ); + + if (!response.ok) { + throw bookingValidation.validationError( + "fetch_circulation_rules_failed", + { + status: response.status, + statusText: response.statusText, + } + ); + } + + return await response.json(); +} + +/** + * Fetches holidays (closed days) for a library + * @param {string} libraryId - The library branchcode + * @param {string} [from] - Start date (ISO format), defaults to today + * @param {string} [to] - End date (ISO format), defaults to 3 months from start + * @returns {Promise} Array of holiday dates in YYYY-MM-DD format + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchHolidays(libraryId, from, to) { + if (!libraryId) { + return []; + } + + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + + const url = `/api/v1/public/libraries/${encodeURIComponent( + libraryId + )}/holidays${params.toString() ? `?${params.toString()}` : ""}`; + + const response = await fetch(url); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_holidays_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +export async function createBooking() { + return {}; +} + +export async function updateBooking() { + return {}; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js new file mode 100644 index 00000000000..2c97e2a450e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js @@ -0,0 +1,417 @@ +/** + * @module bookingApi + * @description Service module for all booking-related API calls. + * All functions return promises and use async/await. + */ + +import { bookingValidation } from "../../booking/validation-messages.js"; +import { buildPatronSearchQuery } from "../patron.mjs"; + +/** + * Fetches bookable items for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch items for + * @returns {Promise>} Array of bookable items + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchBookableItems(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/biblios/${encodeURIComponent(biblionumber)}/items?bookable=1`, + { + headers: { + "x-koha-embed": "+strings,item_type", + }, + } + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_bookable_items_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches bookings for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch bookings for + * @returns {Promise>} Array of bookings + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchBookings(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/biblios/${encodeURIComponent( + biblionumber + )}/bookings?_per_page=-1&q={"status":{"-in":["new","pending","active"]}}` + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_bookings_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches checkouts for a given biblionumber + * @param {number|string} biblionumber - The biblionumber to fetch checkouts for + * @returns {Promise>} Array of checkouts + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchCheckouts(biblionumber) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const response = await fetch( + `/api/v1/biblios/${encodeURIComponent(biblionumber)}/checkouts` + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_checkouts_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Fetches a single patron by ID + * @param {number|string} patronId - The ID of the patron to fetch + * @returns {Promise} The patron object + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchPatron(patronId) { + if (!patronId) { + throw bookingValidation.validationError("patron_id_required"); + } + + const response = await fetch( + `/api/v1/patrons/${encodeURIComponent(patronId)}`, + { + headers: { "x-koha-embed": "library" }, + } + ); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_patron_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Searches for patrons matching a search term + * @param {string} term - The search term to match against patron names, cardnumbers, etc. + * @param {number} [page=1] - The page number for pagination + * @returns {Promise} Object containing patron search results + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchPatrons(term, page = 1) { + if (!term) { + return { results: [] }; + } + + const query = buildPatronSearchQuery(term, { + search_type: "contains", + }); + + const params = new URLSearchParams({ + q: JSON.stringify(query), + _page: String(page), + _per_page: "10", + _order_by: "surname,firstname", + }); + + const response = await fetch(`/api/v1/patrons?${params.toString()}`, { + headers: { + "x-koha-embed": "library", + Accept: "application/json", + }, + }); + + if (!response.ok) { + const error = bookingValidation.validationError( + "fetch_patrons_failed", + { + status: response.status, + statusText: response.statusText, + } + ); + + try { + const errorData = await response.json(); + if (errorData.error) { + error.message += ` - ${errorData.error}`; + } + } catch (e) {} + + throw error; + } + + return await response.json(); +} + +/** + * Fetches pickup locations for a biblionumber, optionally filtered by patron + * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for + * @param {number|string|null} [patronId] - Optional patron ID to filter pickup locations + * @returns {Promise>} Array of pickup location objects + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchPickupLocations(biblionumber, patronId) { + if (!biblionumber) { + throw bookingValidation.validationError("biblionumber_required"); + } + + const params = new URLSearchParams({ + _order_by: "name", + _per_page: "-1", + }); + + if (patronId) { + params.append("patron_id", String(patronId)); + } + + const response = await fetch( + `/api/v1/biblios/${encodeURIComponent( + biblionumber + )}/pickup_locations?${params.toString()}` + ); + + if (!response.ok) { + throw bookingValidation.validationError( + "fetch_pickup_locations_failed", + { + status: response.status, + statusText: response.statusText, + } + ); + } + + return await response.json(); +} + +/** + * Fetches circulation rules based on the provided context parameters + * Now uses the enhanced circulation_rules endpoint with date calculation capabilities + * @param {Object} [params={}] - Context parameters for circulation rules + * @param {string|number} [params.patron_category_id] - Patron category ID + * @param {string|number} [params.item_type_id] - Item type ID + * @param {string|number} [params.library_id] - Library ID + * @param {string} [params.start_date] - Start date for calculations (ISO format) + * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules) + * @param {boolean} [params.calculate_dates] - Whether to calculate dates (defaults to true for bookings) + * @returns {Promise} Object containing circulation rules with calculated dates + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchCirculationRules(params = {}) { + const filteredParams = {}; + for (const key in params) { + if ( + params[key] !== null && + params[key] !== undefined && + params[key] !== "" + ) { + filteredParams[key] = params[key]; + } + } + + if (!filteredParams.rules) { + filteredParams.rules = + "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod"; + } + + const urlParams = new URLSearchParams(); + Object.entries(filteredParams).forEach(([k, v]) => { + if (v === undefined || v === null) return; + urlParams.set(k, String(v)); + }); + + const response = await fetch( + `/api/v1/circulation_rules?${urlParams.toString()}` + ); + + if (!response.ok) { + throw bookingValidation.validationError( + "fetch_circulation_rules_failed", + { + status: response.status, + statusText: response.statusText, + } + ); + } + + return await response.json(); +} + +/** + * Fetches holidays (closed days) for a library within a date range + * @param {string} libraryId - The library ID (branchcode) + * @param {string} [from] - Start date for the range (ISO format, e.g., 2024-01-01). Defaults to today. + * @param {string} [to] - End date for the range (ISO format, e.g., 2024-03-31). Defaults to 3 months from 'from'. + * @returns {Promise} Array of holiday dates in YYYY-MM-DD format + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function fetchHolidays(libraryId, from, to) { + if (!libraryId) { + return []; + } + + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + + const url = `/api/v1/libraries/${encodeURIComponent(libraryId)}/holidays${ + params.toString() ? `?${params.toString()}` : "" + }`; + + const response = await fetch(url); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_holidays_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +/** + * Creates a new booking + * @param {Object} bookingData - The booking data to create + * @param {string} bookingData.start_date - Start date of the booking (ISO 8601 format) + * @param {string} bookingData.end_date - End date of the booking (ISO 8601 format) + * @param {number|string} bookingData.biblio_id - Biblionumber for the booking + * @param {number|string} [bookingData.item_id] - Optional item ID for the booking + * @param {number|string} bookingData.patron_id - Patron ID for the booking + * @param {number|string} bookingData.pickup_library_id - Pickup library ID + * @returns {Promise} The created booking object + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function createBooking(bookingData) { + if (!bookingData) { + throw bookingValidation.validationError("booking_data_required"); + } + + const validationError = bookingValidation.validateRequiredFields( + bookingData, + [ + "start_date", + "end_date", + "biblio_id", + "patron_id", + "pickup_library_id", + ] + ); + + if (validationError) { + throw validationError; + } + + const response = await fetch("/api/v1/bookings", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(bookingData), + }); + + if (!response.ok) { + let errorMessage = bookingValidation.validationError( + "create_booking_failed", + { + status: response.status, + statusText: response.statusText, + } + ).message; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage += ` - ${errorData.error}`; + } + } catch (e) {} + /** @type {Error & { status?: number }} */ + const error = Object.assign(new Error(errorMessage), { + status: response.status, + }); + throw error; + } + + return await response.json(); +} + +/** + * Updates an existing booking + * @param {number|string} bookingId - The ID of the booking to update + * @param {Object} bookingData - The updated booking data + * @param {string} [bookingData.start_date] - New start date (ISO 8601 format) + * @param {string} [bookingData.end_date] - New end date (ISO 8601 format) + * @param {number|string} [bookingData.pickup_library_id] - New pickup library ID + * @param {number|string} [bookingData.item_id] - New item ID (if changing the item) + * @returns {Promise} The updated booking object + * @throws {Error} If the request fails or returns a non-OK status + */ +export async function updateBooking(bookingId, bookingData) { + if (!bookingId) { + throw bookingValidation.validationError("booking_id_required"); + } + + if (!bookingData || Object.keys(bookingData).length === 0) { + throw bookingValidation.validationError("no_update_data"); + } + + const response = await fetch( + `/api/v1/bookings/${encodeURIComponent(bookingId)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ ...bookingData, booking_id: bookingId }), + } + ); + + if (!response.ok) { + let errorMessage = bookingValidation.validationError( + "update_booking_failed", + { + status: response.status, + statusText: response.statusText, + } + ).message; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage += ` - ${errorData.error}`; + } + } catch (e) {} + /** @type {Error & { status?: number }} */ + const error = Object.assign(new Error(errorMessage), { + status: response.status, + }); + throw error; + } + + return await response.json(); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs new file mode 100644 index 00000000000..28b5171a57d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs @@ -0,0 +1,608 @@ +/** + * Flatpickr event handler factories. + * @module calendar/events + * + * ## Instance Cache Mutation Pattern + * + * This module mutates the Flatpickr instance directly by attaching custom + * properties to persist state across event callbacks: + * + * - `instance._loanBoundaryTimes` - Map of timestamps marking loan period boundaries + * - `instance._constraintHighlighting` - Cached constraint highlighting data for reapplication + * + * This pattern is used because: + * 1. Flatpickr callbacks don't share closure state between different hooks + * 2. State must persist across month navigation (onMonthChange triggers re-render) + * 3. The instance is the only stable reference available in all callbacks + * + * Cleanup: Properties are deleted when dates are cleared or the instance is destroyed. + */ + +import { + handleBookingDateChange, + deriveEffectiveRules, + findFirstBlockingDate, +} from "../../booking/availability.mjs"; +import { + getBookingMarkersForDate, + aggregateMarkersByType, +} from "../../booking/markers.mjs"; +import { + calculateConstraintHighlighting, + getCalendarNavigationTarget, +} from "../../booking/highlighting.mjs"; +import { + toISO, + formatYMD, + toDayjs, + subDays, +} from "../../booking/BookingDate.mjs"; +import { calendarLogger as logger } from "../../booking/logger.mjs"; +import { + CLASS_BOOKING_DAY_HOVER_LEAD, + CLASS_BOOKING_DAY_HOVER_TRAIL, + CLASS_BOOKING_MARKER_GRID, + CLASS_FLATPICKR_DISABLED, + CALENDAR_NAVIGATION_DELAY_MS, +} from "../../booking/constants.mjs"; +import { getDateFeedbackMessage } from "../../ui/hover-feedback.mjs"; +import { + applyCalendarHighlighting, + clearCalendarHighlighting, +} from "./highlighting.mjs"; +import { getVisibleCalendarDates } from "./visibility.mjs"; +import { buildMarkerGrid } from "./markers.mjs"; + +// ============================================================================ +// Helper functions for createOnChange (extracted for clarity) +// ============================================================================ + +/** + * Filter valid Date objects from selected dates array. + * @param {Date[]} selectedDates + * @returns {Date[]} + */ +function filterValidDates(selectedDates) { + return (selectedDates || []).filter( + d => d instanceof Date && !Number.isNaN(d.getTime()) + ); +} + +/** + * Clear loan boundary cache from instance. + * @param {import('flatpickr/dist/types/instance').Instance} instance + */ +function clearLoanBoundaryCache(instance) { + if (instance) { + const instWithCache = /** @type {any} */ (instance); + delete instWithCache._loanBoundaryTimes; + } +} + +/** + * Sync selected dates to store if changed. + * @param {object} store + * @param {Date[]} validDates + * @returns {string[]} ISO date range + */ +function syncStoreDates(store, validDates) { + const isoDateRange = validDates.map(d => toISO(d)); + const current = store.selectedDateRange || []; + const same = + current.length === isoDateRange.length && + current.every((v, i) => v === isoDateRange[i]); + if (!same) store.selectedDateRange = isoDateRange; + return isoDateRange; +} + +/** + * Compute and cache loan boundary times on the flatpickr instance. + * @param {import('flatpickr/dist/types/instance').Instance} instance + * @param {Date[]} validDates + * @param {object} baseRules + */ +function computeLoanBoundaries(instance, validDates, baseRules) { + if (!instance || validDates.length === 0) return; + try { + const instWithCache = /** @type {any} */ (instance); + const startDate = toDayjs(validDates[0]).startOf("day"); + const issuelength = parseInt(baseRules?.issuelength) || 0; + const renewalperiod = parseInt(baseRules?.renewalperiod) || 0; + const renewalsallowed = parseInt(baseRules?.renewalsallowed) || 0; + const times = new Set(); + // Start date is always a boundary + times.add(startDate.toDate().getTime()); + if (issuelength > 0) { + const initialEnd = startDate + .add(issuelength, "day") + .toDate() + .getTime(); + times.add(initialEnd); + if (renewalperiod > 0 && renewalsallowed > 0) { + for (let k = 1; k <= renewalsallowed; k++) { + const t = startDate + .add(issuelength + k * renewalperiod, "day") + .toDate() + .getTime(); + times.add(t); + } + } + } + instWithCache._loanBoundaryTimes = times; + } catch (e) { + // non-fatal: boundary decoration best-effort + } +} + +/** + * Handle validation result and set error message. + * @param {object} result + * @param {Function|null} setError + */ +function handleValidationResult(result, setError) { + if (typeof setError !== "function") return; + + const isValid = + (result && Object.prototype.hasOwnProperty.call(result, "valid") + ? result.valid + : result?.isValid) ?? true; + + let message = ""; + if (!isValid) { + if (Array.isArray(result?.errors)) { + message = result.errors.join(", "); + } else if (typeof result?.errorMessage === "string") { + message = result.errorMessage; + } else if (result?.errorMessage != null) { + message = String(result.errorMessage); + } else if (result?.errors != null) { + message = String(result.errors); + } + } + setError(message); +} + +/** + * Navigate calendar to show the target end date if needed. + * @param {import('flatpickr/dist/types/instance').Instance} instance + * @param {object} highlightingData + * @param {Function} _getVisibleCalendarDates + * @param {Function} _getCalendarNavigationTarget + */ +function navigateCalendarIfNeeded( + instance, + highlightingData, + _getVisibleCalendarDates, + _getCalendarNavigationTarget +) { + const visible = _getVisibleCalendarDates(instance); + const currentView = + visible?.length > 0 + ? { + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + } + : {}; + const nav = _getCalendarNavigationTarget( + highlightingData.startDate, + highlightingData.targetEndDate, + currentView + ); + if (nav.shouldNavigate && nav.targetDate) { + setTimeout(() => { + if (instance.jumpToDate) { + instance.jumpToDate(nav.targetDate); + } else if (instance.changeMonth) { + if ( + typeof instance.changeYear === "function" && + typeof nav.targetYear === "number" && + instance.currentYear !== nav.targetYear + ) { + instance.changeYear(nav.targetYear); + } + const offset = + typeof instance.currentMonth === "number" + ? nav.targetMonth - instance.currentMonth + : 0; + instance.changeMonth(offset, false); + } + }, CALENDAR_NAVIGATION_DELAY_MS); + } +} + +/** + * Clamp highlighting range to actual availability by finding first blocking date. + * @param {object} highlightingData + * @param {object} store + * @param {object} effectiveRules + * @returns {object} Clamped highlighting data + */ +function clampHighlightingToAvailability( + highlightingData, + store, + effectiveRules +) { + if ( + !highlightingData || + !Array.isArray(store.bookings) || + !Array.isArray(store.checkouts) || + !Array.isArray(store.bookableItems) || + store.bookableItems.length === 0 + ) { + return highlightingData; + } + + const { firstBlockingDate } = findFirstBlockingDate( + highlightingData.startDate, + highlightingData.targetEndDate, + store.bookings, + store.checkouts, + store.bookableItems, + store.bookingItemId, + store.bookingId, + effectiveRules + ); + + if (!firstBlockingDate) return highlightingData; + + const clampedEndDate = subDays(firstBlockingDate, 1).toDate(); + if (clampedEndDate >= highlightingData.targetEndDate) + return highlightingData; + + return { + ...highlightingData, + targetEndDate: clampedEndDate, + blockedIntermediateDates: + highlightingData.blockedIntermediateDates.filter( + date => date <= clampedEndDate + ), + }; +} + +// ============================================================================ +// Main event handler factories +// ============================================================================ + +/** + * Create a Flatpickr `onChange` handler bound to the booking store. + * + * @param {object} store - Booking Pinia store (or compatible shape) + * @param {import('../../../types/bookings').OnChangeOptions} options + * @param {object} [deps] - Optional dependency injection for testing + * @param {Function} [deps.getVisibleCalendarDates] - Override for getVisibleCalendarDates + * @param {Function} [deps.calculateConstraintHighlighting] - Override for calculateConstraintHighlighting + * @param {Function} [deps.handleBookingDateChange] - Override for handleBookingDateChange + * @param {Function} [deps.getCalendarNavigationTarget] - Override for getCalendarNavigationTarget + */ +export function createOnChange( + store, + { + setError = null, + tooltipVisibleRef = null, + constraintOptionsRef = null, + } = {}, + deps = {} +) { + // Use injected dependencies or defaults (clean DI pattern for testing) + const _getVisibleCalendarDates = + deps.getVisibleCalendarDates || getVisibleCalendarDates; + const _calculateConstraintHighlighting = + deps.calculateConstraintHighlighting || calculateConstraintHighlighting; + const _handleBookingDateChange = + deps.handleBookingDateChange || handleBookingDateChange; + const _getCalendarNavigationTarget = + deps.getCalendarNavigationTarget || getCalendarNavigationTarget; + + return function (selectedDates, _dateStr, instance) { + logger.debug("handleDateChange triggered", { selectedDates }); + + const constraintOptions = constraintOptionsRef?.value ?? {}; + const validDates = filterValidDates(selectedDates); + + if ((selectedDates || []).length === 0) { + clearLoanBoundaryCache(instance); + if ( + Array.isArray(store.selectedDateRange) && + store.selectedDateRange.length + ) { + store.selectedDateRange = []; + } + if (typeof setError === "function") setError(""); + return; + } + + if ((selectedDates || []).length > 0 && validDates.length === 0) { + logger.warn( + "All dates invalid, skipping processing to preserve state" + ); + return; + } + + syncStoreDates(store, validDates); + + const baseRules = + (store.circulationRules && store.circulationRules[0]) || {}; + const effectiveRules = deriveEffectiveRules( + baseRules, + constraintOptions + ); + computeLoanBoundaries(instance, validDates, baseRules); + + let calcOptions = {}; + if (instance) { + const visible = _getVisibleCalendarDates(instance); + if (visible?.length > 0) { + calcOptions = { + onDemand: true, + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + }; + } + } + + const result = _handleBookingDateChange( + selectedDates, + effectiveRules, + store.bookings, + store.checkouts, + store.bookableItems, + store.bookingItemId, + store.bookingId, + undefined, + calcOptions + ); + + handleValidationResult(result, setError); + if (tooltipVisibleRef && "value" in tooltipVisibleRef) { + tooltipVisibleRef.value = false; + } + + if (instance && selectedDates.length === 1) { + let highlightingData = _calculateConstraintHighlighting( + selectedDates[0], + effectiveRules, + constraintOptions + ); + + // Clamp to actual availability + highlightingData = clampHighlightingToAvailability( + highlightingData, + store, + effectiveRules + ); + + if (highlightingData) { + applyCalendarHighlighting(instance, highlightingData); + navigateCalendarIfNeeded( + instance, + highlightingData, + _getVisibleCalendarDates, + _getCalendarNavigationTarget + ); + } + } + + if (instance && selectedDates.length === 0) { + const instWithCache = + /** @type {import('../../../types/bookings').FlatpickrInstanceWithHighlighting} */ ( + instance + ); + instWithCache._constraintHighlighting = null; + clearCalendarHighlighting(instance); + } + }; +} + +/** + * Ensure the feedback bar element exists inside the flatpickr calendar + * container. Creates it on first call and reuses it thereafter. + * + * @param {import('flatpickr/dist/types/instance').Instance} fp + * @returns {HTMLDivElement} + */ +function ensureFeedbackBar(fp) { + const container = fp.calendarContainer; + let bar = container.querySelector(".booking-hover-feedback"); + if (!bar) { + bar = document.createElement("div"); + bar.className = "booking-hover-feedback"; + bar.setAttribute("role", "status"); + bar.setAttribute("aria-live", "polite"); + container.appendChild(bar); + } + return /** @type {HTMLDivElement} */ (bar); +} + +/** @type {number|null} */ +let _feedbackHideTimer = null; + +/** + * Update the feedback bar inside the flatpickr calendar container. + * Hides are deferred so that rapid mouseout→mouseover between adjacent + * days doesn't trigger a visible flicker. + * + * @param {HTMLDivElement} bar + * @param {{ message: string, variant: string } | null} feedback + */ +function updateFeedbackBar(bar, feedback) { + if (!feedback) { + if (_feedbackHideTimer == null) { + _feedbackHideTimer = setTimeout(() => { + _feedbackHideTimer = null; + bar.classList.remove( + "booking-hover-feedback--visible", + "booking-hover-feedback--info", + "booking-hover-feedback--warning", + "booking-hover-feedback--danger" + ); + }, 16); + } + return; + } + if (_feedbackHideTimer != null) { + clearTimeout(_feedbackHideTimer); + _feedbackHideTimer = null; + } + bar.textContent = feedback.message; + bar.classList.remove( + "booking-hover-feedback--info", + "booking-hover-feedback--warning", + "booking-hover-feedback--danger" + ); + bar.classList.add( + "booking-hover-feedback--visible", + `booking-hover-feedback--${feedback.variant}` + ); +} + +/** + * Create Flatpickr `onDayCreate` handler. + * + * Renders per-day marker dots, hover classes, and shows a tooltip with + * aggregated markers. Appends a contextual feedback bar inside the + * flatpickr calendar container (matching upstream placement). + * Reapplies constraint highlighting across month navigation using the + * instance's cached highlighting data. + * + * @param {object} store - booking store or compatible state + * @param {import('../../../types/bookings').RefLike} tooltipMarkers - ref of markers shown in tooltip + * @param {import('../../../types/bookings').RefLike} tooltipVisible - visibility ref for tooltip + * @param {import('../../../types/bookings').RefLike} tooltipX - x position ref + * @param {import('../../../types/bookings').RefLike} tooltipY - y position ref + * @returns {import('flatpickr/dist/types/options').Hook} + */ +export function createOnDayCreate( + store, + tooltipMarkers, + tooltipVisible, + tooltipX, + tooltipY +) { + return function ( + ...[ + , + , + /** @type {import('flatpickr/dist/types/instance').Instance} */ fp, + /** @type {import('flatpickr/dist/types/instance').DayElement} */ dayElem, + ] + ) { + const existingGrids = dayElem.querySelectorAll( + `.${CLASS_BOOKING_MARKER_GRID}` + ); + existingGrids.forEach(grid => grid.remove()); + + const el = + /** @type {import('flatpickr/dist/types/instance').DayElement} */ ( + dayElem + ); + const dateStrForMarker = formatYMD(el.dateObj); + const markersForDots = getBookingMarkersForDate( + store.unavailableByDate, + dateStrForMarker, + store.bookableItems + ); + + if (markersForDots.length > 0) { + const aggregatedMarkers = aggregateMarkersByType(markersForDots); + const grid = buildMarkerGrid(aggregatedMarkers); + if (grid.hasChildNodes()) dayElem.appendChild(grid); + } + + dayElem.addEventListener("mouseover", () => { + const hoveredDateStr = formatYMD(el.dateObj); + const currentTooltipMarkersData = getBookingMarkersForDate( + store.unavailableByDate, + hoveredDateStr, + store.bookableItems + ); + + el.classList.remove( + CLASS_BOOKING_DAY_HOVER_LEAD, + CLASS_BOOKING_DAY_HOVER_TRAIL + ); + let hasLeadMarker = false; + let hasTrailMarker = false; + + currentTooltipMarkersData.forEach(marker => { + if (marker.type === "lead") hasLeadMarker = true; + if (marker.type === "trail") hasTrailMarker = true; + }); + + if (hasLeadMarker) { + el.classList.add(CLASS_BOOKING_DAY_HOVER_LEAD); + } + if (hasTrailMarker) { + el.classList.add(CLASS_BOOKING_DAY_HOVER_TRAIL); + } + + if (currentTooltipMarkersData.length > 0) { + tooltipMarkers.value = currentTooltipMarkersData; + tooltipVisible.value = true; + + const rect = el.getBoundingClientRect(); + tooltipX.value = rect.right + window.scrollX + 8; + tooltipY.value = rect.top + window.scrollY + rect.height / 2; + } else { + tooltipMarkers.value = []; + tooltipVisible.value = false; + } + + const feedbackBar = ensureFeedbackBar(fp); + try { + const isDisabled = el.classList.contains(CLASS_FLATPICKR_DISABLED); + const rules = + (store.circulationRules && store.circulationRules[0]) || {}; + const feedback = getDateFeedbackMessage(el.dateObj, { + isDisabled, + selectedDateRange: store.selectedDateRange || [], + circulationRules: rules, + unavailableByDate: store.unavailableByDate, + holidays: store.holidays || [], + }); + updateFeedbackBar(feedbackBar, feedback); + } catch (_e) { + updateFeedbackBar(feedbackBar, null); + } + }); + + dayElem.addEventListener("mouseout", () => { + dayElem.classList.remove( + CLASS_BOOKING_DAY_HOVER_LEAD, + CLASS_BOOKING_DAY_HOVER_TRAIL + ); + tooltipVisible.value = false; + const feedbackBar = ensureFeedbackBar(fp); + updateFeedbackBar(feedbackBar, null); + }); + + // Reapply constraint highlighting if it exists (for month navigation, etc.) + const fpWithCache = + /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../../types/bookings').ConstraintHighlighting | null }} */ ( + fp + ); + if ( + fpWithCache && + fpWithCache._constraintHighlighting && + fpWithCache.calendarContainer + ) { + requestAnimationFrame(() => { + applyCalendarHighlighting( + fpWithCache, + fpWithCache._constraintHighlighting + ); + }); + } + }; +} + +/** + * Create Flatpickr `onClose` handler to clear tooltip state. + * @param {import('../../../types/bookings').RefLike} tooltipMarkers + * @param {import('../../../types/bookings').RefLike} tooltipVisible + */ +export function createOnClose(tooltipMarkers, tooltipVisible) { + return function () { + tooltipMarkers.value = []; + tooltipVisible.value = false; + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs new file mode 100644 index 00000000000..18057b51255 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs @@ -0,0 +1,308 @@ +/** + * Calendar constraint highlighting utilities. + * @module calendar/highlighting + * + * ## Instance Cache Pattern + * + * This module reads and writes custom properties on the Flatpickr instance: + * + * - `instance._constraintHighlighting` - Written by applyCalendarHighlighting to + * cache highlighting data for re-application after month navigation + * - `instance._loanBoundaryTimes` - Read from instance (set by events.mjs) to + * apply loan boundary styling to specific dates + * + * See events.mjs for the full explanation of why instance mutation is used. + */ + +import { calendarLogger as logger } from "../../booking/logger.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_INTERMEDIATE_BLOCKED, + CLASS_BOOKING_LOAN_BOUNDARY, + CLASS_BOOKING_OVERRIDE_ALLOWED, + CLASS_FLATPICKR_DAY, + CLASS_FLATPICKR_DISABLED, + CLASS_FLATPICKR_NOT_ALLOWED, + DATA_ATTRIBUTE_BOOKING_OVERRIDE, + HIGHLIGHTING_MAX_RETRIES, +} from "../../booking/constants.mjs"; +import { + applyClickPrevention, + applyHolidayClickPrevention, +} from "./prevention.mjs"; + +/** + * Clear constraint highlighting from the Flatpickr calendar. + * + * @param {import('flatpickr/dist/types/instance').Instance} instance + * @returns {void} + */ +export function clearCalendarHighlighting(instance) { + logger.debug("Clearing calendar highlighting"); + + if (!instance || !instance.calendarContainer) return; + + // Query separately to accommodate simple test DOM mocks + const lists = [ + instance.calendarContainer.querySelectorAll( + `.${CLASS_BOOKING_CONSTRAINED_RANGE_MARKER}` + ), + instance.calendarContainer.querySelectorAll( + `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}` + ), + instance.calendarContainer.querySelectorAll( + `.${CLASS_BOOKING_LOAN_BOUNDARY}` + ), + ]; + const existingHighlights = lists.flatMap(list => Array.from(list || [])); + existingHighlights.forEach(elem => { + elem.classList.remove( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_INTERMEDIATE_BLOCKED, + CLASS_BOOKING_LOAN_BOUNDARY + ); + }); +} + +/** + * Fix incorrect date unavailability via a CSS-based override. + * Used for target end dates and dates after holidays that Flatpickr incorrectly blocks. + * + * @param {NodeListOf|Element[]} dayElements + * @param {Date} targetDate + * @param {string} [logContext="target end date"] + * @returns {void} + */ +export function fixDateAvailability( + dayElements, + targetDate, + logContext = "target end date" +) { + if (!dayElements || typeof dayElements.length !== "number") { + logger.warn( + `Invalid dayElements passed to fixDateAvailability (${logContext})`, + dayElements + ); + return; + } + + const targetElem = Array.from(dayElements).find( + elem => elem.dateObj && elem.dateObj.getTime() === targetDate.getTime() + ); + + if (!targetElem) { + logger.debug(`Date element not found for ${logContext}`, targetDate); + return; + } + + // Mark the element as explicitly allowed, overriding Flatpickr's styles + targetElem.classList.remove(CLASS_FLATPICKR_NOT_ALLOWED); + targetElem.removeAttribute("tabindex"); + targetElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED); + + targetElem.setAttribute(DATA_ATTRIBUTE_BOOKING_OVERRIDE, "allowed"); + + logger.debug(`Applied CSS override for ${logContext} availability`, { + targetDate, + element: targetElem, + }); + + if (targetElem.classList.contains(CLASS_FLATPICKR_DISABLED)) { + targetElem.classList.remove( + CLASS_FLATPICKR_DISABLED, + CLASS_FLATPICKR_NOT_ALLOWED + ); + targetElem.removeAttribute("tabindex"); + targetElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED); + + logger.debug(`Applied fix for ${logContext} availability`, { + finalClasses: Array.from(targetElem.classList), + }); + } +} + +/** + * Fix incorrect target-end unavailability via a CSS-based override. + * Wrapper for backward compatibility. + * + * @param {import('flatpickr/dist/types/instance').Instance} _instance + * @param {NodeListOf|Element[]} dayElements + * @param {Date} targetEndDate + * @returns {void} + */ +function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) { + fixDateAvailability(dayElements, targetEndDate, "target end date"); +} + +/** + * Apply constraint highlighting to the Flatpickr calendar. + * + * @param {import('flatpickr/dist/types/instance').Instance} instance + * @param {import('../../../types/bookings').ConstraintHighlighting} highlightingData + * @returns {void} + */ +export function applyCalendarHighlighting(instance, highlightingData) { + if (!instance || !instance.calendarContainer || !highlightingData) { + logger.debug("Missing requirements", { + hasInstance: !!instance, + hasContainer: !!instance?.calendarContainer, + hasData: !!highlightingData, + }); + return; + } + + // Cache highlighting data for re-application after navigation + const instWithCache = + /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../../types/bookings').ConstraintHighlighting | null }} */ ( + instance + ); + instWithCache._constraintHighlighting = highlightingData; + + clearCalendarHighlighting(instance); + + const applyHighlighting = (retryCount = 0) => { + // Guard: calendar may have closed between requestAnimationFrame calls + if (!instance || !instance.calendarContainer) { + logger.debug( + "Calendar closed before highlighting could be applied" + ); + return; + } + + if (retryCount === 0) { + logger.group("applyCalendarHighlighting"); + } + const dayElements = instance.calendarContainer.querySelectorAll( + `.${CLASS_FLATPICKR_DAY}` + ); + + if (dayElements.length === 0 && retryCount < HIGHLIGHTING_MAX_RETRIES) { + logger.debug(`No day elements found, retry ${retryCount + 1}`); + requestAnimationFrame(() => applyHighlighting(retryCount + 1)); + return; + } + + let highlightedCount = 0; + let blockedCount = 0; + + // Preload loan boundary times cached on instance (if present) + const instWithCacheForBoundary = + /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set }} */ ( + instance + ); + const boundaryTimes = instWithCacheForBoundary?._loanBoundaryTimes; + + dayElements.forEach(dayElem => { + if (!dayElem.dateObj) return; + + const dayTime = dayElem.dateObj.getTime(); + const startTime = highlightingData.startDate.getTime(); + const targetTime = highlightingData.targetEndDate.getTime(); + + // Apply bold styling to loan period boundary dates + if (boundaryTimes && boundaryTimes.has(dayTime)) { + dayElem.classList.add(CLASS_BOOKING_LOAN_BOUNDARY); + } + + if (dayTime >= startTime && dayTime <= targetTime) { + if ( + highlightingData.constraintMode === + CONSTRAINT_MODE_END_DATE_ONLY + ) { + const isBlocked = + highlightingData.blockedIntermediateDates.some( + blockedDate => dayTime === blockedDate.getTime() + ); + + if (isBlocked) { + if ( + !dayElem.classList.contains( + CLASS_FLATPICKR_DISABLED + ) + ) { + dayElem.classList.add( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_INTERMEDIATE_BLOCKED + ); + blockedCount++; + } + } else { + if ( + !dayElem.classList.contains( + CLASS_FLATPICKR_DISABLED + ) + ) { + dayElem.classList.add( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER + ); + highlightedCount++; + } + } + } else { + if (!dayElem.classList.contains(CLASS_FLATPICKR_DISABLED)) { + dayElem.classList.add( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER + ); + highlightedCount++; + } + } + } + }); + + logger.debug("Highlighting applied", { + highlightedCount, + blockedCount, + retryCount, + constraintMode: highlightingData.constraintMode, + }); + + if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) { + applyClickPrevention(instance); + fixTargetEndDateAvailability( + instance, + dayElements, + highlightingData.targetEndDate + ); + + const targetEndElem = Array.from(dayElements).find( + elem => + elem.dateObj && + elem.dateObj.getTime() === + highlightingData.targetEndDate.getTime() + ); + if ( + targetEndElem && + !targetEndElem.classList.contains(CLASS_FLATPICKR_DISABLED) + ) { + targetEndElem.classList.add( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER + ); + logger.debug( + "Re-applied highlighting to target end date after availability fix" + ); + } + } + + if (highlightingData.holidays && highlightingData.holidays.length > 0) { + const holidayTimestamps = new Set( + highlightingData.holidays.map(dateStr => { + const d = new Date(dateStr); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + ); + + applyHolidayClickPrevention( + dayElements, + highlightingData.startDate, + highlightingData.targetEndDate, + holidayTimestamps + ); + } + + logger.groupEnd(); + }; + + requestAnimationFrame(() => applyHighlighting()); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/index.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/index.mjs new file mode 100644 index 00000000000..ad6bf8b7bb1 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/index.mjs @@ -0,0 +1,26 @@ +/** + * Calendar adapter barrel file. + * Re-exports all calendar-related utilities for convenient importing. + * + * @module calendar + */ + +export { getCurrentLanguageCode, preloadFlatpickrLocale } from "./locale.mjs"; + +export { + clearCalendarHighlighting, + applyCalendarHighlighting, + fixDateAvailability, +} from "./highlighting.mjs"; + +export { + preventClick, + applyClickPrevention, + applyHolidayClickPrevention, +} from "./prevention.mjs"; + +export { createOnChange, createOnDayCreate, createOnClose } from "./events.mjs"; + +export { getVisibleCalendarDates } from "./visibility.mjs"; + +export { buildMarkerGrid } from "./markers.mjs"; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/locale.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/locale.mjs new file mode 100644 index 00000000000..2ac39b07bf8 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/locale.mjs @@ -0,0 +1,34 @@ +/** + * Flatpickr locale handling utilities. + * @module calendar/locale + */ + +/** + * Get the current language code from the HTML lang attribute. + * @returns {string} Two-letter language code + */ +export function getCurrentLanguageCode() { + const htmlLang = document.documentElement.lang || "en"; + return htmlLang.split("-")[0].toLowerCase(); +} + +/** + * Pre-load flatpickr locale based on current language. + * Should ideally be called once when the page loads. + * @returns {Promise} + */ +export async function preloadFlatpickrLocale() { + const langCode = getCurrentLanguageCode(); + + if (langCode === "en") { + return; + } + + try { + await import(`flatpickr/dist/l10n/${langCode}.js`); + } catch (e) { + console.warn( + `Flatpickr locale for '${langCode}' not found, will use fallback translations` + ); + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/markers.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/markers.mjs new file mode 100644 index 00000000000..e1d558e4ad3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/markers.mjs @@ -0,0 +1,40 @@ +/** + * Calendar marker DOM building utilities. + * @module calendar/markers + */ + +import { + CLASS_BOOKING_MARKER_COUNT, + CLASS_BOOKING_MARKER_DOT, + CLASS_BOOKING_MARKER_GRID, + CLASS_BOOKING_MARKER_ITEM, +} from "../../booking/constants.mjs"; + +/** + * Build the DOM grid for aggregated booking markers. + * + * @param {import('../../../types/bookings').MarkerAggregation} aggregatedMarkers - counts by marker type + * @returns {HTMLDivElement} container element with marker items + */ +export function buildMarkerGrid(aggregatedMarkers) { + const gridContainer = document.createElement("div"); + gridContainer.className = CLASS_BOOKING_MARKER_GRID; + Object.entries(aggregatedMarkers).forEach(([type, count]) => { + const markerSpan = document.createElement("span"); + markerSpan.className = CLASS_BOOKING_MARKER_ITEM; + + const dot = document.createElement("span"); + dot.className = `${CLASS_BOOKING_MARKER_DOT} ${CLASS_BOOKING_MARKER_DOT}--${type}`; + dot.title = type.charAt(0).toUpperCase() + type.slice(1); + markerSpan.appendChild(dot); + + if (count > 0) { + const countSpan = document.createElement("span"); + countSpan.className = CLASS_BOOKING_MARKER_COUNT; + countSpan.textContent = ` ${count}`; + markerSpan.appendChild(countSpan); + } + gridContainer.appendChild(markerSpan); + }); + return gridContainer; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/prevention.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/prevention.mjs new file mode 100644 index 00000000000..c125e1aad24 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/prevention.mjs @@ -0,0 +1,87 @@ +/** + * Click prevention utilities for calendar date selection. + * @module calendar/prevention + */ + +import { calendarLogger as logger } from "../../booking/logger.mjs"; +import { + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_INTERMEDIATE_BLOCKED, +} from "../../booking/constants.mjs"; + +/** + * Click prevention handler. + * @param {Event} e - Click event + * @returns {boolean} Always false to prevent default + */ +export function preventClick(e) { + e.preventDefault(); + e.stopPropagation(); + return false; +} + +/** + * Apply click prevention for intermediate dates in end_date_only mode. + * @param {import('flatpickr/dist/types/instance').Instance} instance - Flatpickr instance + * @returns {void} + */ +export function applyClickPrevention(instance) { + if (!instance || !instance.calendarContainer) return; + + const blockedElements = instance.calendarContainer.querySelectorAll( + `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}` + ); + blockedElements.forEach(elem => { + elem.removeEventListener("click", preventClick, { capture: true }); + elem.addEventListener("click", preventClick, { capture: true }); + }); +} + +/** + * Apply click prevention for holidays when selecting end dates. + * Holidays are not disabled in the function (to allow Flatpickr range validation to pass), + * but we prevent clicking on them and add visual styling. + * + * @param {NodeListOf|Element[]} dayElements - Day elements from Flatpickr + * @param {Date} startDate - Selected start date + * @param {Date} targetEndDate - Maximum allowed end date + * @param {Set} holidayTimestamps - Set of holiday date timestamps + * @returns {void} + */ +export function applyHolidayClickPrevention( + dayElements, + startDate, + targetEndDate, + holidayTimestamps +) { + if (!dayElements || holidayTimestamps.size === 0) { + return; + } + + const startTime = startDate.getTime(); + const endTime = targetEndDate.getTime(); + let blockedCount = 0; + + Array.from(dayElements).forEach(elem => { + if (!elem.dateObj) return; + + const dayTime = elem.dateObj.getTime(); + + if (dayTime <= startTime || dayTime > endTime) return; + if (!holidayTimestamps.has(dayTime)) return; + + elem.classList.add( + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_INTERMEDIATE_BLOCKED + ); + + elem.removeEventListener("click", preventClick, { capture: true }); + elem.addEventListener("click", preventClick, { capture: true }); + + blockedCount++; + }); + + if (blockedCount > 0) { + logger.debug("Applied click prevention to holidays", { blockedCount }); + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/visibility.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/visibility.mjs new file mode 100644 index 00000000000..ae2b3aee9e6 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/visibility.mjs @@ -0,0 +1,46 @@ +/** + * Calendar visibility and date extraction utilities. + * @module calendar/visibility + */ + +import { startOfDayTs, toDayjs } from "../../booking/BookingDate.mjs"; +import { CLASS_FLATPICKR_DAY } from "../../booking/constants.mjs"; +import { calendarLogger } from "../../booking/logger.mjs"; + +/** + * Generate all visible dates for the current calendar view. + * UI-level helper; belongs with calendar DOM logic. + * + * @param {import('../../../types/bookings').FlatpickrInstanceWithHighlighting} flatpickrInstance - Flatpickr instance + * @returns {Date[]} Array of Date objects + */ +export function getVisibleCalendarDates(flatpickrInstance) { + try { + if (!flatpickrInstance) return []; + + // Prefer the calendar container; fall back to `.days` if present + const container = + flatpickrInstance.calendarContainer || flatpickrInstance.days; + if (!container || !container.querySelectorAll) return []; + + const dayNodes = container.querySelectorAll(`.${CLASS_FLATPICKR_DAY}`); + if (!dayNodes || dayNodes.length === 0) return []; + + // Map visible day elements to normalized Date objects and de-duplicate + const seen = new Set(); + const dates = []; + Array.from(dayNodes).forEach(el => { + const d = el && el.dateObj ? el.dateObj : null; + if (!d) return; + const ts = startOfDayTs(d); + if (!seen.has(ts)) { + seen.add(ts); + dates.push(toDayjs(d).startOf("day").toDate()); + } + }); + return dates; + } catch (e) { + calendarLogger.warn("getVisibleCalendarDates", "Failed to extract visible dates", e); + return []; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs new file mode 100644 index 00000000000..be235a949f3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs @@ -0,0 +1,261 @@ +import { win } from "./globals.mjs"; +import { transformPatronData } from "./patron.mjs"; +import dayjs from "../../../../utils/dayjs.mjs"; + +const $__ = globalThis.$__ || (str => str); + +/** @typedef {import('../../types/bookings').ExternalDependencies} ExternalDependencies */ + +export { debounce } from "../../../../utils/functions.mjs"; + +/** + * Default dependencies for external updates - can be overridden in tests + * @type {ExternalDependencies} + */ +const defaultDependencies = { + timeline: () => win("timeline"), + bookingsTable: () => win("bookings_table"), + patronRenderer: () => win("$patron_to_html"), + domQuery: selector => document.querySelectorAll(selector), + logger: { + warn: (msg, data) => console.warn(msg, data), + error: (msg, error) => console.error(msg, error), + }, +}; + +/** + * Renders patron content for display, with injected dependency + * + * @param {{ cardnumber?: string }|null} bookingPatron + * @param {ExternalDependencies} [dependencies=defaultDependencies] + * @returns {string} + */ +function renderPatronContent( + bookingPatron, + dependencies = defaultDependencies +) { + try { + const patronRenderer = dependencies.patronRenderer(); + if (typeof patronRenderer === "function" && bookingPatron) { + return patronRenderer(bookingPatron, { + display_cardnumber: true, + url: true, + }); + } + + if (bookingPatron) { + const transformed = transformPatronData(bookingPatron); + return transformed?.label || bookingPatron.cardnumber || ""; + } + + return ""; + } catch (error) { + dependencies.logger.error("Failed to render patron content", { + error, + bookingPatron, + }); + const transformed = transformPatronData(bookingPatron); + return transformed?.label || bookingPatron?.cardnumber || ""; + } +} + +/** + * Updates timeline component with booking data + * + * @param {import('../../types/bookings').Booking} newBooking + * @param {{ cardnumber?: string }|null} bookingPatron + * @param {boolean} isUpdate + * @param {ExternalDependencies} dependencies + * @returns {{ success: boolean, reason?: string }} + */ +function updateTimelineComponent( + newBooking, + bookingPatron, + isUpdate, + dependencies +) { + const timeline = dependencies.timeline(); + if (!timeline) return { success: false, reason: "Timeline not available" }; + + try { + const timezoneFn = win("$timezone"); + const tz = typeof timezoneFn === "function" ? timezoneFn() : null; + const startDayjs = tz && dayjs.tz + ? dayjs(newBooking.start_date).tz(tz) + : dayjs(newBooking.start_date); + const endDayjs = tz && dayjs.tz + ? dayjs(newBooking.end_date).tz(tz) + : dayjs(newBooking.end_date); + + const itemData = { + id: newBooking.booking_id, + booking: newBooking.booking_id, + patron: newBooking.patron_id, + start: startDayjs.toDate(), + end: endDayjs.toDate(), + content: renderPatronContent(bookingPatron, dependencies), + editable: { remove: true, updateTime: true }, + type: "range", + group: newBooking.item_id ? newBooking.item_id : 0, + }; + + if (isUpdate) { + timeline.itemsData.update(itemData); + } else { + timeline.itemsData.add(itemData); + } + timeline.focus(newBooking.booking_id); + + return { success: true }; + } catch (error) { + dependencies.logger.error("Failed to update timeline", { + error, + newBooking, + }); + return { success: false, reason: error.message }; + } +} + +/** + * Updates bookings table component + * + * @param {ExternalDependencies} dependencies + * @returns {{ success: boolean, reason?: string }} + */ +function updateBookingsTable(dependencies) { + const bookingsTable = dependencies.bookingsTable(); + if (!bookingsTable) + return { success: false, reason: "Bookings table not available" }; + + try { + bookingsTable.api().ajax.reload(); + return { success: true }; + } catch (error) { + dependencies.logger.error("Failed to update bookings table", { error }); + return { success: false, reason: error.message }; + } +} + +/** + * Updates booking count elements in the DOM + * + * @param {boolean} isUpdate + * @param {ExternalDependencies} dependencies + * @returns {{ success: boolean, reason?: string, updatedElements?: number, totalElements?: number }} + */ +function updateBookingCounts(isUpdate, dependencies) { + if (isUpdate) + return { success: true, reason: "No count update needed for updates" }; + + try { + const countEls = dependencies.domQuery(".bookings_count"); + let updatedCount = 0; + + countEls.forEach(el => { + const html = el.innerHTML; + const match = html.match(/(\d+)/); + if (match) { + const newCount = parseInt(match[1], 10) + 1; + el.innerHTML = html.replace(/(\d+)/, String(newCount)); + updatedCount++; + } + }); + + return { + success: true, + updatedElements: updatedCount, + totalElements: countEls.length, + }; + } catch (error) { + dependencies.logger.error("Failed to update booking counts", { error }); + return { success: false, reason: error.message }; + } +} + +/** + * Shows a transient success message in the #transient_result element + * + * @param {boolean} isUpdate - Whether this was an update or create + * @param {ExternalDependencies} dependencies + * @returns {{ success: boolean, reason?: string }} + */ +function showTransientSuccess(isUpdate, dependencies) { + try { + const container = dependencies.domQuery("#transient_result"); + if (!container || container.length === 0) { + return { success: false, reason: "Transient result container not found" }; + } + + const msg = isUpdate + ? $__("Booking successfully updated") + : $__("Booking successfully placed"); + + const el = container[0] || container; + el.innerHTML = ``; + + return { success: true }; + } catch (error) { + dependencies.logger.error("Failed to show transient success", { error }); + return { success: false, reason: error.message }; + } +} + +/** + * Updates external components that depend on booking data + * + * This function is designed with dependency injection to make it testable + * and to provide proper error handling with detailed feedback. + * + * @param {import('../../types/bookings').Booking} newBooking - The booking data that was created/updated + * @param {{ cardnumber?: string }|null} bookingPatron - The patron data for rendering + * @param {boolean} isUpdate - Whether this is an update (true) or create (false) + * @param {ExternalDependencies} dependencies - Injectable dependencies (for testing) + * @returns {Record} Results summary with success/failure details + */ +export function updateExternalDependents( + newBooking, + bookingPatron, + isUpdate = false, + dependencies = defaultDependencies +) { + const results = { + timeline: { attempted: false }, + bookingsTable: { attempted: false }, + bookingCounts: { attempted: false }, + transientSuccess: { attempted: false }, + }; + + if (dependencies.timeline()) { + results.timeline = { + attempted: true, + ...updateTimelineComponent( + newBooking, + bookingPatron, + isUpdate, + dependencies + ), + }; + } + + if (dependencies.bookingsTable()) { + results.bookingsTable = { + attempted: true, + ...updateBookingsTable(dependencies), + }; + } + + results.bookingCounts = { + attempted: true, + ...updateBookingCounts(isUpdate, dependencies), + }; + + results.transientSuccess = { + attempted: true, + ...showTransientSuccess(isUpdate, dependencies), + }; + + return results; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/form.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/form.mjs new file mode 100644 index 00000000000..7915d9daa40 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/form.mjs @@ -0,0 +1,17 @@ +/** + * Append hidden input fields to a form from a list of entries. + * Skips undefined/null values. + * + * @param {HTMLFormElement} form + * @param {Array<[string, unknown]>} entries + */ +export function appendHiddenInputs(form, entries) { + entries.forEach(([name, value]) => { + if (value === undefined || value === null) return; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = String(name); + input.value = String(value); + form.appendChild(input); + }); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs new file mode 100644 index 00000000000..3ade58b0860 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs @@ -0,0 +1,14 @@ +/** + * Safe accessors for window-scoped globals using bracket notation + */ + +/** + * Get a value from window by key using bracket notation + * + * @param {string} key + * @returns {unknown} + */ +export function win(key) { + if (typeof window === "undefined") return undefined; + return window[key]; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs new file mode 100644 index 00000000000..c0bb22f871a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs @@ -0,0 +1,118 @@ +/** + * Patron data transformation and search utilities. + * @module adapters/patron + * + * ## Fallback Drift Risk + * + * `buildPatronSearchQuery` delegates to `window.buildPatronSearchQuery` when available, + * falling back to a simplified local implementation. This creates a maintenance risk: + * + * - The fallback may drift from the real implementation as Koha evolves + * - The fallback lacks support for extended attribute searching + * - Search behavior may differ between staff interface (has global) and tests (uses fallback) + * + * If patron search behaves unexpectedly, verify that the global function is loaded + * before the booking modal initializes. The fallback logs a warning when used. + */ + +import { win } from "./globals.mjs"; +import { managerLogger as logger } from "../booking/logger.mjs"; +/** + * Builds a search query for patron searches + * This is a wrapper around the global buildPatronSearchQuery function + * @param {string} term - The search term + * @param {Object} [options] - Search options + * @param {string} [options.search_type] - 'contains' or 'starts_with' + * @param {string} [options.search_fields] - Comma-separated list of fields to search + * @param {Array} [options.extended_attribute_types] - Extended attribute types to search + * @param {string} [options.table_prefix] - Table name prefix for fields + * @returns {Array} Query conditions for the API + */ +export function buildPatronSearchQuery(term, options = {}) { + /** @type {((term: string, options?: object) => any) | null} */ + const globalBuilder = + typeof win("buildPatronSearchQuery") === "function" + ? /** @type {any} */ (win("buildPatronSearchQuery")) + : null; + if (globalBuilder) { + return globalBuilder(term, options); + } + + // Fallback implementation if the global function is not available + logger.warn( + "window.buildPatronSearchQuery is not available, using fallback implementation" + ); + const q = []; + if (!term) return q; + + const table_prefix = options.table_prefix || "me"; + const search_fields = options.search_fields + ? options.search_fields.split(",").map(f => f.trim()) + : ["surname", "firstname", "cardnumber", "userid"]; + + search_fields.forEach(field => { + q.push({ + [`${table_prefix}.${field}`]: { + like: `%${term}%`, + }, + }); + }); + + return [{ "-or": q }]; +} + +/** + * Calculates age in years from a date of birth string. + * @param {string} dateOfBirth - ISO date string (YYYY-MM-DD) + * @returns {number|null} Age in whole years, or null if invalid + */ +export function getAgeFromDob(dateOfBirth) { + if (!dateOfBirth) return null; + const dob = new Date(dateOfBirth); + if (isNaN(dob.getTime())) return null; + const today = new Date(); + let age = today.getFullYear() - dob.getFullYear(); + const monthDiff = today.getMonth() - dob.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) { + age--; + } + return age; +} + +/** + * Transforms patron data into a consistent format for display. + * The label (used by vue-select for filtering/selection display) shows: + * Surname, Firstname (cardnumber) + * Additional fields (age, library) are available for the custom #option slot. + * @param {Object} patron - The patron object to transform + * @returns {Object} Transformed patron object with a display label + */ +export function transformPatronData(patron) { + if (!patron) return null; + + return { + ...patron, + label: [ + patron.surname, + patron.firstname, + patron.cardnumber ? `(${patron.cardnumber})` : "", + ] + .filter(Boolean) + .join(" ") + .trim(), + _age: getAgeFromDob(patron.date_of_birth), + _libraryName: patron.library?.name || null, + }; +} + +/** + * Transforms an array of patrons using transformPatronData + * @param {Array|Object} data - The patron data (single object or array) + * @returns {Array|Object} Transformed patron(s) + */ +export function transformPatronsData(data) { + if (!data) return []; + + const patrons = Array.isArray(data) ? data : data.results || []; + return patrons.map(transformPatronData); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/BookingDate.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/BookingDate.mjs new file mode 100644 index 00000000000..70806b12156 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/BookingDate.mjs @@ -0,0 +1,588 @@ +/** + * BookingDate - Unified date adapter for the booking system. + * + * This class encapsulates all date operations and provides consistent + * conversions between different date representations used throughout + * the booking system: + * + * - ISO 8601 strings: Used in the Pinia store (single source of truth) + * - Date objects: Used by Flatpickr widget + * - dayjs instances: Used for all calculations + * - API format (YYYY-MM-DD): Used in REST API payloads + * + * By centralizing date handling, we eliminate scattered conversion calls + * and reduce the risk of timezone-related bugs. + * + * @example + * // Creating BookingDate instances + * const date1 = BookingDate.from('2025-03-14T00:00:00.000Z'); + * const date2 = BookingDate.from(new Date()); + * const date3 = BookingDate.today(); + * + * // Converting to different formats + * date1.toISO(); // '2025-03-14T00:00:00.000Z' + * date1.toDate(); // Date object + * date1.toAPIFormat(); // '2025-03-14' + * + * // Arithmetic + * const nextWeek = date1.addDays(7); + * const lastMonth = date1.subtractMonths(1); + * + * // Comparisons + * date1.isBefore(date2); + * date1.isSameDay(date2); + * + * @module BookingDate + */ + +import dayjs from "../../../../utils/dayjs.mjs"; + +/** + * Immutable date wrapper for booking operations. + * All arithmetic operations return new BookingDate instances. + */ +export class BookingDate { + /** @type {import('dayjs').Dayjs} */ + #dayjs; + + /** + * Create a BookingDate from any date-like input. + * The date is normalized to start of day to avoid time-related issues. + * + * @param {string|number|Date|import('dayjs').Dayjs|BookingDate} input - Date input (string, timestamp, Date, dayjs, or BookingDate) + * @param {Object} [options] + * @param {boolean} [options.preserveTime=false] - If true, don't normalize to start of day + */ + constructor(input, options = {}) { + if (input instanceof BookingDate) { + this.#dayjs = input.#dayjs.clone(); + } else { + this.#dayjs = dayjs( + /** @type {import('dayjs').ConfigType} */ (input) + ); + } + + if (!options.preserveTime) { + this.#dayjs = this.#dayjs.startOf("day"); + } + + if (!this.#dayjs.isValid()) { + throw new Error(`Invalid date input: ${input}`); + } + } + + // ========================================================================= + // Static Factory Methods + // ========================================================================= + + /** + * Create a BookingDate from any date-like input. + * Preferred factory method for creating instances. + * + * @param {string|number|Date|import('dayjs').Dayjs|BookingDate|null|undefined} input + * @param {Object} [options] + * @param {boolean} [options.preserveTime=false] + * @returns {BookingDate|null} Returns null if input is null/undefined + */ + static from(input, options = {}) { + if (input == null) return null; + if (input instanceof BookingDate) return input; + return new BookingDate(input, options); + } + + /** + * Create a BookingDate for today (start of day). + * @returns {BookingDate} + */ + static today() { + return new BookingDate(dayjs()); + } + + /** + * Create a BookingDate from an ISO string. + * @param {string} isoString + * @returns {BookingDate} + */ + static fromISO(isoString) { + return new BookingDate(isoString); + } + + /** + * Create a BookingDate from a Date object. + * @param {Date} date + * @returns {BookingDate} + */ + static fromDate(date) { + return new BookingDate(date); + } + + /** + * Create a BookingDate from API format (YYYY-MM-DD). + * @param {string} apiDate + * @returns {BookingDate} + */ + static fromAPIFormat(apiDate) { + return new BookingDate(apiDate); + } + + /** + * Convert an array of ISO strings to BookingDate array. + * Filters out null/invalid values. + * + * @param {Array} isoArray + * @returns {BookingDate[]} + */ + static fromISOArray(isoArray) { + if (!Array.isArray(isoArray)) return []; + return isoArray + .filter(Boolean) + .map(iso => BookingDate.fromISO(iso)) + .filter(d => d !== null); + } + + /** + * Convert an array of BookingDates to ISO strings. + * @param {BookingDate[]} dates + * @returns {string[]} + */ + static toISOArray(dates) { + if (!Array.isArray(dates)) return []; + return dates.filter(d => d instanceof BookingDate).map(d => d.toISO()); + } + + /** + * Convert an array of BookingDates to Date objects. + * Used for Flatpickr integration. + * @param {BookingDate[]} dates + * @returns {Date[]} + */ + static toDateArray(dates) { + if (!Array.isArray(dates)) return []; + return dates.filter(d => d instanceof BookingDate).map(d => d.toDate()); + } + + // ========================================================================= + // Conversion Methods (Output) + // ========================================================================= + + /** + * Convert to ISO 8601 string for store storage. + * @returns {string} + */ + toISO() { + return this.#dayjs.toISOString(); + } + + /** + * Convert to native Date object for Flatpickr. + * @returns {Date} + */ + toDate() { + return this.#dayjs.toDate(); + } + + /** + * Convert to dayjs instance for complex calculations. + * Returns a clone to maintain immutability. + * @returns {import('dayjs').Dayjs} + */ + toDayjs() { + return this.#dayjs.clone(); + } + + /** + * Convert to API format (YYYY-MM-DD) for REST payloads. + * @returns {string} + */ + toAPIFormat() { + return this.#dayjs.format("YYYY-MM-DD"); + } + + /** + * Format date with custom pattern. + * @param {string} pattern - dayjs format pattern + * @returns {string} + */ + format(pattern) { + return this.#dayjs.format(pattern); + } + + /** + * Get Unix timestamp in milliseconds. + * @returns {number} + */ + valueOf() { + return this.#dayjs.valueOf(); + } + + /** + * Get Unix timestamp in milliseconds (alias for valueOf). + * @returns {number} + */ + getTime() { + return this.valueOf(); + } + + /** + * String representation (ISO format). + * @returns {string} + */ + toString() { + return this.toISO(); + } + + // ========================================================================= + // Arithmetic Methods (Return new BookingDate) + // ========================================================================= + + /** + * Add days to the date. + * @param {number} days + * @returns {BookingDate} + */ + addDays(days) { + return new BookingDate(this.#dayjs.add(days, "day")); + } + + /** + * Subtract days from the date. + * @param {number} days + * @returns {BookingDate} + */ + subtractDays(days) { + return new BookingDate(this.#dayjs.subtract(days, "day")); + } + + /** + * Add months to the date. + * @param {number} months + * @returns {BookingDate} + */ + addMonths(months) { + return new BookingDate(this.#dayjs.add(months, "month")); + } + + /** + * Subtract months from the date. + * @param {number} months + * @returns {BookingDate} + */ + subtractMonths(months) { + return new BookingDate(this.#dayjs.subtract(months, "month")); + } + + /** + * Add years to the date. + * @param {number} years + * @returns {BookingDate} + */ + addYears(years) { + return new BookingDate(this.#dayjs.add(years, "year")); + } + + /** + * Subtract years from the date. + * @param {number} years + * @returns {BookingDate} + */ + subtractYears(years) { + return new BookingDate(this.#dayjs.subtract(years, "year")); + } + + // ========================================================================= + // Comparison Methods + // ========================================================================= + + /** + * Check if this date is before another date. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isBefore(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return false; + return this.#dayjs.isBefore(otherDate.#dayjs, unit); + } + + /** + * Check if this date is after another date. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isAfter(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return false; + return this.#dayjs.isAfter(otherDate.#dayjs, unit); + } + + /** + * Check if this date is the same as another date. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isSame(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return false; + return this.#dayjs.isSame(otherDate.#dayjs, unit); + } + + /** + * Check if this date is the same day as another date. + * Convenience method for isSame(other, 'day'). + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @returns {boolean} + */ + isSameDay(other) { + return this.isSame(other, "day"); + } + + /** + * Check if this date is the same or before another date. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isSameOrBefore(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return false; + return this.#dayjs.isSameOrBefore(otherDate.#dayjs, unit); + } + + /** + * Check if this date is the same or after another date. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isSameOrAfter(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return false; + return this.#dayjs.isSameOrAfter(otherDate.#dayjs, unit); + } + + /** + * Check if this date is between two other dates (inclusive). + * Implemented using isSameOrAfter/isSameOrBefore to avoid requiring isBetween plugin. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} start + * @param {string|Date|import('dayjs').Dayjs|BookingDate} end + * @param {'day'|'month'|'year'} [unit='day'] + * @returns {boolean} + */ + isBetween(start, end, unit = "day") { + const startDate = BookingDate.from(start); + const endDate = BookingDate.from(end); + if (!startDate || !endDate) return false; + return ( + this.isSameOrAfter(startDate, unit) && + this.isSameOrBefore(endDate, unit) + ); + } + + /** + * Get the difference between this date and another. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @param {'day'|'month'|'year'|'hour'|'minute'|'second'} [unit='day'] + * @returns {number} + */ + diff(other, unit = "day") { + const otherDate = BookingDate.from(other); + if (!otherDate) return 0; + return this.#dayjs.diff(otherDate.#dayjs, unit); + } + + /** + * Compare two dates, returning -1, 0, or 1. + * Useful for array sorting. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} other + * @returns {-1|0|1} + */ + compare(other) { + const otherDate = BookingDate.from(other); + if (!otherDate) return 1; + if (this.isBefore(otherDate)) return -1; + if (this.isAfter(otherDate)) return 1; + return 0; + } + + // ========================================================================= + // Component Accessors + // ========================================================================= + + /** + * Get the year. + * @returns {number} + */ + year() { + return this.#dayjs.year(); + } + + /** + * Get the month (0-11). + * @returns {number} + */ + month() { + return this.#dayjs.month(); + } + + /** + * Get the day of month (1-31). + * @returns {number} + */ + date() { + return this.#dayjs.date(); + } + + /** + * Get the day of week (0-6, Sunday is 0). + * @returns {number} + */ + day() { + return this.#dayjs.day(); + } + + // ========================================================================= + // Utility Methods + // ========================================================================= + + /** + * Check if the date is valid. + * @returns {boolean} + */ + isValid() { + return this.#dayjs.isValid(); + } + + /** + * Clone this BookingDate. + * @returns {BookingDate} + */ + clone() { + return new BookingDate(this.#dayjs.clone()); + } + + /** + * Check if this date is today. + * @returns {boolean} + */ + isToday() { + return this.isSameDay(BookingDate.today()); + } + + /** + * Check if this date is in the past (before today). + * @returns {boolean} + */ + isPast() { + return this.isBefore(BookingDate.today()); + } + + /** + * Check if this date is in the future (after today). + * @returns {boolean} + */ + isFuture() { + return this.isAfter(BookingDate.today()); + } +} + +// ========================================================================= +// Standalone Helper Functions +// ========================================================================= + +/** + * Convert an array of ISO strings to Date objects. + * @param {Array} values + * @returns {Date[]} + */ +export function isoArrayToDates(values) { + return BookingDate.toDateArray(BookingDate.fromISOArray(values)); +} + +/** + * Convert any date input to ISO string. + * @param {string|Date|import('dayjs').Dayjs} input + * @returns {string} + */ +export function toISO(input) { + const bd = BookingDate.from(input); + return bd ? bd.toISO() : ""; +} + +/** + * Convert any date input to dayjs instance. + * @param {string|Date|import('dayjs').Dayjs} input + * @returns {import('dayjs').Dayjs} + */ +export function toDayjs(input) { + const bd = BookingDate.from(input); + return bd ? bd.toDayjs() : dayjs(); +} + +/** + * Get start-of-day timestamp for any date input. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @returns {number} + */ +export function startOfDayTs(input) { + const bd = BookingDate.from(input); + return bd ? bd.valueOf() : 0; +} + +/** + * Format any date input as YYYY-MM-DD. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @returns {string} + */ +export function formatYMD(input) { + const bd = BookingDate.from(input); + return bd ? bd.toAPIFormat() : ""; +} + +/** + * Add days to any date input. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @param {number} days + * @returns {import('dayjs').Dayjs} + */ +export function addDays(input, days) { + const bd = BookingDate.from(input); + return bd ? bd.addDays(days).toDayjs() : dayjs(); +} + +/** + * Subtract days from any date input. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @param {number} days + * @returns {import('dayjs').Dayjs} + */ +export function subDays(input, days) { + const bd = BookingDate.from(input); + return bd ? bd.subtractDays(days).toDayjs() : dayjs(); +} + +/** + * Add months to any date input. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @param {number} months + * @returns {import('dayjs').Dayjs} + */ +export function addMonths(input, months) { + const bd = BookingDate.from(input); + return bd ? bd.addMonths(months).toDayjs() : dayjs(); +} + +/** + * Get end-of-day timestamp for any date input. + * @param {string|Date|import('dayjs').Dayjs|BookingDate} input + * @returns {number} + */ +export function endOfDayTs(input) { + const bd = BookingDate.from(input, { preserveTime: true }); + return bd ? bd.toDayjs().endOf("day").valueOf() : 0; +} + +// Default export for convenience +export default BookingDate; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs new file mode 100644 index 00000000000..79bdf1a9b7e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs @@ -0,0 +1,575 @@ +/** + * IntervalTree.js - Efficient interval tree data structure for booking date queries + * + * Provides O(log n) query performance for finding overlapping bookings/checkouts + * Based on augmented red-black tree with interval overlap detection + */ + +import { BookingDate } from "../BookingDate.mjs"; +import { managerLogger as logger } from "../logger.mjs"; + +/** + * Represents a booking or checkout interval + * @class BookingInterval + */ +export class BookingInterval { + /** + * Create a booking interval + * @param {string|Date|import("dayjs").Dayjs} startDate - Start date of the interval + * @param {string|Date|import("dayjs").Dayjs} endDate - End date of the interval + * @param {string|number} itemId - Item ID (will be converted to string) + * @param {'booking'|'checkout'|'lead'|'trail'|'query'} type - Type of interval + * @param {Object} metadata - Additional metadata (booking_id, patron_id, etc.) + * @param {number} [metadata.booking_id] - Booking ID for bookings + * @param {number} [metadata.patron_id] - Patron ID + * @param {number} [metadata.checkout_id] - Checkout ID for checkouts + * @param {number} [metadata.days] - Number of lead/trail days + */ + constructor(startDate, endDate, itemId, type, metadata = {}) { + /** @type {number} Unix timestamp for start date */ + this.start = BookingDate.from(startDate).valueOf(); // Convert to timestamp for fast comparison + /** @type {number} Unix timestamp for end date */ + this.end = BookingDate.from(endDate).valueOf(); + /** @type {string} Item ID as string for consistent comparison */ + this.itemId = String(itemId); // Ensure string for consistent comparison + /** @type {'booking'|'checkout'|'lead'|'trail'|'query'} Type of interval */ + this.type = type; // 'booking', 'checkout', 'lead', 'trail' + /** @type {Object} Additional metadata */ + this.metadata = metadata; // booking_id, patron info, etc. + + // Validate interval + if (this.start > this.end) { + throw new Error( + `Invalid interval: start (${startDate}) is after end (${endDate})` + ); + } + } + + /** + * Check if this interval contains a specific date + * @param {number|Date|import("dayjs").Dayjs} date - Date to check (timestamp, Date object, or dayjs instance) + * @returns {boolean} True if the date is within this interval (inclusive) + */ + containsDate(date) { + const timestamp = + typeof date === "number" ? date : BookingDate.from(date).valueOf(); + return timestamp >= this.start && timestamp <= this.end; + } + + /** + * Check if this interval overlaps with another interval + * @param {BookingInterval} other - The other interval to check for overlap + * @returns {boolean} True if the intervals overlap + */ + overlaps(other) { + return this.start <= other.end && other.start <= this.end; + } + + /** + * Get a string representation for debugging + * @returns {string} Human-readable string representation + */ + toString() { + const startStr = BookingDate.from(this.start).format("YYYY-MM-DD"); + const endStr = BookingDate.from(this.end).format("YYYY-MM-DD"); + return `${this.type}[${startStr} to ${endStr}] item:${this.itemId}`; + } +} + +/** + * Node in the interval tree (internal class) + * @class IntervalTreeNode + * @private + */ +class IntervalTreeNode { + /** + * Create an interval tree node + * @param {BookingInterval} interval - The interval stored in this node + */ + constructor(interval) { + /** @type {BookingInterval} The interval stored in this node */ + this.interval = interval; + /** @type {number} Maximum end value in this subtree (for efficient queries) */ + this.max = interval.end; // Max end value in this subtree + /** @type {IntervalTreeNode|null} Left child node */ + this.left = null; + /** @type {IntervalTreeNode|null} Right child node */ + this.right = null; + /** @type {number} Height of this node for AVL balancing */ + this.height = 1; + } + + /** + * Update the max value based on children (internal method) + */ + updateMax() { + this.max = this.interval.end; + if (this.left && this.left.max > this.max) { + this.max = this.left.max; + } + if (this.right && this.right.max > this.max) { + this.max = this.right.max; + } + } +} + +/** + * Interval tree implementation with AVL balancing + * Provides efficient O(log n) queries for interval overlaps + * @class IntervalTree + */ +export class IntervalTree { + /** + * Create a new interval tree + */ + constructor() { + /** @type {IntervalTreeNode|null} Root node of the tree */ + this.root = null; + /** @type {number} Number of intervals in the tree */ + this.size = 0; + } + + /** + * Get the height of a node (internal method) + * @param {IntervalTreeNode|null} node - The node to get height for + * @returns {number} Height of the node (0 for null nodes) + * @private + */ + _getHeight(node) { + return node ? node.height : 0; + } + + /** + * Get the balance factor of a node (internal method) + * @param {IntervalTreeNode|null} node - The node to get balance factor for + * @returns {number} Balance factor (left height - right height) + * @private + */ + _getBalance(node) { + return node + ? this._getHeight(node.left) - this._getHeight(node.right) + : 0; + } + + /** + * Update node height based on children + * @param {IntervalTreeNode} node + */ + _updateHeight(node) { + if (node) { + node.height = + 1 + + Math.max( + this._getHeight(node.left), + this._getHeight(node.right) + ); + } + } + + /** + * Rotate right (for balancing) + * @param {IntervalTreeNode} y + * @returns {IntervalTreeNode} + */ + _rotateRight(y) { + if (!y || !y.left) { + logger.error("Invalid rotation: y or y.left is null", { + y: y?.interval?.toString(), + }); + return y; + } + + const x = y.left; + const T2 = x.right; + + x.right = y; + y.left = T2; + + this._updateHeight(y); + this._updateHeight(x); + + // Update max values after rotation + y.updateMax(); + x.updateMax(); + + return x; + } + + /** + * Rotate left (for balancing) + * @param {IntervalTreeNode} x + * @returns {IntervalTreeNode} + */ + _rotateLeft(x) { + if (!x || !x.right) { + logger.error("Invalid rotation: x or x.right is null", { + x: x?.interval?.toString(), + }); + return x; + } + + const y = x.right; + const T2 = y.left; + + y.left = x; + x.right = T2; + + this._updateHeight(x); + this._updateHeight(y); + + // Update max values after rotation + x.updateMax(); + y.updateMax(); + + return y; + } + + /** + * Insert an interval into the tree + * @param {BookingInterval} interval - The interval to insert + * @throws {Error} If the interval is invalid + */ + insert(interval) { + this.root = this._insertNode(this.root, interval); + this.size++; + } + + /** + * Recursive helper for insertion with balancing + * @param {IntervalTreeNode} node + * @param {BookingInterval} interval + * @returns {IntervalTreeNode} + */ + _insertNode(node, interval) { + // Standard BST insertion based on start time + if (!node) { + return new IntervalTreeNode(interval); + } + + if (interval.start < node.interval.start) { + node.left = this._insertNode(node.left, interval); + } else { + node.right = this._insertNode(node.right, interval); + } + + // Update height and max + this._updateHeight(node); + node.updateMax(); + + // Balance the tree + const balance = this._getBalance(node); + + // Left heavy + if (balance > 1) { + if (interval.start < node.left.interval.start) { + return this._rotateRight(node); + } else { + node.left = this._rotateLeft(node.left); + return this._rotateRight(node); + } + } + + // Right heavy + if (balance < -1) { + if (interval.start > node.right.interval.start) { + return this._rotateLeft(node); + } else { + node.right = this._rotateRight(node.right); + return this._rotateLeft(node); + } + } + + return node; + } + + /** + * Query all intervals that contain a specific date + * @param {Date|import("dayjs").Dayjs|number} date - The date to query (Date object, dayjs instance, or timestamp) + * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items) + * @returns {BookingInterval[]} Array of intervals that contain the date + */ + query(date, itemId = null) { + const timestamp = + typeof date === "number" ? date : BookingDate.from(date).valueOf(); + const results = []; + this._queryNode(this.root, timestamp, results, itemId); + return results; + } + + /** + * Recursive helper for point queries + * @param {IntervalTreeNode} node + * @param {number} timestamp + * @param {BookingInterval[]} results + * @param {string} itemId + */ + _queryNode(node, timestamp, results, itemId) { + if (!node) return; + + // Check if current interval contains the timestamp + if (node.interval.containsDate(timestamp)) { + if (!itemId || node.interval.itemId === itemId) { + results.push(node.interval); + } + } + + // Recurse left if possible + if (node.left && node.left.max >= timestamp) { + this._queryNode(node.left, timestamp, results, itemId); + } + + // Recurse right if possible + if (node.right && node.interval.start <= timestamp) { + this._queryNode(node.right, timestamp, results, itemId); + } + } + + /** + * Query all intervals that overlap with a date range + * @param {Date|import("dayjs").Dayjs|number} startDate - Start of the range to query + * @param {Date|import("dayjs").Dayjs|number} endDate - End of the range to query + * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items) + * @returns {BookingInterval[]} Array of intervals that overlap with the range + */ + queryRange(startDate, endDate, itemId = null) { + const startTimestamp = + typeof startDate === "number" + ? startDate + : BookingDate.from(startDate).valueOf(); + const endTimestamp = + typeof endDate === "number" ? endDate : BookingDate.from(endDate).valueOf(); + + const queryInterval = new BookingInterval( + new Date(startTimestamp), + new Date(endTimestamp), + "", + "query" + ); + const results = []; + this._queryRangeNode(this.root, queryInterval, results, itemId); + return results; + } + + /** + * Recursive helper for range queries + * @param {IntervalTreeNode} node + * @param {BookingInterval} queryInterval + * @param {BookingInterval[]} results + * @param {string} itemId + */ + _queryRangeNode(node, queryInterval, results, itemId) { + if (!node) return; + + // Check if current interval overlaps with query + if (node.interval.overlaps(queryInterval)) { + if (!itemId || node.interval.itemId === itemId) { + results.push(node.interval); + } + } + + // Recurse left if possible + if (node.left && node.left.max >= queryInterval.start) { + this._queryRangeNode(node.left, queryInterval, results, itemId); + } + + // Recurse right if possible + if (node.right && node.interval.start <= queryInterval.end) { + this._queryRangeNode(node.right, queryInterval, results, itemId); + } + } + + /** + * Remove all intervals matching a predicate + * @param {Function} predicate - Function that returns true for intervals to remove + * @returns {number} Number of intervals removed + */ + removeWhere(predicate) { + const toRemove = []; + this._collectNodes(this.root, node => { + if (predicate(node.interval)) { + toRemove.push(node.interval); + } + }); + + toRemove.forEach(interval => { + this.root = this._removeNode(this.root, interval); + this.size--; + }); + + return toRemove.length; + } + + /** + * Helper to collect all nodes + * @param {IntervalTreeNode} node + * @param {Function} callback + */ + _collectNodes(node, callback) { + if (!node) return; + this._collectNodes(node.left, callback); + callback(node); + this._collectNodes(node.right, callback); + } + + /** + * Remove a specific interval (simplified - doesn't rebalance) + * @param {IntervalTreeNode} node + * @param {BookingInterval} interval + * @returns {IntervalTreeNode} + */ + _removeNode(node, interval) { + if (!node) return null; + + if (interval.start < node.interval.start) { + node.left = this._removeNode(node.left, interval); + } else if (interval.start > node.interval.start) { + node.right = this._removeNode(node.right, interval); + } else if ( + interval.end === node.interval.end && + interval.itemId === node.interval.itemId && + interval.type === node.interval.type + ) { + // Found the node to remove + if (!node.left) return node.right; + if (!node.right) return node.left; + + // Node has two children - get inorder successor + let minNode = node.right; + while (minNode.left) { + minNode = minNode.left; + } + + node.interval = minNode.interval; + node.right = this._removeNode(node.right, minNode.interval); + } else { + // Continue searching + node.right = this._removeNode(node.right, interval); + } + + if (node) { + this._updateHeight(node); + node.updateMax(); + } + + return node; + } + + /** + * Clear all intervals + */ + clear() { + this.root = null; + this.size = 0; + } + + /** + * Get statistics about the tree for debugging and monitoring + * @returns {Object} Statistics object + */ + getStats() { + return { + size: this.size, + height: this._getHeight(this.root), + balanced: Math.abs(this._getBalance(this.root)) <= 1, + }; + } +} + +/** + * Build an interval tree from bookings and checkouts data + * @param {Array} bookings - Array of booking objects + * @param {Array} checkouts - Array of checkout objects + * @param {Object} circulationRules - Circulation rules configuration + * @returns {IntervalTree} Populated interval tree ready for queries + */ +export function buildIntervalTree(bookings, checkouts, circulationRules) { + const tree = new IntervalTree(); + + // Add booking intervals with lead/trail times + bookings.forEach(booking => { + try { + // Skip invalid bookings + if (!booking.item_id || !booking.start_date || !booking.end_date) { + logger.warn("Skipping invalid booking", { booking }); + return; + } + + // Core booking interval + const bookingInterval = new BookingInterval( + booking.start_date, + booking.end_date, + booking.item_id, + "booking", + { booking_id: booking.booking_id, patron_id: booking.patron_id } + ); + tree.insert(bookingInterval); + + // Lead time interval + const leadDays = circulationRules?.bookings_lead_period || 0; + if (leadDays > 0) { + const bookingStart = BookingDate.from(booking.start_date); + const leadStart = bookingStart.subtractDays(leadDays); + const leadEnd = bookingStart.subtractDays(1); + const leadInterval = new BookingInterval( + leadStart.toDate(), + leadEnd.toDate(), + booking.item_id, + "lead", + { booking_id: booking.booking_id, days: leadDays } + ); + tree.insert(leadInterval); + } + + // Trail time interval + const trailDays = circulationRules?.bookings_trail_period || 0; + if (trailDays > 0) { + const bookingEnd = BookingDate.from(booking.end_date); + const trailStart = bookingEnd.addDays(1); + const trailEnd = bookingEnd.addDays(trailDays); + const trailInterval = new BookingInterval( + trailStart.toDate(), + trailEnd.toDate(), + booking.item_id, + "trail", + { booking_id: booking.booking_id, days: trailDays } + ); + tree.insert(trailInterval); + } + } catch (error) { + logger.error("Failed to insert booking interval", { + booking, + error, + }); + } + }); + + // Add checkout intervals + checkouts.forEach(checkout => { + try { + if ( + checkout.item_id && + checkout.checkout_date && + checkout.due_date + ) { + const checkoutInterval = new BookingInterval( + checkout.checkout_date, + checkout.due_date, + checkout.item_id, + "checkout", + { + checkout_id: checkout.issue_id, + patron_id: checkout.patron_id, + } + ); + tree.insert(checkoutInterval); + } + } catch (error) { + logger.error("Failed to insert checkout interval", { + checkout, + error, + }); + } + }); + + return tree; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs new file mode 100644 index 00000000000..805f1ae125c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs @@ -0,0 +1,329 @@ +/** + * SweepLineProcessor.js - Efficient sweep line algorithm for processing date ranges + * + * Processes all bookings/checkouts in a date range using a sweep line algorithm + * to efficiently determine availability for each day in O(n log n) time + */ + +import { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs"; +import { MAX_SEARCH_DAYS } from "../constants.mjs"; + +/** + * Event types for the sweep line algorithm + * @readonly + * @enum {string} + * @exported for testability + */ +export const EventType = { + /** Start of an interval */ + START: "start", + /** End of an interval */ + END: "end", +}; + +/** + * Represents an event in the sweep line algorithm (internal class) + * @class SweepEvent + * @private + */ +class SweepEvent { + /** + * Create a sweep event + * @param {number} timestamp - Unix timestamp of the event + * @param {'start'|'end'} type - Type of event + * @param {import('./interval-tree.mjs').BookingInterval} interval - The interval associated with this event + */ + constructor(timestamp, type, interval) { + /** @type {number} Unix timestamp of the event */ + this.timestamp = timestamp; + /** @type {'start'|'end'} Type of event */ + this.type = type; // 'start' or 'end' + /** @type {import('./interval-tree.mjs').BookingInterval} The booking/checkout interval */ + this.interval = interval; // The booking/checkout interval + } +} + +/** + * Sweep line processor for efficient date range queries + * Uses sweep line algorithm to process intervals in O(n log n) time + * @class SweepLineProcessor + */ +export class SweepLineProcessor { + /** + * Create a new sweep line processor + */ + constructor() { + /** @type {SweepEvent[]} Array of sweep events */ + this.events = []; + } + + /** + * Process intervals to generate unavailability data for a date range + * @param {import('./interval-tree.mjs').BookingInterval[]} intervals - All booking/checkout intervals + * @param {Date|import("dayjs").Dayjs} viewStart - Start of the visible date range + * @param {Date|import("dayjs").Dayjs} viewEnd - End of the visible date range + * @param {Array} allItemIds - All bookable item IDs + * @returns {Object>>} unavailableByDate map + */ + processIntervals(intervals, viewStart, viewEnd, allItemIds) { + const startTimestamp = startOfDayTs(viewStart); + const endTimestamp = endOfDayTs(viewEnd); + + this.events = []; + intervals.forEach(interval => { + if ( + interval.end < startTimestamp || + interval.start > endTimestamp + ) { + return; + } + + const clampedStart = Math.max(interval.start, startTimestamp); + const nextDayStart = BookingDate.from(interval.end).addDays(1).valueOf(); + const endRemovalTs = Math.min(nextDayStart, endTimestamp + 1); + + this.events.push(new SweepEvent(clampedStart, "start", interval)); + this.events.push(new SweepEvent(endRemovalTs, "end", interval)); + }); + + this.events.sort((a, b) => { + if (a.timestamp !== b.timestamp) { + return a.timestamp - b.timestamp; + } + return a.type === "start" ? -1 : 1; + }); + + /** @type {Record>>} */ + const unavailableByDate = {}; + const activeIntervals = new Map(); // itemId -> Set of intervals + + allItemIds.forEach(itemId => { + activeIntervals.set(itemId, new Set()); + }); + + let eventIndex = 0; + + for ( + let date = BookingDate.from(viewStart).toDayjs(); + date.isSameOrBefore(viewEnd, "day"); + date = date.add(1, "day") + ) { + const dateKey = date.format("YYYY-MM-DD"); + const dateStart = date.valueOf(); + const dateEnd = date.endOf("day").valueOf(); + + while ( + eventIndex < this.events.length && + this.events[eventIndex].timestamp <= dateEnd + ) { + const event = this.events[eventIndex]; + const itemId = event.interval.itemId; + + if (event.type === EventType.START) { + if (!activeIntervals.has(itemId)) { + activeIntervals.set(itemId, new Set()); + } + activeIntervals.get(itemId).add(event.interval); + } else { + if (activeIntervals.has(itemId)) { + activeIntervals.get(itemId).delete(event.interval); + } + } + + eventIndex++; + } + + unavailableByDate[dateKey] = {}; + + activeIntervals.forEach((intervals, itemId) => { + const reasons = new Set(); + + intervals.forEach(interval => { + if ( + interval.start <= dateEnd && + interval.end >= dateStart + ) { + if (interval.type === "booking") { + reasons.add("core"); + } else if (interval.type === "checkout") { + reasons.add("checkout"); + } else { + reasons.add(interval.type); // 'lead' or 'trail' + } + } + }); + + if (reasons.size > 0) { + unavailableByDate[dateKey][itemId] = reasons; + } + }); + } + + return unavailableByDate; + } + + /** + * Process intervals and return aggregated statistics + * @param {Array} intervals + * @param {Date|import("dayjs").Dayjs} viewStart + * @param {Date|import("dayjs").Dayjs} viewEnd + * @returns {Object} Statistics about the date range + */ + getDateRangeStatistics(intervals, viewStart, viewEnd) { + const stats = { + totalDays: 0, + daysWithBookings: 0, + daysWithCheckouts: 0, + fullyBookedDays: 0, + peakBookingCount: 0, + peakDate: null, + itemUtilization: new Map(), + }; + + const startDate = BookingDate.from(viewStart).toDayjs(); + const endDate = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day"); + + stats.totalDays = endDate.diff(startDate, "day") + 1; + + for ( + let date = startDate; + date.isSameOrBefore(endDate, "day"); + date = date.add(1, "day") + ) { + const dayStart = date.valueOf(); + const dayEnd = date.endOf("day").valueOf(); + + let bookingCount = 0; + let checkoutCount = 0; + const itemsInUse = new Set(); + + intervals.forEach(interval => { + if (interval.start <= dayEnd && interval.end >= dayStart) { + if (interval.type === "booking") { + bookingCount++; + itemsInUse.add(interval.itemId); + } else if (interval.type === "checkout") { + checkoutCount++; + itemsInUse.add(interval.itemId); + } + } + }); + + if (bookingCount > 0) stats.daysWithBookings++; + if (checkoutCount > 0) stats.daysWithCheckouts++; + + const totalCount = bookingCount + checkoutCount; + if (totalCount > stats.peakBookingCount) { + stats.peakBookingCount = totalCount; + stats.peakDate = date.format("YYYY-MM-DD"); + } + + itemsInUse.forEach(itemId => { + if (!stats.itemUtilization.has(itemId)) { + stats.itemUtilization.set(itemId, 0); + } + stats.itemUtilization.set( + itemId, + stats.itemUtilization.get(itemId) + 1 + ); + }); + } + + return stats; + } + + /** + * Find the next available date for a specific item + * @param {Array} intervals + * @param {string} itemId + * @param {Date|import('dayjs').Dayjs} startDate + * @param {number} maxDaysToSearch + * @returns {Date|null} + */ + findNextAvailableDate( + intervals, + itemId, + startDate, + maxDaysToSearch = MAX_SEARCH_DAYS + ) { + const start = BookingDate.from(startDate).toDayjs(); + const itemIntervals = intervals.filter( + interval => interval.itemId === itemId + ); + + itemIntervals.sort((a, b) => a.start - b.start); + + for (let i = 0; i < maxDaysToSearch; i++) { + const checkDate = start.add(i, "day"); + const dateStart = checkDate.valueOf(); + const dateEnd = checkDate.endOf("day").valueOf(); + + const isAvailable = !itemIntervals.some( + interval => + interval.start <= dateEnd && interval.end >= dateStart + ); + + if (isAvailable) { + return checkDate.toDate(); + } + } + + return null; + } + + /** + * Find gaps (available periods) for an item + * @param {Array} intervals + * @param {string} itemId + * @param {Date|import('dayjs').Dayjs} viewStart + * @param {Date|import('dayjs').Dayjs} viewEnd + * @param {number} minGapDays - Minimum gap size to report + * @returns {Array<{start: Date, end: Date, days: number}>} + */ + findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) { + const gaps = []; + const itemIntervals = intervals + .filter(interval => interval.itemId === itemId) + .sort((a, b) => a.start - b.start); + + const rangeStart = BookingDate.from(viewStart).valueOf(); + const rangeEnd = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day").valueOf(); + + let lastEnd = rangeStart; + + itemIntervals.forEach(interval => { + if (interval.end < rangeStart || interval.start > rangeEnd) { + return; + } + + const gapStart = Math.max(lastEnd, rangeStart); + const gapEnd = Math.min(interval.start, rangeEnd); + + if (gapEnd > gapStart) { + const gapDays = BookingDate.from(gapEnd).diff(BookingDate.from(gapStart), "day"); + if (gapDays >= minGapDays) { + gaps.push({ + start: new Date(gapStart), + end: new Date(gapEnd - 1), // End of previous day + days: gapDays, + }); + } + } + + lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day + }); + + if (lastEnd < rangeEnd) { + const gapDays = BookingDate.from(rangeEnd).diff(BookingDate.from(lastEnd), "day"); + if (gapDays >= minGapDays) { + gaps.push({ + start: new Date(lastEnd), + end: new Date(rangeEnd), + days: gapDays, + }); + } + } + + return gaps; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability.mjs new file mode 100644 index 00000000000..cd340f3d9c0 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability.mjs @@ -0,0 +1,37 @@ +/** + * Core availability calculation logic for the booking system. + * + * This module has been split into focused sub-modules for better maintainability. + * All exports are re-exported from ./availability/index.mjs for backward compatibility. + * + * Sub-modules: + * - ./availability/rules.mjs - Circulation rules utilities + * - ./availability/period-validators.mjs - Period validation utilities + * - ./availability/unavailable-map.mjs - Unavailable date map builders + * - ./availability/disabled-dates.mjs - Main calculateDisabledDates function + * - ./availability/date-change.mjs - Date change handlers + * + * @module availability + */ + +export { + extractBookingConfiguration, + deriveEffectiveRules, + toEffectiveRules, + calculateMaxBookingPeriod, + calculateMaxEndDate, + validateBookingPeriod, + validateLeadPeriodOptimized, + validateTrailPeriodOptimized, + validateRangeOverlapForEndDate, + getAvailableItemsForPeriod, + buildUnavailableByDateMap, + addHolidayMarkers, + addLeadPeriodFromTodayMarkers, + addTheoreticalLeadPeriodMarkers, + calculateDisabledDates, + buildIntervalTree, + findFirstBlockingDate, + calculateAvailabilityData, + handleBookingDateChange, +} from "./availability/index.mjs"; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/date-change.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/date-change.mjs new file mode 100644 index 00000000000..c7f5a6c4823 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/date-change.mjs @@ -0,0 +1,302 @@ +/** + * Date change handlers for booking availability. + * @module availability/date-change + */ + +import { BookingDate, isoArrayToDates } from "../BookingDate.mjs"; +import { createConstraintStrategy } from "../strategies.mjs"; +import { buildIntervalTree } from "../algorithms/interval-tree.mjs"; +import { CONSTRAINT_MODE_END_DATE_ONLY } from "../constants.mjs"; +import { calculateDisabledDates } from "./disabled-dates.mjs"; +import { deriveEffectiveRules, calculateMaxBookingPeriod } from "./rules.mjs"; +import { calculateMaxEndDate } from "./period-validators.mjs"; +import { + queryRangeAndResolve, + createConflictContext, +} from "../conflict-resolution.mjs"; + +const $__ = globalThis.$__ || (str => str); + +/** + * Find the first date where a booking range [startDate, candidateEnd] would conflict + * with all items. This mirrors the backend's range-overlap detection logic. + * + * The backend considers two bookings overlapping if: + * - existing.start_date BETWEEN new.start AND new.end (inclusive) + * - existing.end_date BETWEEN new.start AND new.end (inclusive) + * - existing completely wraps new + * + * @param {Date|import('dayjs').Dayjs} startDate - Start of the booking range + * @param {Date|import('dayjs').Dayjs} endDate - Maximum end date to check + * @param {Array} bookings - Array of booking objects + * @param {Array} checkouts - Array of checkout objects + * @param {Array} bookableItems - Array of bookable items + * @param {string|number|null} selectedItem - Selected item ID or null for "any item" + * @param {string|number|null} editBookingId - Booking ID being edited (to exclude) + * @param {Object} circulationRules - Circulation rules for lead/trail periods + * @returns {{ firstBlockingDate: Date|null, reason: string|null }} The first date that would cause all items to conflict + */ +export function findFirstBlockingDate( + startDate, + endDate, + bookings, + checkouts, + bookableItems, + selectedItem, + editBookingId, + circulationRules = {} +) { + if (!bookableItems || bookableItems.length === 0) { + return { + firstBlockingDate: BookingDate.from(startDate).toDate(), + reason: "no_items", + }; + } + + const intervalTree = buildIntervalTree( + bookings, + checkouts, + circulationRules + ); + const allItemIds = bookableItems.map(i => String(i.item_id)); + const ctx = createConflictContext(selectedItem, editBookingId, allItemIds); + + const start = BookingDate.from(startDate).toDayjs(); + const end = BookingDate.from(endDate).toDayjs(); + + // For each potential end date, check if the range [start, candidateEnd] would have at least one available item + for ( + let candidateEnd = start.add(1, "day"); + candidateEnd.isSameOrBefore(end, "day"); + candidateEnd = candidateEnd.add(1, "day") + ) { + const result = queryRangeAndResolve( + intervalTree, + start.valueOf(), + candidateEnd.valueOf(), + ctx + ); + + if (result.hasConflict) { + return { + firstBlockingDate: candidateEnd.toDate(), + reason: ctx.selectedItem + ? result.conflicts[0]?.type || "conflict" + : "all_items_have_conflicts", + }; + } + } + + return { firstBlockingDate: null, reason: null }; +} + +/** + * Convenience wrapper to calculate availability (disable fn + map) given a dateRange. + * Accepts ISO strings for dateRange and returns the result of calculateDisabledDates. + * @returns {import('../../../types/bookings').AvailabilityResult} + */ +export function calculateAvailabilityData(dateRange, storeData, options = {}) { + const { + bookings, + checkouts, + bookableItems, + circulationRules, + bookingItemId, + bookingId, + } = storeData; + + if (!bookings || !checkouts || !bookableItems) { + return { disable: () => false, unavailableByDate: {} }; + } + + const baseRules = circulationRules?.[0] || {}; + const maxBookingPeriod = calculateMaxBookingPeriod( + circulationRules, + options.dateRangeConstraint, + options.customDateRangeFormula + ); + const effectiveRules = deriveEffectiveRules(baseRules, { + dateRangeConstraint: options.dateRangeConstraint, + maxBookingPeriod, + }); + + let selectedDatesArray = []; + if (Array.isArray(dateRange)) { + selectedDatesArray = isoArrayToDates(dateRange); + } else if (typeof dateRange === "string") { + throw new TypeError( + "calculateAvailabilityData expects an array of ISO/date values for dateRange" + ); + } + + return calculateDisabledDates( + bookings, + checkouts, + bookableItems, + bookingItemId, + bookingId, + selectedDatesArray, + effectiveRules + ); +} + +/** + * Pure function to handle Flatpickr's onChange event logic for booking period selection. + * Determines the valid end date range, applies circulation rules, and returns validation info. + * + * @param {Array} selectedDates - Array of currently selected dates ([start], or [start, end]) + * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, etc.) + * @param {Array} bookings - Array of bookings + * @param {Array} checkouts - Array of checkouts + * @param {Array} bookableItems - Array of all bookable items + * @param {number|string|null} selectedItem - The currently selected item + * @param {number|string|null} editBookingId - The booking_id being edited (if any) + * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests + * @returns {Object} - { valid: boolean, errors: Array, newMaxEndDate: Date|null, newMinEndDate: Date|null } + */ +export function handleBookingDateChange( + selectedDates, + circulationRules, + bookings, + checkouts, + bookableItems, + selectedItem, + editBookingId, + todayArg = undefined, + options = {} +) { + const dayjsStart = selectedDates[0] + ? BookingDate.from(selectedDates[0]).toDayjs() + : null; + const dayjsEnd = selectedDates[1] + ? BookingDate.from(selectedDates[1], { preserveTime: true }).toDayjs().endOf("day") + : null; + const errors = []; + let valid = true; + let newMaxEndDate = null; + let newMinEndDate = null; + + // Validate: ensure start date is present + if (!dayjsStart) { + errors.push(String($__("Start date is required."))); + valid = false; + } else { + // Apply circulation rules: leadDays, maxPeriod (in days) + const leadDays = circulationRules?.leadDays || 0; + const maxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 0; + + // Calculate min end date; max end date only when constrained + newMinEndDate = dayjsStart.add(1, "day").startOf("day"); + if (maxPeriod > 0) { + newMaxEndDate = calculateMaxEndDate(dayjsStart, maxPeriod).startOf( + "day" + ); + } else { + newMaxEndDate = null; + } + + // Validate: start must be after today + leadDays + const today = todayArg + ? BookingDate.from(todayArg).toDayjs() + : BookingDate.today().toDayjs(); + if (dayjsStart.isBefore(today.add(leadDays, "day"))) { + errors.push( + String($__("Start date is too soon (lead time required)")) + ); + valid = false; + } + + // Validate: end must not be before start (only if end date exists) + if (dayjsEnd && dayjsEnd.isBefore(dayjsStart)) { + errors.push(String($__("End date is before start date"))); + valid = false; + } + + // Validate: period must not exceed maxPeriod unless overridden in end_date_only by backend due date + // Start date counts as day 1, so valid range is: end <= start + (maxPeriod - 1) + // Equivalently: diff < maxPeriod, or diff >= maxPeriod means invalid + if (dayjsEnd) { + const isEndDateOnly = + circulationRules?.booking_constraint_mode === + CONSTRAINT_MODE_END_DATE_ONLY; + const dueStr = circulationRules?.calculated_due_date; + const hasBackendDue = Boolean(dueStr); + if (!isEndDateOnly || !hasBackendDue) { + if ( + maxPeriod > 0 && + dayjsEnd.diff(dayjsStart, "day") >= maxPeriod + ) { + errors.push( + String($__("Booking period exceeds maximum allowed")) + ); + valid = false; + } + } + } + + // Strategy-specific enforcement for end date (e.g., end_date_only) + const strategy = createConstraintStrategy( + circulationRules?.booking_constraint_mode + ); + const enforcement = strategy.enforceEndDateSelection( + dayjsStart, + dayjsEnd, + circulationRules + ); + if (!enforcement.ok) { + errors.push( + String( + $__( + "In end date only mode, you can only select the calculated end date" + ) + ) + ); + valid = false; + } + + // Validate: check for booking/checkouts overlap using calculateDisabledDates + // This check is only meaningful if we have at least a start date, + // and if an end date is also present, we check the whole range. + // If only start date, effectively checks that single day. + const endDateForLoop = dayjsEnd || dayjsStart; // If no end date, loop for the start date only + + const disableFnResults = calculateDisabledDates( + bookings, + checkouts, + bookableItems, + selectedItem, + editBookingId, + selectedDates, + circulationRules, + todayArg, + options + ); + for ( + let d = dayjsStart.clone(); + d.isSameOrBefore(endDateForLoop, "day"); + d = d.add(1, "day") + ) { + if (disableFnResults.disable(d.toDate())) { + errors.push( + String( + $__("Date %s is unavailable.").format( + d.format("YYYY-MM-DD") + ) + ) + ); + valid = false; + break; + } + } + } + + return { + valid, + errors, + newMaxEndDate: newMaxEndDate ? newMaxEndDate.toDate() : null, + newMinEndDate: newMinEndDate ? newMinEndDate.toDate() : null, + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/disabled-dates.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/disabled-dates.mjs new file mode 100644 index 00000000000..b01b02376fa --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/disabled-dates.mjs @@ -0,0 +1,367 @@ +/** + * Disabled dates calculation for booking availability. + * Contains the main calculateDisabledDates function and createDisableFunction. + * @module availability/disabled-dates + */ + +import { BookingDate } from "../BookingDate.mjs"; +import { createConstraintStrategy } from "../strategies.mjs"; +import { buildIntervalTree } from "../algorithms/interval-tree.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CONSTRAINT_MODE_NORMAL, +} from "../constants.mjs"; +import { extractBookingConfiguration } from "./rules.mjs"; +import { + calculateMaxEndDate, + validateLeadPeriodOptimized, + validateTrailPeriodOptimized, + validateRangeOverlapForEndDate, +} from "./period-validators.mjs"; +import { + buildUnavailableByDateMap, + addHolidayMarkers, + addLeadPeriodFromTodayMarkers, + addTheoreticalLeadPeriodMarkers, +} from "./unavailable-map.mjs"; +import { + queryPointAndResolve, + createConflictContext, +} from "../conflict-resolution.mjs"; + +/** + * Creates the main disable function that determines if a date should be disabled + * @param {Object} intervalTree - Interval tree for conflict checking + * @param {Object} config - Configuration object from extractBookingConfiguration + * @param {Array} bookableItems - Array of bookable items + * @param {string|null} selectedItem - Selected item ID or null + * @param {number|null} editBookingId - Booking ID being edited + * @param {Array} selectedDates - Currently selected dates + * @param {Array} holidays - Array of holiday dates in YYYY-MM-DD format + * @returns {(date: Date) => boolean} Disable function for Flatpickr + */ +function createDisableFunction( + intervalTree, + config, + bookableItems, + selectedItem, + editBookingId, + selectedDates, + holidays = [] +) { + const { + today, + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + calculatedDueDate, + } = config; + const allItemIds = bookableItems.map(i => String(i.item_id)); + const strategy = createConstraintStrategy( + isEndDateOnly ? CONSTRAINT_MODE_END_DATE_ONLY : CONSTRAINT_MODE_NORMAL + ); + const conflictCtx = createConflictContext( + selectedItem, + editBookingId, + allItemIds + ); + + const holidaySet = new Set(holidays); + + return date => { + const dayjs_date = BookingDate.from(date).toDayjs(); + + if (dayjs_date.isBefore(today, "day")) return true; + + // Only disable holidays when selecting START date - for END date selection, + // we use click prevention instead so Flatpickr's range validation passes + if ( + holidaySet.size > 0 && + (!selectedDates || selectedDates.length === 0) + ) { + const dateKey = dayjs_date.format("YYYY-MM-DD"); + if (holidaySet.has(dateKey)) { + return true; + } + } + + // Guard clause: No bookable items available + if (!bookableItems || bookableItems.length === 0) { + return true; + } + + // Mode-specific start date validation + if ( + strategy.validateStartDateSelection( + dayjs_date, + { + today, + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + calculatedDueDate, + }, + intervalTree, + selectedItem, + editBookingId, + allItemIds, + selectedDates + ) + ) { + return true; + } + + // Mode-specific intermediate date handling + const intermediateResult = strategy.handleIntermediateDate( + dayjs_date, + selectedDates, + { + today, + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + calculatedDueDate, + } + ); + if (intermediateResult === true) { + return true; + } + + // Guard clause: Standard point-in-time availability check using conflict resolution + const pointResult = queryPointAndResolve( + intervalTree, + dayjs_date.valueOf(), + conflictCtx + ); + + if (pointResult.hasConflict) { + return true; + } + + // Lead/trail period validation using optimized queries + if (!selectedDates || selectedDates.length === 0) { + // Potential start date - check lead period + if (leadDays > 0) { + // Enforce minimum advance booking: start date must be >= today + leadDays + // This applies even for the first booking (no existing bookings to conflict with) + const minStartDate = today.add(leadDays, "day"); + if (dayjs_date.isBefore(minStartDate, "day")) { + return true; + } + } + + // Optimized lead period validation using range queries + // This checks for conflicts with existing bookings in the lead window + if ( + validateLeadPeriodOptimized( + dayjs_date, + leadDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + return true; + } + } else if ( + selectedDates[0] && + dayjs_date.isSameOrBefore( + BookingDate.from(selectedDates[0]).toDayjs(), + "day" + ) + ) { + // Date is before or same as selected start - still needs validation as potential start + // This handles the case where user clicks a date before their current selection + // (which in Flatpickr range mode would reset and start a new range) + if (leadDays > 0) { + const minStartDate = today.add(leadDays, "day"); + if (dayjs_date.isBefore(minStartDate, "day")) { + return true; + } + } + + if ( + validateLeadPeriodOptimized( + dayjs_date, + leadDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + return true; + } + } else if ( + selectedDates[0] && + dayjs_date.isAfter(BookingDate.from(selectedDates[0]).toDayjs(), "day") + ) { + // Potential end date - any date after the start could become the new end + // This applies whether we have an end date selected or not + const start = BookingDate.from(selectedDates[0]).toDayjs(); + + // Basic end date validations + if (dayjs_date.isBefore(start, "day")) return true; + + // Calculate the target end date for fixed-duration modes + let calculatedEnd = null; + if ( + config.calculatedDueDate && + !config.calculatedDueDate.isBefore(start, "day") + ) { + calculatedEnd = config.calculatedDueDate; + } else if (maxPeriod > 0) { + calculatedEnd = calculateMaxEndDate(start, maxPeriod); + } + + // In end_date_only mode, the target end date is ALWAYS selectable + // Skip all other validation for it (trail period, range overlap, etc.) + if (isEndDateOnly && calculatedEnd && dayjs_date.isSame(calculatedEnd, "day")) { + return false; + } + + // Use backend-calculated due date when available (respects useDaysMode/calendar) + // This correctly calculates the Nth opening day from start, skipping closed days + // Fall back to simple maxPeriod arithmetic only if no calculated date + if (calculatedEnd) { + if (dayjs_date.isAfter(calculatedEnd, "day")) + return true; + } + + // Optimized trail period validation using range queries + if ( + validateTrailPeriodOptimized( + dayjs_date, + trailDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + return true; + } + + // In end_date_only mode, intermediate dates are not disabled here + // (they use click prevention instead for better UX) + if (isEndDateOnly) { + // Intermediate date - don't disable, click prevention handles it + return false; + } + + // Check if the booking range [start, end] would conflict with all items + // This mirrors the backend's BETWEEN-based overlap detection + if ( + validateRangeOverlapForEndDate( + start, + dayjs_date, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + return true; + } + } + + return false; + }; +} + +/** + * Pure function for Flatpickr's `disable` option. + * Disables dates that overlap with existing bookings or checkouts for the selected item, or when not enough items are available. + * Also handles end_date_only constraint mode by disabling intermediate dates. + * + * @param {Array} bookings - Array of booking objects ({ booking_id, item_id, start_date, end_date }) + * @param {Array} checkouts - Array of checkout objects ({ item_id, due_date, ... }) + * @param {Array} bookableItems - Array of all bookable item objects (must have item_id) + * @param {number|string|null} selectedItem - The currently selected item (item_id or null for 'any') + * @param {number|string|null} editBookingId - The booking_id being edited (if any) + * @param {Array} selectedDates - Array of currently selected dates in Flatpickr (can be empty, or [start], or [start, end]) + * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, booking_constraint_mode, etc.) + * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests + * @param {Object} options - Additional options for optimization + * @param {Array} [options.holidays] - Array of holiday dates in YYYY-MM-DD format + * @returns {import('../../../types/bookings').AvailabilityResult} + */ +export function calculateDisabledDates( + bookings, + checkouts, + bookableItems, + selectedItem, + editBookingId, + selectedDates = [], + circulationRules = {}, + todayArg = undefined, + options = {} +) { + const holidays = options.holidays || []; + const normalizedSelectedItem = + selectedItem != null ? String(selectedItem) : null; + + // Build IntervalTree with all booking/checkout data + const intervalTree = buildIntervalTree( + bookings, + checkouts, + circulationRules + ); + + // Extract and validate configuration + const config = extractBookingConfiguration(circulationRules, todayArg); + const allItemIds = bookableItems.map(i => String(i.item_id)); + + // Create optimized disable function using extracted helper + const normalizedEditBookingId = + editBookingId != null ? Number(editBookingId) : null; + const disableFunction = createDisableFunction( + intervalTree, + config, + bookableItems, + normalizedSelectedItem, + normalizedEditBookingId, + selectedDates, + holidays + ); + + // Build unavailableByDate for backward compatibility and markers + // Pass options for performance optimization + + const unavailableByDate = buildUnavailableByDateMap( + intervalTree, + config.today, + allItemIds, + normalizedEditBookingId, + options + ); + + addHolidayMarkers(unavailableByDate, holidays, allItemIds); + + addLeadPeriodFromTodayMarkers( + unavailableByDate, + config.today, + config.leadDays, + allItemIds + ); + + addTheoreticalLeadPeriodMarkers( + unavailableByDate, + intervalTree, + config.today, + config.leadDays, + normalizedEditBookingId + ); + + return { + disable: disableFunction, + unavailableByDate: unavailableByDate, + }; +} + +// Re-export buildIntervalTree for consumers that need direct access +export { buildIntervalTree }; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/index.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/index.mjs new file mode 100644 index 00000000000..153d4728f54 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/index.mjs @@ -0,0 +1,42 @@ +/** + * Availability module - re-exports for backward compatibility. + * + * This module consolidates all availability-related exports from the split modules. + * Import from this index for the same API as the original availability.mjs. + * + * @module availability + */ + +export { + extractBookingConfiguration, + deriveEffectiveRules, + toEffectiveRules, + calculateMaxBookingPeriod, +} from "./rules.mjs"; + +export { + calculateMaxEndDate, + validateBookingPeriod, + validateLeadPeriodOptimized, + validateTrailPeriodOptimized, + validateRangeOverlapForEndDate, + getAvailableItemsForPeriod, +} from "./period-validators.mjs"; + +export { + buildUnavailableByDateMap, + addHolidayMarkers, + addLeadPeriodFromTodayMarkers, + addTheoreticalLeadPeriodMarkers, +} from "./unavailable-map.mjs"; + +export { + calculateDisabledDates, + buildIntervalTree, +} from "./disabled-dates.mjs"; + +export { + findFirstBlockingDate, + calculateAvailabilityData, + handleBookingDateChange, +} from "./date-change.mjs"; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/period-validators.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/period-validators.mjs new file mode 100644 index 00000000000..1d4cbdc0140 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/period-validators.mjs @@ -0,0 +1,192 @@ +/** + * Period validation utilities for booking availability. + * @module availability/period-validators + */ + +import { BookingDate } from "../BookingDate.mjs"; +import { + queryRangeAndResolve, + createConflictContext, +} from "../conflict-resolution.mjs"; +import { buildIntervalTree } from "../algorithms/interval-tree.mjs"; + +/** + * Calculates the maximum end date for a booking period based on start date and maximum period. + * Follows Koha circulation behavior where the start date counts as day 1. + * + * Example: issuelength=30, start=Feb 20 → end=March 21 (day 1 through day 30) + * + * @param {Date|string|import('dayjs').Dayjs} startDate - The start date + * @param {number} maxPeriod - Maximum period in days (from circulation rules) + * @returns {import('dayjs').Dayjs} The maximum end date + */ +export function calculateMaxEndDate(startDate, maxPeriod) { + if (!maxPeriod || maxPeriod <= 0) { + throw new Error("maxPeriod must be a positive number"); + } + + const start = BookingDate.from(startDate).toDayjs(); + // Start date is day 1, so end = start + (maxPeriod - 1) + return start.add(maxPeriod - 1, "day"); +} + +/** + * Validates if an end date exceeds the maximum allowed period + * + * @param {Date|string|import('dayjs').Dayjs} startDate - The start date + * @param {Date|string|import('dayjs').Dayjs} endDate - The proposed end date + * @param {number} maxPeriod - Maximum period in days + * @returns {boolean} True if end date is valid (within limits) + */ +export function validateBookingPeriod(startDate, endDate, maxPeriod) { + if (!maxPeriod || maxPeriod <= 0) { + return true; // No limit + } + + const maxEndDate = calculateMaxEndDate(startDate, maxPeriod); + const proposedEnd = BookingDate.from(endDate).toDayjs(); + + return !proposedEnd.isAfter(maxEndDate, "day"); +} + +/** + * Optimized lead period validation using range queries instead of individual point queries + * @param {import("dayjs").Dayjs} startDate - Potential start date to validate + * @param {number} leadDays - Number of lead period days to check + * @param {Object} intervalTree - Interval tree for conflict checking + * @param {string|null} selectedItem - Selected item ID or null + * @param {number|null} editBookingId - Booking ID being edited + * @param {Array} allItemIds - All available item IDs + * @returns {boolean} True if start date should be blocked due to lead period conflicts + */ +export function validateLeadPeriodOptimized( + startDate, + leadDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds +) { + if (leadDays <= 0) return false; + + const leadStart = startDate.subtract(leadDays, "day"); + const leadEnd = startDate.subtract(1, "day"); + + const ctx = createConflictContext(selectedItem, editBookingId, allItemIds); + const result = queryRangeAndResolve( + intervalTree, + leadStart.valueOf(), + leadEnd.valueOf(), + ctx + ); + + return result.hasConflict; +} + +/** + * Optimized trail period validation using range queries instead of individual point queries + * @param {import("dayjs").Dayjs} endDate - Potential end date to validate + * @param {number} trailDays - Number of trail period days to check + * @param {Object} intervalTree - Interval tree for conflict checking + * @param {string|null} selectedItem - Selected item ID or null + * @param {number|null} editBookingId - Booking ID being edited + * @param {Array} allItemIds - All available item IDs + * @returns {boolean} True if end date should be blocked due to trail period conflicts + */ +export function validateTrailPeriodOptimized( + endDate, + trailDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds +) { + if (trailDays <= 0) return false; + + const trailStart = endDate.add(1, "day"); + const trailEnd = endDate.add(trailDays, "day"); + + const ctx = createConflictContext(selectedItem, editBookingId, allItemIds); + const result = queryRangeAndResolve( + intervalTree, + trailStart.valueOf(), + trailEnd.valueOf(), + ctx + ); + + return result.hasConflict; +} + +/** + * Validate if a booking range [startDate, endDate] would conflict with all available items. + * This mirrors the backend's BETWEEN-based overlap detection. + * + * @param {import("dayjs").Dayjs} startDate - Start date of the potential booking + * @param {import("dayjs").Dayjs} endDate - End date to validate + * @param {Object} intervalTree - Interval tree for conflict checking + * @param {string|null} selectedItem - Selected item ID or null for "any item" + * @param {number|null} editBookingId - Booking ID being edited (to exclude) + * @param {Array} allItemIds - All available item IDs + * @returns {boolean} True if end date should be blocked due to range overlap conflicts + */ +export function validateRangeOverlapForEndDate( + startDate, + endDate, + intervalTree, + selectedItem, + editBookingId, + allItemIds +) { + const ctx = createConflictContext(selectedItem, editBookingId, allItemIds); + const result = queryRangeAndResolve( + intervalTree, + startDate.valueOf(), + endDate.valueOf(), + ctx + ); + + return result.hasConflict; +} + +/** + * Get items available for the entire specified period (no booking/checkout conflicts). + * Used for "any item" mode payload construction at submission time to implement + * upstream's 3-way logic: 0 available → error, 1 → auto-assign, 2+ → send itemtype_id. + * + * @param {string} startDate - ISO start date + * @param {string} endDate - ISO end date + * @param {Array} bookableItems - Constrained bookable items to check + * @param {Array} bookings - All bookings for the biblio + * @param {Array} checkouts - All checkouts for the biblio + * @param {Object} circulationRules - Circulation rules (for interval tree construction) + * @param {number|string|null} editBookingId - Booking being edited (excluded from conflicts) + * @returns {Array} Items available for the entire period + */ +export function getAvailableItemsForPeriod( + startDate, + endDate, + bookableItems, + bookings, + checkouts, + circulationRules, + editBookingId +) { + const tree = buildIntervalTree(bookings, checkouts, circulationRules); + const startTs = BookingDate.from(startDate).toDayjs().startOf("day").valueOf(); + const endTs = BookingDate.from(endDate).toDayjs().startOf("day").valueOf(); + const normalizedEditId = + editBookingId != null ? Number(editBookingId) : null; + + return bookableItems.filter(item => { + const itemId = String(item.item_id); + const conflicts = tree + .queryRange(startTs, endTs, itemId) + .filter( + c => + !normalizedEditId || + c.metadata.booking_id != normalizedEditId + ) + .filter(c => c.type === "booking" || c.type === "checkout"); + return conflicts.length === 0; + }); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/rules.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/rules.mjs new file mode 100644 index 00000000000..999ebb50633 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/rules.mjs @@ -0,0 +1,109 @@ +/** + * Circulation rules utilities for booking availability. + * @module availability/rules + */ + +import { BookingDate } from "../BookingDate.mjs"; +import { CONSTRAINT_MODE_END_DATE_ONLY } from "../constants.mjs"; + +/** + * Extracts and validates configuration from circulation rules + * @param {Object} circulationRules - Raw circulation rules object + * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests + * @returns {Object} Normalized configuration object + */ +export function extractBookingConfiguration(circulationRules, todayArg) { + const today = todayArg + ? BookingDate.from(todayArg).toDayjs() + : BookingDate.today().toDayjs(); + const leadDays = Number(circulationRules?.bookings_lead_period) || 0; + const trailDays = Number(circulationRules?.bookings_trail_period) || 0; + // In unconstrained mode, do not enforce a default max period + const maxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 0; + const isEndDateOnly = + circulationRules?.booking_constraint_mode === + CONSTRAINT_MODE_END_DATE_ONLY; + const calculatedDueDate = circulationRules?.calculated_due_date + ? BookingDate.from(circulationRules.calculated_due_date).toDayjs() + : null; + const calculatedPeriodDays = Number( + circulationRules?.calculated_period_days + ) + ? Number(circulationRules.calculated_period_days) + : null; + + return { + today, + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + calculatedDueDate, + calculatedPeriodDays, + }; +} + +/** + * Derive effective circulation rules with constraint options applied. + * - Applies maxPeriod only for constraining modes + * - Strips caps for unconstrained mode + * @param {import('../../../types/bookings').CirculationRule} [baseRules={}] + * @param {import('../../../types/bookings').ConstraintOptions} [constraintOptions={}] + * @returns {import('../../../types/bookings').CirculationRule} + */ +export function deriveEffectiveRules(baseRules = {}, constraintOptions = {}) { + const effectiveRules = { ...baseRules }; + const mode = constraintOptions.dateRangeConstraint; + if (mode === "issuelength" || mode === "issuelength_with_renewals") { + if (constraintOptions.maxBookingPeriod) { + effectiveRules.maxPeriod = constraintOptions.maxBookingPeriod; + } + } else { + if ("maxPeriod" in effectiveRules) delete effectiveRules.maxPeriod; + if ("issuelength" in effectiveRules) delete effectiveRules.issuelength; + } + return effectiveRules; +} + +/** + * Convenience: take full circulationRules array and constraint options, + * return effective rules applying maxPeriod logic. + * @param {import('../../../types/bookings').CirculationRule[]} circulationRules + * @param {import('../../../types/bookings').ConstraintOptions} [constraintOptions={}] + * @returns {import('../../../types/bookings').CirculationRule} + */ +export function toEffectiveRules(circulationRules, constraintOptions = {}) { + const baseRules = circulationRules?.[0] || {}; + return deriveEffectiveRules(baseRules, constraintOptions); +} + +/** + * Calculate maximum booking period from circulation rules and constraint mode. + */ +export function calculateMaxBookingPeriod( + circulationRules, + dateRangeConstraint, + customDateRangeFormula = null +) { + if (!dateRangeConstraint) return null; + const rules = circulationRules?.[0]; + if (!rules) return null; + const issuelength = parseInt(rules.issuelength) || 0; + switch (dateRangeConstraint) { + case "issuelength": + return issuelength; + case "issuelength_with_renewals": + const renewalperiod = parseInt(rules.renewalperiod) || 0; + const renewalsallowed = parseInt(rules.renewalsallowed) || 0; + return issuelength + renewalperiod * renewalsallowed; + case "custom": + return typeof customDateRangeFormula === "function" + ? customDateRangeFormula(rules) + : null; + default: + return null; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/unavailable-map.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/unavailable-map.mjs new file mode 100644 index 00000000000..7803aa9e801 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/unavailable-map.mjs @@ -0,0 +1,238 @@ +/** + * Unavailable date map builders for booking availability. + * @module availability/unavailable-map + */ + +import { BookingDate, addDays, subDays } from "../BookingDate.mjs"; +import { SweepLineProcessor } from "../algorithms/sweep-line-processor.mjs"; +import { + CALENDAR_BUFFER_DAYS, + DEFAULT_LOOKAHEAD_DAYS, + MAX_SEARCH_DAYS, +} from "../constants.mjs"; + +/** + * Build unavailableByDate map from IntervalTree for backward compatibility + * @param {import('../algorithms/interval-tree.mjs').IntervalTree} intervalTree - The interval tree containing all bookings/checkouts + * @param {import('dayjs').Dayjs} today - Today's date for range calculation + * @param {Array} allItemIds - Array of all item IDs + * @param {number|string|null} editBookingId - The booking_id being edited (exclude from results) + * @param {import('../../../types/bookings').ConstraintOptions} options - Additional options for optimization + * @returns {import('../../../types/bookings').UnavailableByDate} + */ +export function buildUnavailableByDateMap( + intervalTree, + today, + allItemIds, + editBookingId, + options = {} +) { + /** @type {import('../../../types/bookings').UnavailableByDate} */ + const unavailableByDate = {}; + + if (!intervalTree || intervalTree.size === 0) { + return unavailableByDate; + } + + let startDate, endDate; + if ( + options.onDemand && + options.visibleStartDate && + options.visibleEndDate + ) { + startDate = subDays(options.visibleStartDate, CALENDAR_BUFFER_DAYS); + endDate = addDays(options.visibleEndDate, CALENDAR_BUFFER_DAYS); + } else { + startDate = subDays(today, CALENDAR_BUFFER_DAYS); + endDate = addDays(today, DEFAULT_LOOKAHEAD_DAYS); + } + + const rangeIntervals = intervalTree.queryRange( + startDate.toDate(), + endDate.toDate() + ); + + // Exclude the booking being edited + const relevantIntervals = editBookingId + ? rangeIntervals.filter( + interval => interval.metadata?.booking_id != editBookingId + ) + : rangeIntervals; + + const processor = new SweepLineProcessor(); + const sweptMap = processor.processIntervals( + relevantIntervals, + startDate.toDate(), + endDate.toDate(), + allItemIds + ); + + // Ensure the map contains all dates in the requested range, even if empty + const filledMap = sweptMap && typeof sweptMap === "object" ? sweptMap : {}; + for ( + let d = startDate.clone(); + d.isSameOrBefore(endDate, "day"); + d = d.add(1, "day") + ) { + const key = d.format("YYYY-MM-DD"); + if (!filledMap[key]) filledMap[key] = {}; + } + + // Normalize reasons for legacy API expectations: convert 'core' -> 'booking' + Object.keys(filledMap).forEach(dateKey => { + const byItem = filledMap[dateKey]; + Object.keys(byItem).forEach(itemId => { + const original = byItem[itemId]; + if (original && original instanceof Set) { + const mapped = new Set(); + original.forEach(reason => { + mapped.add(reason === "core" ? "booking" : reason); + }); + byItem[itemId] = mapped; + } + }); + }); + + return filledMap; +} + +/** + * Add holiday markers for dates that are library closed days. + * This ensures visual highlighting for closed days in the calendar. + * + * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify + * @param {Array} holidays - Array of holiday dates in YYYY-MM-DD format + * @param {Array} allItemIds - Array of all item IDs + */ +export function addHolidayMarkers(unavailableByDate, holidays, allItemIds) { + if ( + !holidays || + holidays.length === 0 || + !allItemIds || + allItemIds.length === 0 + ) { + return; + } + + holidays.forEach(dateStr => { + if (!unavailableByDate[dateStr]) { + unavailableByDate[dateStr] = {}; + } + + allItemIds.forEach(itemId => { + if (!unavailableByDate[dateStr][itemId]) { + unavailableByDate[dateStr][itemId] = new Set(); + } + unavailableByDate[dateStr][itemId].add("holiday"); + }); + }); +} + +/** + * Add lead period markers for dates within the lead period from today. + * This ensures visual highlighting for the first booking on a given bibliographic record. + * + * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify + * @param {import('dayjs').Dayjs} today - Today's date + * @param {number} leadDays - Number of lead period days + * @param {Array} allItemIds - Array of all item IDs + */ +export function addLeadPeriodFromTodayMarkers( + unavailableByDate, + today, + leadDays, + allItemIds +) { + if (leadDays <= 0 || !allItemIds || allItemIds.length === 0) return; + + // Add "lead" markers for dates from today to today + leadDays - 1 + for (let i = 0; i < leadDays; i++) { + const date = today.add(i, "day"); + const key = date.format("YYYY-MM-DD"); + + if (!unavailableByDate[key]) { + unavailableByDate[key] = {}; + } + + // Add lead reason for items not already blocked by a stronger reason + allItemIds.forEach(itemId => { + const existing = unavailableByDate[key][itemId]; + if (existing && (existing.has("booking") || existing.has("checkout"))) { + return; // already unavailable for a stronger reason + } + if (!existing) { + unavailableByDate[key][itemId] = new Set(); + } + unavailableByDate[key][itemId].add("lead"); + }); + } +} + +/** + * Add lead period markers for dates after trail periods where the lead period + * would overlap with the trail. This ensures visual highlighting for the + * theoretical lead period after existing bookings. + * + * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify + * @param {import('../algorithms/interval-tree.mjs').IntervalTree} intervalTree - The interval tree with all bookings/checkouts + * @param {import('dayjs').Dayjs} today - Today's date + * @param {number} leadDays - Number of lead period days + * @param {number|null} editBookingId - Booking ID being edited (to exclude) + */ +export function addTheoreticalLeadPeriodMarkers( + unavailableByDate, + intervalTree, + today, + leadDays, + editBookingId +) { + if (leadDays <= 0 || !intervalTree || intervalTree.size === 0) return; + + // Query all trail intervals in a reasonable range + const rangeStart = today.subtract(CALENDAR_BUFFER_DAYS, "day"); + const rangeEnd = today.add(MAX_SEARCH_DAYS, "day"); + + const allIntervals = intervalTree.queryRange( + rangeStart.valueOf(), + rangeEnd.valueOf() + ); + + // Filter to get only trail intervals + const trailIntervals = allIntervals.filter( + interval => + interval.type === "trail" && + (!editBookingId || interval.metadata?.booking_id != editBookingId) + ); + + trailIntervals.forEach(trailInterval => { + // Trail interval ends at trailInterval.end + // After the trail, the next booking's lead period must not overlap + // So dates from trailEnd+1 to trailEnd+leadDays are blocked due to lead requirements + const trailEnd = BookingDate.from(trailInterval.end).toDayjs(); + const itemId = trailInterval.itemId; + + for (let i = 1; i <= leadDays; i++) { + const blockedDate = trailEnd.add(i, "day"); + // Only mark future dates + if (blockedDate.isBefore(today, "day")) continue; + + const key = blockedDate.format("YYYY-MM-DD"); + + if (!unavailableByDate[key]) { + unavailableByDate[key] = {}; + } + + const existing = unavailableByDate[key][itemId]; + if (existing && (existing.has("booking") || existing.has("checkout"))) { + continue; // already unavailable for a stronger reason + } + + if (!existing) { + unavailableByDate[key][itemId] = new Set(); + } + + // Add "lead" reason to indicate this is blocked due to lead period + unavailableByDate[key][itemId].add("lead"); + } + }); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/conflict-resolution.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/conflict-resolution.mjs new file mode 100644 index 00000000000..3cb8e552cd0 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/conflict-resolution.mjs @@ -0,0 +1,133 @@ +/** + * Conflict resolution utilities for booking availability. + * + * This module centralizes the conflict detection and resolution logic that was + * previously duplicated across 6 different locations in the codebase. + * + * @module conflict-resolution + */ + +/** + * @typedef {Object} ConflictContext + * @property {string|null} selectedItem - Selected item ID or null for "any item" mode + * @property {number|null} editBookingId - Booking ID being edited (excluded from conflicts) + * @property {string[]} allItemIds - All available item IDs for "any item" mode resolution + */ + +/** + * @typedef {Object} ConflictResult + * @property {boolean} hasConflict - Whether there is a blocking conflict + * @property {Array} conflicts - The relevant conflicts (filtered by editBookingId) + * @property {Set} [itemsWithConflicts] - Set of item IDs that have conflicts (any item mode only) + */ + +/** + * Filter conflicts by edit booking ID and resolve based on item selection mode. + * + * This function encapsulates the conflict resolution logic that determines whether + * a date/range should be blocked based on existing bookings and checkouts. + * + * Resolution modes: + * - **Single item mode** (selectedItem !== null): Any conflict blocks the date + * - **Any item mode** (selectedItem === null): Only block if ALL items have conflicts + * + * @param {Array} conflicts - Raw conflicts from interval tree query + * @param {ConflictContext} ctx - Context for conflict resolution + * @returns {ConflictResult} Resolution result with conflict status and details + * + * @example + * // Single item mode + * const result = resolveConflicts(conflicts, { + * selectedItem: '123', + * editBookingId: null, + * allItemIds: ['123', '456'] + * }); + * if (result.hasConflict) { // Block the date } + * + * @example + * // Any item mode - only blocks if all items unavailable + * const result = resolveConflicts(conflicts, { + * selectedItem: null, + * editBookingId: 789, // Editing booking 789, exclude from conflicts + * allItemIds: ['123', '456', '789'] + * }); + */ +export function resolveConflicts(conflicts, ctx) { + const { selectedItem, editBookingId, allItemIds } = ctx; + + // Filter out the booking being edited + const relevant = editBookingId + ? conflicts.filter(c => c.metadata?.booking_id != editBookingId) + : conflicts; + + if (relevant.length === 0) { + return { hasConflict: false, conflicts: [] }; + } + + // Single item mode: any conflict blocks + if (selectedItem) { + return { hasConflict: true, conflicts: relevant }; + } + + // Any item mode: only block if ALL items have conflicts + const itemsWithConflicts = new Set(relevant.map(c => String(c.itemId))); + const allBlocked = + allItemIds.length > 0 && + allItemIds.every(id => itemsWithConflicts.has(String(id))); + + return { + hasConflict: allBlocked, + conflicts: relevant, + itemsWithConflicts, + }; +} + +/** + * Query interval tree for a point in time and resolve conflicts. + * + * Convenience wrapper that combines a point query with conflict resolution. + * + * @param {Object} intervalTree - Interval tree instance + * @param {number} timestamp - Timestamp to query (milliseconds) + * @param {ConflictContext} ctx - Context for conflict resolution + * @returns {ConflictResult} Resolution result + */ +export function queryPointAndResolve(intervalTree, timestamp, ctx) { + const conflicts = intervalTree.query(timestamp, ctx.selectedItem); + return resolveConflicts(conflicts, ctx); +} + +/** + * Query interval tree for a range and resolve conflicts. + * + * Convenience wrapper that combines a range query with conflict resolution. + * + * @param {Object} intervalTree - Interval tree instance + * @param {number} startTs - Start timestamp (milliseconds) + * @param {number} endTs - End timestamp (milliseconds) + * @param {ConflictContext} ctx - Context for conflict resolution + * @returns {ConflictResult} Resolution result + */ +export function queryRangeAndResolve(intervalTree, startTs, endTs, ctx) { + const conflicts = intervalTree.queryRange(startTs, endTs, ctx.selectedItem); + return resolveConflicts(conflicts, ctx); +} + +/** + * Create a conflict context object from common parameters. + * + * Helper to construct a ConflictContext from the parameters commonly + * passed around in availability checking functions. + * + * @param {string|number|null} selectedItem - Selected item ID or null + * @param {string|number|null} editBookingId - Booking ID being edited + * @param {string[]} allItemIds - All available item IDs + * @returns {ConflictContext} + */ +export function createConflictContext(selectedItem, editBookingId, allItemIds) { + return { + selectedItem: selectedItem != null ? String(selectedItem) : null, + editBookingId: editBookingId != null ? Number(editBookingId) : null, + allItemIds: allItemIds.map(id => String(id)), + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs new file mode 100644 index 00000000000..979b83b0463 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs @@ -0,0 +1,59 @@ +/** + * Shared constants for the booking system (business logic + UI) + * @module constants + */ + +/** @constant {string} Constraint mode for end-date-only selection */ +export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only"; +export const CONSTRAINT_MODE_NORMAL = "normal"; + +// Selection semantics (logging, diagnostics) +export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE"; +export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM"; + +// UI class names (used across calendar/adapters/composables) +export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER = + "booking-constrained-range-marker"; +export const CLASS_BOOKING_DAY_HOVER_LEAD = "booking-day--hover-lead"; +export const CLASS_BOOKING_DAY_HOVER_TRAIL = "booking-day--hover-trail"; +export const CLASS_BOOKING_INTERMEDIATE_BLOCKED = + "booking-intermediate-blocked"; +export const CLASS_BOOKING_MARKER_COUNT = "booking-marker-count"; +export const CLASS_BOOKING_MARKER_DOT = "booking-marker-dot"; +export const CLASS_BOOKING_MARKER_GRID = "booking-marker-grid"; +export const CLASS_BOOKING_MARKER_ITEM = "booking-marker-item"; +export const CLASS_BOOKING_OVERRIDE_ALLOWED = "booking-override-allowed"; +export const CLASS_FLATPICKR_DAY = "flatpickr-day"; +export const CLASS_FLATPICKR_DISABLED = "flatpickr-disabled"; +export const CLASS_FLATPICKR_NOT_ALLOWED = "notAllowed"; +export const CLASS_BOOKING_LOAN_BOUNDARY = "booking-loan-boundary"; + +// Data attributes +export const DATA_ATTRIBUTE_BOOKING_OVERRIDE = "data-booking-override"; + +// Calendar range constants (days) +export const CALENDAR_BUFFER_DAYS = 7; +export const DEFAULT_LOOKAHEAD_DAYS = 90; +export const MAX_SEARCH_DAYS = 365; +export const DEFAULT_MAX_PERIOD_DAYS = 30; + +// Calendar highlighting retry configuration +export const HIGHLIGHTING_MAX_RETRIES = 5; + +// Calendar navigation delay (ms) - allows Flatpickr to settle before jumping +export const CALENDAR_NAVIGATION_DELAY_MS = 100; + +// Debounce delays (ms) +export const PATRON_SEARCH_DEBOUNCE_MS = 250; +export const HOLIDAY_EXTENSION_DEBOUNCE_MS = 150; + +// Holiday prefetch configuration +export const HOLIDAY_PREFETCH_THRESHOLD_DAYS = 60; +export const HOLIDAY_PREFETCH_MONTHS = 6; + +// Marker type mapping (IntervalTree/Sweep reasons → CSS class names) +export const MARKER_TYPE_MAP = Object.freeze({ + booking: "booked", + core: "booked", + checkout: "checked-out", +}); diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constraints.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constraints.mjs new file mode 100644 index 00000000000..92fe18093a7 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constraints.mjs @@ -0,0 +1,175 @@ +/** + * Constraint filtering functions for the booking system. + * + * This module handles filtering of pickup locations, bookable items, + * and item types based on selection constraints. + * + * @module constraints + */ + +import { idsEqual, includesId } from "./id-utils.mjs"; + +/** + * Helper to standardize constraint function return shape + * @template T + * @param {T[]} filtered - The filtered array + * @param {number} total - Total count before filtering + * @returns {import('../../types/bookings').ConstraintResult} + */ +function buildConstraintResult(filtered, total) { + const filteredOutCount = total - filtered.length; + return { + filtered, + filteredOutCount, + total, + constraintApplied: filtered.length !== total, + }; +} + +/** + * Generic constraint application function. + * Filters items using an array of predicates with AND logic. + * + * @template T + * @param {T[]} items - Items to filter + * @param {Array<(item: T) => boolean>} predicates - Filter predicates (AND logic) + * @returns {import('../../types/bookings').ConstraintResult} + */ +export function applyConstraints(items, predicates) { + if (predicates.length === 0) { + return buildConstraintResult(items, items.length); + } + + const filtered = items.filter(item => + predicates.every(predicate => predicate(item)) + ); + + return buildConstraintResult(filtered, items.length); +} + +/** + * Constrain pickup locations based on selected itemtype or item + * Returns { filtered, filteredOutCount, total, constraintApplied } + * + * @param {Array} pickupLocations + * @param {Array} bookableItems + * @param {string|number|null} bookingItemtypeId + * @param {string|number|null} bookingItemId + * @returns {import('../../types/bookings').ConstraintResult} + */ +export function constrainPickupLocations( + pickupLocations, + bookableItems, + bookingItemtypeId, + bookingItemId +) { + const predicates = []; + + // When a specific item is selected, location must allow pickup of that item + if (bookingItemId) { + predicates.push( + loc => + loc.pickup_items && includesId(loc.pickup_items, bookingItemId) + ); + } + // When an itemtype is selected, location must allow pickup of at least one item of that type + else if (bookingItemtypeId) { + predicates.push( + loc => + loc.pickup_items && + bookableItems.some( + item => + idsEqual(item.item_type_id, bookingItemtypeId) && + includesId(loc.pickup_items, item.item_id) + ) + ); + } + + return applyConstraints(pickupLocations, predicates); +} + +/** + * Constrain bookable items based on selected pickup location and/or itemtype + * Returns { filtered, filteredOutCount, total, constraintApplied } + * + * @param {Array} bookableItems + * @param {Array} pickupLocations + * @param {string|null} pickupLibraryId + * @param {string|number|null} bookingItemtypeId + * @returns {import('../../types/bookings').ConstraintResult} + */ +export function constrainBookableItems( + bookableItems, + pickupLocations, + pickupLibraryId, + bookingItemtypeId +) { + const predicates = []; + + // When a pickup location is selected, item must be pickable at that location + if (pickupLibraryId) { + predicates.push(item => + pickupLocations.some( + loc => + idsEqual(loc.library_id, pickupLibraryId) && + loc.pickup_items && + includesId(loc.pickup_items, item.item_id) + ) + ); + } + + // When an itemtype is selected, item must match that type + if (bookingItemtypeId) { + predicates.push(item => idsEqual(item.item_type_id, bookingItemtypeId)); + } + + return applyConstraints(bookableItems, predicates); +} + +/** + * Constrain item types based on selected pickup location or item + * Returns { filtered, filteredOutCount, total, constraintApplied } + * @param {Array} itemTypes + * @param {Array} bookableItems + * @param {Array} pickupLocations + * @param {string|null} pickupLibraryId + * @param {string|number|null} bookingItemId + * @returns {import('../../types/bookings').ConstraintResult} + */ +export function constrainItemTypes( + itemTypes, + bookableItems, + pickupLocations, + pickupLibraryId, + bookingItemId +) { + const predicates = []; + + // When a specific item is selected, only show its itemtype + if (bookingItemId) { + predicates.push(type => + bookableItems.some( + item => + idsEqual(item.item_id, bookingItemId) && + idsEqual(item.item_type_id, type.item_type_id) + ) + ); + } + // When a pickup location is selected, only show itemtypes that have items pickable there + else if (pickupLibraryId) { + predicates.push(type => + bookableItems.some( + item => + idsEqual(item.item_type_id, type.item_type_id) && + pickupLocations.some( + loc => + idsEqual(loc.library_id, pickupLibraryId) && + loc.pickup_items && + includesId(loc.pickup_items, item.item_id) + ) + ) + ); + } + + return applyConstraints(itemTypes, predicates); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/highlighting.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/highlighting.mjs new file mode 100644 index 00000000000..5e96a1ed8fb --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/highlighting.mjs @@ -0,0 +1,80 @@ +/** + * Calendar highlighting logic for the booking system. + * + * This module handles constraint highlighting calculations and + * calendar navigation target determination. + * + * @module highlighting + */ + +import { toDayjs } from "./BookingDate.mjs"; +import { createConstraintStrategy } from "./strategies.mjs"; + +/** + * Calculate constraint highlighting data for calendar display + * @param {Date|import('dayjs').Dayjs} startDate - Selected start date + * @param {Object} circulationRules - Circulation rules object + * @param {Object} constraintOptions - Additional constraint options + * @returns {import('../../types/bookings').ConstraintHighlighting | null} Constraint highlighting + */ +export function calculateConstraintHighlighting( + startDate, + circulationRules, + constraintOptions = {} +) { + const strategy = createConstraintStrategy( + circulationRules?.booking_constraint_mode + ); + return strategy.calculateConstraintHighlighting( + startDate, + circulationRules, + constraintOptions + ); +} + +/** + * Determine if calendar should navigate to show target end date + * @param {Date|import('dayjs').Dayjs} startDate - Selected start date + * @param {Date|import('dayjs').Dayjs} targetEndDate - Calculated target end date + * @param {import('../../types/bookings').CalendarCurrentView} currentView - Current calendar view info + * @returns {import('../../types/bookings').CalendarNavigationTarget} + */ +export function getCalendarNavigationTarget( + startDate, + targetEndDate, + currentView = {} +) { + const start = toDayjs(startDate); + const target = toDayjs(targetEndDate); + + // Never navigate backwards if target is before the chosen start + if (target.isBefore(start, "day")) { + return { shouldNavigate: false }; + } + + // If we know the currently visible range, do not navigate when target is already visible + if (currentView.visibleStartDate && currentView.visibleEndDate) { + const visibleStart = toDayjs(currentView.visibleStartDate).startOf( + "day" + ); + const visibleEnd = toDayjs(currentView.visibleEndDate).endOf("day"); + const inView = + target.isSameOrAfter(visibleStart, "day") && + target.isSameOrBefore(visibleEnd, "day"); + if (inView) { + return { shouldNavigate: false }; + } + } + + // Fallback: navigate when target month differs from start month + if (start.month() !== target.month() || start.year() !== target.year()) { + return { + shouldNavigate: true, + targetMonth: target.month(), + targetYear: target.year(), + targetDate: target.toDate(), + }; + } + + return { shouldNavigate: false }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs new file mode 100644 index 00000000000..d6f9187968e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs @@ -0,0 +1,40 @@ +/** + * Utilities for comparing and handling mixed string/number IDs consistently + * @module id-utils + */ + +/** + * Compare two IDs for equality, handling mixed string/number types + * @param {string|number|null|undefined} a - First ID to compare + * @param {string|number|null|undefined} b - Second ID to compare + * @returns {boolean} True if IDs are equal (after string conversion) + */ +export function idsEqual(a, b) { + if (a == null || b == null) return false; + return String(a) === String(b); +} + +/** + * Check if a list contains a target ID, handling mixed string/number types + * @param {Array} list - Array of IDs to search + * @param {string|number} target - ID to find + * @returns {boolean} True if target ID is found in the list + */ +export function includesId(list, target) { + if (!Array.isArray(list)) return false; + return list.some(id => idsEqual(id, target)); +} + +/** + * Normalize an identifier's type to match a sample (number|string) for strict comparisons. + * If sample is a number, casts value to number; otherwise casts to string. + * Falls back to string when sample is null/undefined. + * + * @param {unknown} sample - A sample value to infer the desired type from + * @param {unknown} value - The value to normalize + * @returns {string|number|null} + */ +export function normalizeIdType(sample, value) { + if (value == null) return null; + return typeof sample === "number" ? Number(value) : String(value); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs new file mode 100644 index 00000000000..ae7ec74cb2e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs @@ -0,0 +1,293 @@ +/** + * bookingLogger.js - Debug logging utility for the booking system + * + * Provides configurable debug logging that can be enabled/disabled at runtime. + * Logs can be controlled via localStorage or global variables. + * + * ## Browser vs Node.js Environment + * + * This module uses several browser-specific APIs that behave differently in Node.js: + * + * | API | Browser | Node.js (test env) | + * |------------------|------------------------------|------------------------------| + * | localStorage | Persists debug settings | Not available, uses defaults | + * | console.group | Creates collapsible groups | Plain text output | + * | console.time | Performance timing | Works (Node 8+) | + * | performance.now | High-res timing | Works via perf_hooks | + * | window | Global browser object | undefined or JSDOM mock | + * + * The module initializes with logging DISABLED by default. In browsers, set + * `localStorage.setItem('koha.booking.debug', 'true')` or call + * `window.BookingDebug.enable()` to enable. + * + * In Node.js test environments, a simplified BookingDebug object is attached to + * globalThis.window if JSDOM creates one. + */ + +class BookingLogger { + constructor(module) { + this.module = module; + this.enabled = false; + // Don't log anything by default unless explicitly enabled + this.enabledLevels = new Set(); + // Track active timers and groups to prevent console errors + this._activeTimers = new Set(); + this._activeGroups = []; + // Initialize log buffer and timers in constructor + this._logBuffer = []; + this._timers = {}; + + // Check for debug configuration + if (typeof window !== "undefined" && window.localStorage) { + // Check localStorage first, then global variable + this.enabled = + window.localStorage.getItem("koha.booking.debug") === "true" || + window["KOHA_BOOKING_DEBUG"] === true; + + // Allow configuring specific log levels + const levels = window.localStorage.getItem( + "koha.booking.debug.levels" + ); + if (levels) { + this.enabledLevels = new Set(levels.split(",")); + } + } + } + + /** + * Enable or disable debug logging + * @param {boolean} enabled + */ + setEnabled(enabled) { + this.enabled = enabled; + if (enabled) { + // When enabling debug, include all levels + this.enabledLevels = new Set(["debug", "info", "warn", "error"]); + } else { + // When disabling, clear all levels + this.enabledLevels = new Set(); + } + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.setItem( + "koha.booking.debug", + enabled.toString() + ); + } + } + + /** + * Set which log levels are enabled + * @param {string[]} levels - Array of level names (debug, info, warn, error) + */ + setLevels(levels) { + this.enabledLevels = new Set(levels); + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.setItem( + "koha.booking.debug.levels", + levels.join(",") + ); + } + } + + /** + * Core logging method + * @param {string} level + * @param {string} message + * @param {...unknown} args + */ + log(level, message, ...args) { + if (!this.enabledLevels.has(level)) return; + + const timestamp = new Date().toISOString(); + const prefix = `[${timestamp}] [${ + this.module + }] [${level.toUpperCase()}]`; + + console[level](prefix, message, ...args); + + this._logBuffer.push({ + timestamp, + module: this.module, + level, + message, + args, + }); + + if (this._logBuffer.length > 1000) { + this._logBuffer = this._logBuffer.slice(-1000); + } + } + + // Convenience methods + debug(message, ...args) { + this.log("debug", message, ...args); + } + info(message, ...args) { + this.log("info", message, ...args); + } + warn(message, ...args) { + this.log("warn", message, ...args); + } + error(message, ...args) { + this.log("error", message, ...args); + } + + /** + * Performance timing utilities + */ + time(label) { + if (!this.enabledLevels.has("debug")) return; + const key = `[${this.module}] ${label}`; + console.time(key); + this._activeTimers.add(label); + this._timers[label] = performance.now(); + } + + timeEnd(label) { + if (!this.enabledLevels.has("debug")) return; + // Only call console.timeEnd if we actually started this timer + if (!this._activeTimers.has(label)) return; + + const key = `[${this.module}] ${label}`; + console.timeEnd(key); + this._activeTimers.delete(label); + + // Also log the duration + if (this._timers[label]) { + const duration = performance.now() - this._timers[label]; + this.debug(`${label} completed in ${duration.toFixed(2)}ms`); + delete this._timers[label]; + } + } + + /** + * Group related log entries + */ + group(label) { + if (!this.enabledLevels.has("debug")) return; + console.group(`[${this.module}] ${label}`); + this._activeGroups.push(label); + } + + groupEnd() { + if (!this.enabledLevels.has("debug")) return; + // Only call console.groupEnd if we have an active group + if (this._activeGroups.length === 0) return; + + console.groupEnd(); + this._activeGroups.pop(); + } + + /** + * Export logs for bug reports + */ + exportLogs() { + return { + module: this.module, + enabled: this.enabled, + enabledLevels: Array.from(this.enabledLevels), + logs: this._logBuffer || [], + }; + } + + /** + * Clear log buffer + */ + clearLogs() { + this._logBuffer = []; + this._activeTimers.clear(); + this._activeGroups = []; + } +} + +// Create singleton instances for each module +export const managerLogger = new BookingLogger("BookingManager"); +export const calendarLogger = new BookingLogger("BookingCalendar"); + +// Expose debug utilities to browser console +if (typeof window !== "undefined") { + const debugObj = { + // Enable/disable all booking debug logs + enable() { + managerLogger.setEnabled(true); + calendarLogger.setEnabled(true); + console.log("Booking debug logging enabled"); + }, + + disable() { + managerLogger.setEnabled(false); + calendarLogger.setEnabled(false); + console.log("Booking debug logging disabled"); + }, + + // Set specific log levels + setLevels(levels) { + managerLogger.setLevels(levels); + calendarLogger.setLevels(levels); + console.log(`Booking log levels set to: ${levels.join(", ")}`); + }, + + // Export all logs + exportLogs() { + return { + manager: managerLogger.exportLogs(), + calendar: calendarLogger.exportLogs(), + }; + }, + + // Clear all logs + clearLogs() { + managerLogger.clearLogs(); + calendarLogger.clearLogs(); + console.log("Booking logs cleared"); + }, + + // Get current status + status() { + return { + enabled: { + manager: managerLogger.enabled, + calendar: calendarLogger.enabled, + }, + levels: { + manager: Array.from(managerLogger.enabledLevels), + calendar: Array.from(calendarLogger.enabledLevels), + }, + }; + }, + }; + + // Set on browser window + window["BookingDebug"] = debugObj; + + // Only log availability message if debug is already enabled + if (managerLogger.enabled || calendarLogger.enabled) { + console.log("Booking debug utilities available at window.BookingDebug"); + } +} + +// Additional setup for Node.js testing environment +if (typeof globalThis !== "undefined" && typeof window === "undefined") { + // We're in Node.js - set up global.window if it exists + if (globalThis.window) { + const debugObj = { + enable: () => { + managerLogger.setEnabled(true); + calendarLogger.setEnabled(true); + }, + disable: () => { + managerLogger.setEnabled(false); + calendarLogger.setEnabled(false); + }, + exportLogs: () => ({ + manager: managerLogger.exportLogs(), + calendar: calendarLogger.exportLogs(), + }), + status: () => ({ + managerEnabled: managerLogger.enabled, + calendarEnabled: calendarLogger.enabled, + }), + }; + globalThis.window["BookingDebug"] = debugObj; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/markers.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/markers.mjs new file mode 100644 index 00000000000..886ac2baa6d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/markers.mjs @@ -0,0 +1,81 @@ +/** + * Marker generation and aggregation for the booking system. + * + * This module handles generation of calendar markers from availability data + * and aggregation of markers by type for display purposes. + * + * @module markers + */ + +import { BookingDate } from "./BookingDate.mjs"; +import { idsEqual } from "./id-utils.mjs"; +import { MARKER_TYPE_MAP } from "./constants.mjs"; + +/** + * Aggregate all booking/checkouts for a given date (for calendar indicators) + * @param {import('../../types/bookings').UnavailableByDate} unavailableByDate - Map produced by buildUnavailableByDateMap + * @param {string|Date|import("dayjs").Dayjs} dateStr - date to check (YYYY-MM-DD or Date or dayjs) + * @param {Array} bookableItems - Array of all bookable items + * @returns {import('../../types/bookings').CalendarMarker[]} indicators for that date + */ +export function getBookingMarkersForDate( + unavailableByDate, + dateStr, + bookableItems = [] +) { + if (!unavailableByDate) { + return []; + } + + let d; + try { + d = dateStr ? BookingDate.from(dateStr).toDayjs() : BookingDate.today().toDayjs(); + } catch { + d = BookingDate.today().toDayjs(); + } + const key = d.format("YYYY-MM-DD"); + const markers = []; + + const findItem = item_id => { + if (item_id == null) return undefined; + return bookableItems.find(i => idsEqual(i?.item_id, item_id)); + }; + + const entry = unavailableByDate[key]; + + if (!entry) { + return []; + } + + for (const [item_id, reasons] of Object.entries(entry)) { + const item = findItem(item_id); + for (const reason of reasons) { + // Map IntervalTree/Sweep reasons to CSS class names + // lead and trail periods keep their original names for CSS + const type = MARKER_TYPE_MAP[reason] ?? reason; + markers.push({ + /** @type {import('../../types/bookings').MarkerType} */ + type: /** @type {any} */ (type), + item: String(item_id), + itemName: item?.title || String(item_id), + barcode: item?.barcode || item?.external_id || null, + }); + } + } + return markers; +} + +/** + * Aggregate markers by type for display + * @param {Array} markers - Array of booking markers + * @returns {import('../../types/bookings').MarkerAggregation} Aggregated counts by type + */ +export function aggregateMarkersByType(markers) { + return markers.reduce((acc, marker) => { + // Exclude lead and trail markers from visual display + if (marker.type !== "lead" && marker.type !== "trail") { + acc[marker.type] = (acc[marker.type] || 0) + 1; + } + return acc; + }, {}); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs new file mode 100644 index 00000000000..c7a35ee7a45 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs @@ -0,0 +1,315 @@ +import { BookingDate, addDays } from "./BookingDate.mjs"; +import { calculateMaxEndDate } from "./availability.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CONSTRAINT_MODE_NORMAL, + DEFAULT_MAX_PERIOD_DAYS, +} from "./constants.mjs"; +import { + queryRangeAndResolve, + queryPointAndResolve, + createConflictContext, +} from "./conflict-resolution.mjs"; + +/** + * Base strategy with shared logic for constraint highlighting. + * Mode-specific strategies override methods as needed. + */ +const BaseStrategy = { + name: "base", + + /** + * Validate if a start date can be selected. + * Base implementation allows all dates. + */ + validateStartDateSelection() { + return false; + }, + + /** + * Handle intermediate dates between start and end. + * Base implementation has no special handling. + */ + handleIntermediateDate() { + return null; + }, + + /** + * Enforce end date selection rules. + * Base implementation allows any end date. + */ + enforceEndDateSelection() { + return { ok: true }; + }, + + /** + * Calculate target end date from circulation rules or options. + * Shared helper for highlighting calculation. + * @protected + */ + _calculateTargetEnd(start, circulationRules, constraintOptions) { + // Prefer backend-calculated due date (respects useDaysMode/calendar) + const dueStr = circulationRules?.calculated_due_date; + if (dueStr) { + const due = BookingDate.from(dueStr).toDayjs(); + if (!due.isBefore(start, "day")) { + return { + targetEnd: due, + maxPeriod: + Number(circulationRules?.calculated_period_days) || + constraintOptions.maxBookingPeriod, + }; + } + } + + // Fall back to maxPeriod arithmetic + let maxPeriod = constraintOptions.maxBookingPeriod; + if (!maxPeriod) { + maxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + DEFAULT_MAX_PERIOD_DAYS; + } + if (!maxPeriod) return null; + + return { + targetEnd: calculateMaxEndDate(start, maxPeriod), + maxPeriod, + }; + }, + + /** + * Get blocked intermediate dates between start and target end. + * Override in strategies that need to block intermediate dates. + * @protected + * @param {import('dayjs').Dayjs} _start - Start date (unused in base) + * @param {import('dayjs').Dayjs} _targetEnd - Target end date (unused in base) + * @returns {Date[]} + */ + _getBlockedIntermediateDates(_start, _targetEnd) { + return []; + }, + + /** + * Calculate constraint highlighting for the calendar. + * Uses template method pattern - subclasses override _getBlockedIntermediateDates. + * @param {Date|import('dayjs').Dayjs} startDate + * @param {Object} circulationRules + * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}] + * @returns {import('../../types/bookings').ConstraintHighlighting|null} + */ + calculateConstraintHighlighting( + startDate, + circulationRules, + constraintOptions = {} + ) { + const start = BookingDate.from(startDate).toDayjs(); + + const result = this._calculateTargetEnd( + start, + circulationRules, + constraintOptions + ); + if (!result) return null; + + const { targetEnd, maxPeriod } = result; + + return { + startDate: start.toDate(), + targetEndDate: targetEnd.toDate(), + blockedIntermediateDates: this._getBlockedIntermediateDates( + start, + targetEnd + ), + constraintMode: this.name, + maxPeriod, + }; + }, +}; + +/** + * Validate start date for end_date_only mode. + * Checks if the entire booking period (start to calculated end) is available. + * @exported for testability + */ +export function validateEndDateOnlyStartDate( + date, + config, + intervalTree, + selectedItem, + editBookingId, + allItemIds +) { + // Determine target end date based on backend due date override when available + let targetEndDate; + const due = config?.calculatedDueDate || null; + if (due && !due.isBefore(date, "day")) { + targetEndDate = due.clone(); + } else { + const maxPeriod = Number(config?.maxPeriod) || 0; + targetEndDate = + maxPeriod > 0 + ? calculateMaxEndDate(date, maxPeriod).toDate() + : date; + } + + const ctx = createConflictContext(selectedItem, editBookingId, allItemIds); + + if (selectedItem) { + // Single item mode: use range query + const result = queryRangeAndResolve( + intervalTree, + date.valueOf(), + targetEndDate.valueOf(), + ctx + ); + return result.hasConflict; + } else { + // Any item mode: check each day in the range + // Block if all items are unavailable on any single day + for ( + let checkDate = date; + checkDate.isSameOrBefore(targetEndDate, "day"); + checkDate = checkDate.add(1, "day") + ) { + const result = queryPointAndResolve( + intervalTree, + checkDate.valueOf(), + ctx + ); + if (result.hasConflict) { + return true; + } + } + return false; + } +} + +/** + * Handle intermediate date clicks for end_date_only mode. + * Returns true to disable, null to allow normal handling. + * @exported for testability + */ +export function handleEndDateOnlyIntermediateDate(date, selectedDates, config) { + if (!selectedDates || selectedDates.length !== 1) { + return null; + } + + const startDate = BookingDate.from(selectedDates[0]).toDayjs(); + + // Prefer backend due date when provided + const due = config?.calculatedDueDate; + if (due && !due.isBefore(startDate, "day")) { + const expectedEndDate = due.clone(); + if (date.isSame(expectedEndDate, "day")) return null; + if (date.isAfter(expectedEndDate, "day")) return true; + return null; // intermediate left to UI highlighting + click prevention + } + + // Fall back to maxPeriod handling + const maxPeriod = Number(config?.maxPeriod) || 0; + if (!maxPeriod) return null; + + const expectedEndDate = calculateMaxEndDate(startDate, maxPeriod); + if (date.isSame(expectedEndDate, "day")) return null; + if (date.isAfter(expectedEndDate, "day")) return true; + return null; +} + +/** + * Strategy for end_date_only constraint mode. + * Users must select the exact end date calculated from start + period. + */ +const EndDateOnlyStrategy = { + ...BaseStrategy, + name: CONSTRAINT_MODE_END_DATE_ONLY, + + validateStartDateSelection( + dayjsDate, + config, + intervalTree, + selectedItem, + editBookingId, + allItemIds, + selectedDates + ) { + if (!selectedDates || selectedDates.length === 0) { + return validateEndDateOnlyStartDate( + dayjsDate, + config, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ); + } + return false; + }, + + handleIntermediateDate(dayjsDate, selectedDates, config) { + return handleEndDateOnlyIntermediateDate( + dayjsDate, + selectedDates, + config + ); + }, + + /** + * Generate blocked dates between start and target end. + * @override + */ + _getBlockedIntermediateDates(start, targetEnd) { + const diffDays = Math.max(0, targetEnd.diff(start, "day")); + const blockedDates = []; + for (let i = 1; i < diffDays; i++) { + blockedDates.push(addDays(start, i).toDate()); + } + return blockedDates; + }, + + enforceEndDateSelection(dayjsStart, dayjsEnd, circulationRules) { + if (!dayjsEnd) return { ok: true }; + + const dueStr = circulationRules?.calculated_due_date; + let targetEnd; + if (dueStr) { + const due = BookingDate.from(dueStr).toDayjs(); + if (!due.isBefore(dayjsStart, "day")) { + targetEnd = due; + } + } + if (!targetEnd) { + const numericMaxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 0; + // Use calculateMaxEndDate for consistency: end = start + (maxPeriod - 1), as start is day 1 + targetEnd = calculateMaxEndDate(dayjsStart, Math.max(1, numericMaxPeriod)); + } + return { + ok: dayjsEnd.isSame(targetEnd, "day"), + expectedEnd: targetEnd, + }; + }, +}; + +/** + * Strategy for normal constraint mode. + * Users can select any valid date range within the max period. + */ +const NormalStrategy = { + ...BaseStrategy, + name: CONSTRAINT_MODE_NORMAL, + // Uses all base implementations - no overrides needed +}; + +/** + * Factory function to get the appropriate strategy for a constraint mode. + * @param {string} mode - The constraint mode (CONSTRAINT_MODE_END_DATE_ONLY or CONSTRAINT_MODE_NORMAL) + * @returns {Object} The strategy object + */ +export function createConstraintStrategy(mode) { + return mode === CONSTRAINT_MODE_END_DATE_ONLY + ? EndDateOnlyStrategy + : NormalStrategy; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js new file mode 100644 index 00000000000..53e886d1e6e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js @@ -0,0 +1,74 @@ +import { $__ } from "../../../../i18n/index.js"; +import { createValidationErrorHandler } from "../../../../utils/validationErrors.js"; + +/** + * Booking-specific validation error messages + * Each key maps to a function that returns a translated message + */ +export const bookingValidationMessages = { + biblionumber_required: () => $__("Biblionumber is required"), + patron_id_required: () => $__("Patron ID is required"), + booking_data_required: () => $__("Booking data is required"), + booking_id_required: () => $__("Booking ID is required"), + no_update_data: () => $__("No update data provided"), + data_required: () => $__("Data is required"), + missing_required_fields: params => + $__("Missing required fields: %s").format(params.fields), + + // HTTP failure messages + fetch_bookable_items_failed: params => + $__("Failed to fetch bookable items: %s %s").format( + params.status, + params.statusText + ), + fetch_bookings_failed: params => + $__("Failed to fetch bookings: %s %s").format( + params.status, + params.statusText + ), + fetch_checkouts_failed: params => + $__("Failed to fetch checkouts: %s %s").format( + params.status, + params.statusText + ), + fetch_patron_failed: params => + $__("Failed to fetch patron: %s %s").format( + params.status, + params.statusText + ), + fetch_patrons_failed: params => + $__("Failed to fetch patrons: %s %s").format( + params.status, + params.statusText + ), + fetch_pickup_locations_failed: params => + $__("Failed to fetch pickup locations: %s %s").format( + params.status, + params.statusText + ), + fetch_circulation_rules_failed: params => + $__("Failed to fetch circulation rules: %s %s").format( + params.status, + params.statusText + ), + fetch_holidays_failed: params => + $__("Failed to fetch holidays: %s %s").format( + params.status, + params.statusText + ), + create_booking_failed: params => + $__("Failed to create booking: %s %s").format( + params.status, + params.statusText + ), + update_booking_failed: params => + $__("Failed to update booking: %s %s").format( + params.status, + params.statusText + ), +}; + +// Create the booking validation handler +export const bookingValidation = createValidationErrorHandler( + bookingValidationMessages +); diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs new file mode 100644 index 00000000000..a354872356a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs @@ -0,0 +1,69 @@ +/** + * Pure functions for booking validation logic + * Extracted from BookingValidationService to eliminate store coupling + */ + +/** + * Validate if user can proceed to step 3 (period selection) + * @param {Object} validationData - All required data for validation + * @param {boolean} validationData.showPatronSelect - Whether patron selection is required + * @param {Object} validationData.bookingPatron - Selected booking patron + * @param {boolean} validationData.showItemDetailsSelects - Whether item details are required + * @param {boolean} validationData.showPickupLocationSelect - Whether pickup location is required + * @param {string} validationData.pickupLibraryId - Selected pickup library ID + * @param {string} validationData.bookingItemtypeId - Selected item type ID + * @param {Array} validationData.itemtypeOptions - Available item type options + * @param {string} validationData.bookingItemId - Selected item ID + * @param {Array} validationData.bookableItems - Available bookable items + * @returns {boolean} Whether the user can proceed to step 3 + */ +export function canProceedToStep3(validationData) { + const { + showPatronSelect, + bookingPatron, + showItemDetailsSelects, + showPickupLocationSelect, + pickupLibraryId, + bookingItemtypeId, + itemtypeOptions, + bookingItemId, + bookableItems, + } = validationData; + + if (showPatronSelect && !bookingPatron) { + return false; + } + + if (showItemDetailsSelects || showPickupLocationSelect) { + if (showPickupLocationSelect && !pickupLibraryId) { + return false; + } + if (showItemDetailsSelects) { + if (!bookingItemtypeId && itemtypeOptions.length > 0) { + return false; + } + if (!bookingItemId && bookableItems.length > 0) { + return false; + } + } + } + + if (!bookableItems || bookableItems.length === 0) { + return false; + } + + return true; +} + +/** + * Validate if form can be submitted + * @param {Object} validationData - Data required for step 3 validation + * @param {Array} dateRange - Selected date range + * @returns {boolean} Whether the form can be submitted + */ +export function canSubmitBooking(validationData, dateRange) { + if (!canProceedToStep3(validationData)) return false; + if (!Array.isArray(dateRange) || dateRange.length < 2) return false; + + return true; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/hover-feedback.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/hover-feedback.mjs new file mode 100644 index 00000000000..d5655ae9d90 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/hover-feedback.mjs @@ -0,0 +1,241 @@ +/** + * Contextual hover feedback messages for booking calendar dates. + * + * Generates user-facing messages explaining why a date is disabled + * or providing context about the current selection mode. + * Mirrors upstream's ~20 contextual messages adapted for the Vue architecture. + * + * @module hover-feedback + */ + +import { BookingDate, formatYMD } from "../booking/BookingDate.mjs"; +import { $__ } from "../../../../i18n/index.js"; + +/** + * Generate a contextual feedback message for a hovered calendar date. + * + * @param {Date} date - The date being hovered + * @param {Object} context + * @param {boolean} context.isDisabled - Whether the date is disabled in the calendar + * @param {string[]} context.selectedDateRange - Currently selected dates (ISO strings) + * @param {Object} context.circulationRules - First circulation rule object + * @param {Object} context.unavailableByDate - Unavailability map from store + * @param {string[]} [context.holidays] - Holiday date strings (YYYY-MM-DD) + * @returns {{ message: string, variant: "info"|"warning"|"danger" } | null} + */ +export function getDateFeedbackMessage(date, context) { + const { + isDisabled, + selectedDateRange, + circulationRules, + unavailableByDate, + holidays, + } = context; + + const today = BookingDate.today().toDayjs(); + const d = BookingDate.from(date).toDayjs(); + const dateKey = formatYMD(date); + + const leadDays = Number(circulationRules?.bookings_lead_period) || 0; + const trailDays = Number(circulationRules?.bookings_trail_period) || 0; + const maxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 0; + + const hasStart = selectedDateRange && selectedDateRange.length >= 1; + const isSelectingEnd = hasStart; + const isSelectingStart = !hasStart; + + if (isDisabled) { + const reason = getDisabledReason(d, dateKey, { + today, + leadDays, + trailDays, + maxPeriod, + isSelectingStart, + isSelectingEnd, + selectedDateRange, + unavailableByDate, + holidays, + }); + return { message: reason, variant: "danger" }; + } + + const info = getEnabledInfo({ + leadDays, + trailDays, + isSelectingStart, + isSelectingEnd, + unavailableByDate, + dateKey, + }); + return info ? { message: info, variant: "info" } : null; +} + +/** + * Determine the reason a date is disabled. + * Checks conditions in priority order matching upstream logic. + */ +function getDisabledReason(d, dateKey, ctx) { + // Past date + if (d.isBefore(ctx.today, "day")) { + return $__("Cannot select: date is in the past"); + } + + // Holiday + if (ctx.holidays && ctx.holidays.includes(dateKey)) { + return $__("Cannot select: library is closed on this date"); + } + + // Insufficient lead time from today + if (ctx.isSelectingStart && ctx.leadDays > 0) { + const minStart = ctx.today.add(ctx.leadDays, "day"); + if (d.isBefore(minStart, "day")) { + return $__( + "Cannot select: insufficient lead time (%s days required before start)" + ).format(ctx.leadDays); + } + } + + // Exceeds maximum booking period + if ( + ctx.isSelectingEnd && + ctx.maxPeriod > 0 && + ctx.selectedDateRange?.[0] + ) { + const start = BookingDate.from(ctx.selectedDateRange[0]).toDayjs(); + if (d.isAfter(start.add(ctx.maxPeriod, "day"), "day")) { + return $__( + "Cannot select: exceeds maximum booking period (%s days)" + ).format(ctx.maxPeriod); + } + } + + // Check markers in unavailableByDate for specific reasons + const markerReasons = collectMarkerReasons(ctx.unavailableByDate, dateKey); + + if (markerReasons.has("holiday")) { + return $__("Cannot select: library is closed on this date"); + } + if ( + markerReasons.has("booking") || + markerReasons.has("booked") || + markerReasons.has("core") + ) { + return $__( + "Cannot select: this date is part of an existing booking" + ); + } + if ( + markerReasons.has("checkout") || + markerReasons.has("checked-out") + ) { + return $__( + "Cannot select: this date is part of an existing checkout" + ); + } + if (markerReasons.has("lead")) { + return $__( + "Cannot select: this date is part of an existing booking's lead period" + ); + } + if (markerReasons.has("trail")) { + return $__( + "Cannot select: this date is part of an existing booking's trail period" + ); + } + + // Lead period of selected start would conflict + if (ctx.isSelectingStart && ctx.leadDays > 0) { + return $__( + "Cannot select: lead period (%s days before start) conflicts with an existing booking" + ).format(ctx.leadDays); + } + + // Trail period of selected end would conflict + if (ctx.isSelectingEnd && ctx.trailDays > 0) { + return $__( + "Cannot select: trail period (%s days after return) conflicts with an existing booking" + ).format(ctx.trailDays); + } + + return $__("Cannot select: conflicts with an existing booking"); +} + +/** + * Generate info message for an enabled (selectable) date. + */ +function getEnabledInfo(ctx) { + // Collect context appendages from markers + const appendages = []; + const markerReasons = collectMarkerReasons( + ctx.unavailableByDate, + ctx.dateKey + ); + if (markerReasons.has("lead")) { + appendages.push( + $__("hovering an existing booking's lead period") + ); + } + if (markerReasons.has("trail")) { + appendages.push( + $__("hovering an existing booking's trail period") + ); + } + + const suffix = + appendages.length > 0 ? " \u2022 " + appendages.join(", ") : ""; + + if (ctx.isSelectingStart) { + const extras = []; + if (ctx.leadDays > 0) { + extras.push( + $__("Lead period: %s days before start").format(ctx.leadDays) + ); + } + if (ctx.trailDays > 0) { + extras.push( + $__("Trail period: %s days after return").format( + ctx.trailDays + ) + ); + } + const detail = extras.length > 0 ? ". " + extras.join(". ") : ""; + return $__("Select a start date") + detail + suffix; + } + + if (ctx.isSelectingEnd) { + const detail = + ctx.trailDays > 0 + ? ". " + + $__("Trail period: %s days after return").format( + ctx.trailDays + ) + : ""; + return $__("Select an end date") + detail + suffix; + } + + return null; +} + +/** + * Collect all marker reason strings for a date from the unavailableByDate map. + * @param {Object} unavailableByDate + * @param {string} dateKey - YYYY-MM-DD + * @returns {Set} + */ +function collectMarkerReasons(unavailableByDate, dateKey) { + const reasons = new Set(); + const entry = unavailableByDate?.[dateKey]; + if (!entry) return reasons; + + Object.values(entry).forEach(itemReasons => { + if (itemReasons instanceof Set) { + itemReasons.forEach(r => reasons.add(r)); + } else if (Array.isArray(itemReasons)) { + itemReasons.forEach(r => reasons.add(r)); + } + }); + return reasons; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs new file mode 100644 index 00000000000..c50096bdf07 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs @@ -0,0 +1,22 @@ +/** + * Marker label utilities for booking calendar display + * @module marker-labels + */ + +import { $__ } from "../../../../i18n/index.js"; + +/** + * Get the translated display label for a marker type + * @param {string} type - The marker type identifier (e.g., "booked", "checked-out", "lead", "trail", "holiday") + * @returns {string} The translated label or the original type if no translation exists + */ +export function getMarkerTypeLabel(type) { + const labels = { + booked: $__("Booked"), + "checked-out": $__("Checked out"), + lead: $__("Lead period"), + trail: $__("Trail period"), + holiday: $__("Library closed"), + }; + return labels[type] || type; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs new file mode 100644 index 00000000000..924d0cc85d9 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs @@ -0,0 +1,47 @@ +/** + * User-facing message builders for booking selection feedback + * @module selection-message + */ + +import { idsEqual } from "../booking/id-utils.mjs"; +import { $__ } from "../../../../i18n/index.js"; + +/** + * Build a localized message explaining why no items are available for booking + * @param {Array<{library_id: string, name: string}>} pickupLocations - Available pickup locations + * @param {Array<{item_type_id: string, description: string}>} itemTypes - Available item types + * @param {string|null} pickupLibraryId - Currently selected pickup location ID + * @param {string|null} itemtypeId - Currently selected item type ID + * @returns {string} Translated message describing the selection criteria + */ +export function buildNoItemsAvailableMessage( + pickupLocations, + itemTypes, + pickupLibraryId, + itemtypeId +) { + const selectionParts = []; + if (pickupLibraryId) { + const location = (pickupLocations || []).find(l => + idsEqual(l.library_id, pickupLibraryId) + ); + selectionParts.push( + $__("pickup location: %s").format( + (location && location.name) || pickupLibraryId + ) + ); + } + if (itemtypeId) { + const itemType = (itemTypes || []).find(t => + idsEqual(t.item_type_id, itemtypeId) + ); + selectionParts.push( + $__("item type: %s").format( + (itemType && itemType.description) || itemtypeId + ) + ); + } + return $__( + "No items are available for booking with the selected criteria (%s). Please adjust your selection." + ).format(selectionParts.join(", ")); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs new file mode 100644 index 00000000000..e9117650976 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs @@ -0,0 +1,45 @@ +/** + * Pure functions for booking step calculation and management + * Extracted from BookingStepService to provide pure, testable functions + */ + +/** + * Calculate step numbers based on configuration + * @param {boolean} showPatronSelect - Whether patron selection step is shown + * @param {boolean} showItemDetailsSelects - Whether item details step is shown + * @param {boolean} showPickupLocationSelect - Whether pickup location step is shown + * @param {boolean} showAdditionalFields - Whether additional fields step is shown + * @param {boolean} hasAdditionalFields - Whether additional fields exist + * @returns {Object} Step numbers for each section + */ +export function calculateStepNumbers( + showPatronSelect, + showItemDetailsSelects, + showPickupLocationSelect, + showAdditionalFields, + hasAdditionalFields +) { + let currentStep = 1; + const steps = { + patron: 0, + details: 0, + period: 0, + additionalFields: 0, + }; + + if (showPatronSelect) { + steps.patron = currentStep++; + } + + if (showItemDetailsSelects || showPickupLocationSelect) { + steps.details = currentStep++; + } + + steps.period = currentStep++; + + if (showAdditionalFields && hasAdditionalFields) { + steps.additionalFields = currentStep++; + } + + return steps; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json new file mode 100644 index 00000000000..cd260a575a4 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2020", + "moduleResolution": "Node", + "checkJs": true, + "skipLibCheck": true, + "allowJs": true, + "noEmit": true, + "strict": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "baseUrl": ".", + "paths": { + "@bookingApi": [ + "./lib/adapters/api/staff-interface.js", + "./lib/adapters/api/opac.js" + ] + }, + "types": ["node"], + "lib": ["ES2021", "DOM", "DOM.Iterable"] + }, + "include": [ + "./**/*.js", + "./**/*.mjs", + "./**/*.ts", + "./**/*.vue", + "./**/*.d.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts new file mode 100644 index 00000000000..1e63dc70e63 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts @@ -0,0 +1,291 @@ +/** + * Physical item that can be booked (minimum shape used across the UI). + */ +export type BookableItem = { + /** Internal item identifier */ + item_id: Id; + /** Koha item type code */ + item_type_id: string; + /** Effective type after MARC policies (when present) */ + effective_item_type_id?: string; + /** Owning or home library id */ + home_library_id: string; + /** Optional descriptive fields used in UI/logs */ + title?: string; + barcode?: string; + external_id?: string; + holding_library?: string; + available_pickup_locations?: any; + /** Localized strings container (when available) */ + _strings?: { item_type_id?: { str?: string } }; +}; + +/** + * Booking record (core fields only, as used by the UI). + */ +export type Booking = { + booking_id: number; + item_id: Id; + start_date: ISODateString; + end_date: ISODateString; + status?: string; + patron_id?: number; +}; + +/** + * Active checkout record for an item relevant to bookings. + */ +export type Checkout = { + item_id: Id; + due_date: ISODateString; +}; + +/** + * Library that can serve as pickup location with optional item whitelist. + */ +export type PickupLocation = { + library_id: string; + name: string; + /** Allowed item ids for pickup at this location (when restricted) */ + pickup_items?: Array; +}; + +/** + * Subset of circulation rules used by bookings logic (from backend API). + */ +export type CirculationRule = { + /** Max booking length in days (effective, UI-enforced) */ + maxPeriod?: number; + /** Base issue length in days (backend rule) */ + issuelength?: number; + /** Renewal policy: length per renewal (days) */ + renewalperiod?: number; + /** Renewal policy: number of renewals allowed */ + renewalsallowed?: number; + /** Lead/trail periods around bookings (days) */ + leadTime?: number; + leadTimeToday?: boolean; + /** Optional calculated due date from backend (ISO) */ + calculated_due_date?: ISODateString; + /** Optional calculated period in days (from backend) */ + calculated_period_days?: number; + /** Constraint mode selection */ + booking_constraint_mode?: "range" | "end_date_only"; +}; + +/** Visual marker type used in calendar tooltip and markers grid. */ +export type MarkerType = "booked" | "checked-out" | "lead" | "trail"; + +/** + * Visual marker entry for a specific date/item. + */ +export type Marker = { + type: MarkerType; + barcode?: string; + external_id?: string; + itemnumber?: Id; +}; + +/** + * Marker used by calendar code (tooltips + aggregation). + * Contains display label (itemName) and resolved barcode (or external id). + */ +export type CalendarMarker = { + type: MarkerType; + item: string; + itemName: string; + barcode: string | null; +}; + +/** Minimal item type shape used in constraints */ +export type ItemType = { + item_type_id: string; + name?: string; +}; + +/** + * Result of availability calculation: Flatpickr disable function + daily map. + */ +export type AvailabilityResult = { + disable: DisableFn; + unavailableByDate: UnavailableByDate; +}; + +/** + * Canonical map of daily unavailability across items. + * + * Keys: + * - Outer key: date in YYYY-MM-DD (calendar day) + * - Inner key: item id as string + * - Value: set of reasons for unavailability on that day + */ +export type UnavailableByDate = Record>>; + +/** Enumerates reasons an item is not bookable on a specific date. */ +export type UnavailabilityReason = "booking" | "checkout" | "lead" | "trail" | string; + +/** Disable function for Flatpickr */ +export type DisableFn = (date: Date) => boolean; + +/** Options affecting constraint calculations (UI + rules composition). */ +export type ConstraintOptions = { + dateRangeConstraint?: string; + maxBookingPeriod?: number; + /** Start of the currently visible calendar range (on-demand marker build) */ + visibleStartDate?: Date; + /** End of the currently visible calendar range (on-demand marker build) */ + visibleEndDate?: Date; + /** Holiday dates (YYYY-MM-DD format) for constraint highlighting */ + holidays?: string[]; + /** On-demand loading flag */ + onDemand?: boolean; +}; + +/** Resulting highlighting metadata for calendar UI. */ +export type ConstraintHighlighting = { + startDate: Date; + targetEndDate: Date; + blockedIntermediateDates: Date[]; + constraintMode: string; + maxPeriod: number; + /** Holiday dates (YYYY-MM-DD format) for visual highlighting */ + holidays?: string[]; +}; + +/** Minimal shape of the Pinia booking store used by the UI. */ +export type BookingStoreLike = { + selectedDateRange?: string[]; + circulationRules?: CirculationRule[]; + bookings?: Booking[]; + checkouts?: Checkout[]; + bookableItems?: BookableItem[]; + bookingItemId?: Id | null; + bookingId?: Id | null; + unavailableByDate?: UnavailableByDate; + /** Holiday dates (YYYY-MM-DD format) */ + holidays?: string[]; +}; + +/** Store actions used by composables to interact with backend. */ +export type BookingStoreActions = { + fetchPickupLocations: ( + biblionumber: Id, + patronId: Id + ) => Promise; + invalidateCalculatedDue: () => void; + fetchCirculationRules: ( + params: Record + ) => Promise; + /** Fetch holidays for a library within a date range */ + fetchHolidays?: ( + libraryId: string, + startDate: string, + endDate: string + ) => Promise; +}; + +/** Dependencies used for updating external widgets after booking changes. */ +export type ExternalDependencies = { + timeline: () => any; + bookingsTable: () => any; + patronRenderer: () => any; + domQuery: (selector: string) => NodeListOf; + logger: { + warn: (msg: any, data?: any) => void; + error: (msg: any, err?: any) => void; + debug?: (msg: any, data?: any) => void; + }; +}; + +/** Generic Ref-like helper for accepting either Vue Ref or plain `{ value }`. */ +export type RefLike = import('vue').Ref | { value: T }; + +/** Minimal patron shape used by composables. */ +export type PatronLike = { + patron_id?: number | string; + category_id?: string | number; + library_id?: string; + cardnumber?: string; +}; + +/** Patron data from API with display label added by transformPatronData. */ +export type PatronOption = PatronLike & { + surname?: string; + firstname?: string; + /** Display label formatted as "surname firstname (cardnumber)" */ + label: string; + library?: { + library_id: string; + name: string; + }; +}; + +/** Options for calendar `createOnChange` handler. */ +export type OnChangeOptions = { + setError?: (msg: string) => void; + tooltipVisibleRef?: { value: boolean }; + /** Ref for constraint options to avoid stale closures */ + constraintOptionsRef?: RefLike | null; +}; + +/** Minimal parameter set for circulation rules fetching. */ +export type RulesParams = { + patron_category_id?: string | number; + item_type_id?: Id; + library_id?: string; + start_date?: string; +}; + +/** Flatpickr instance augmented with a cache for constraint highlighting. */ +export type FlatpickrInstanceWithHighlighting = { + _constraintHighlighting?: ConstraintHighlighting | null; + _loanBoundaryTimes?: Set; + [key: string]: any; +}; + +/** Convenience alias for stores passed to fetchers. */ +export type StoreWithActions = BookingStoreLike & BookingStoreActions; + +/** Common result shape for `constrain*` helpers. */ +export type ConstraintResult = { + filtered: T[]; + filteredOutCount: number; + total: number; + constraintApplied: boolean; +}; + +/** Navigation target calculation for calendar month navigation. */ +export type CalendarNavigationTarget = { + shouldNavigate: boolean; + targetMonth?: number; + targetYear?: number; + targetDate?: Date; +}; + +/** Aggregated counts by marker type for the markers grid. */ +export type MarkerAggregation = Record; + +/** + * Current calendar view boundaries (visible date range) for navigation logic. + */ +export type CalendarCurrentView = { + visibleStartDate?: Date; + visibleEndDate?: Date; +}; + +/** + * Common identifier type used across UI (string or number). + */ +export type Id = string | number; + +/** ISO-8601 date string (YYYY-MM-DD or full ISO as returned by backend). */ +export type ISODateString = string; + +/** Minimal item type shape used in constraints and selection UI. */ +export type ItemType = { + item_type_id: string; + /** Display description (used by v-select label) */ + description?: string; + /** Alternate name field (for backwards compatibility) */ + name?: string; +}; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/dayjs-plugins.d.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/dayjs-plugins.d.ts new file mode 100644 index 00000000000..ee5333a591d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/dayjs-plugins.d.ts @@ -0,0 +1,13 @@ +import "dayjs"; +declare module "dayjs" { + interface Dayjs { + isSameOrBefore( + date?: import("dayjs").ConfigType, + unit?: import("dayjs").OpUnitType + ): boolean; + isSameOrAfter( + date?: import("dayjs").ConfigType, + unit?: import("dayjs").OpUnitType + ): boolean; + } +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/vue-shims.d.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/vue-shims.d.ts new file mode 100644 index 00000000000..f566678d1b5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/vue-shims.d.ts @@ -0,0 +1,49 @@ +/** + * Vue component type declarations for template type checking. + */ + +import type { ComponentCustomProperties } from "vue"; + +/** + * Augment Vue's component custom properties to include $__ for i18n. + * This allows vue-tsc to recognize $__ in templates. + */ +declare module "vue" { + interface ComponentCustomProperties { + /** + * i18n translation function - translates the given string. + * @param str - The string to translate + * @returns The translated string (with .format() method for placeholders) + */ + $__: ( + str: string + ) => string & { format: (...args: unknown[]) => string }; + } +} + +/** + * Global $__ function available via import from i18n module. + */ +declare global { + /** + * Koha i18n translation function. + */ + function $__( + str: string + ): string & { format: (...args: unknown[]) => string }; + + /** + * String prototype extension for i18n formatting. + * Koha extends String.prototype with a format method for placeholder substitution. + */ + interface String { + /** + * Format string with placeholder substitution. + * @param args - Values to substitute for placeholders + * @returns Formatted string + */ + format(...args: unknown[]): string; + } +} + +export {}; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue new file mode 100644 index 00000000000..3984949e6f5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue @@ -0,0 +1,39 @@ + + + diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts index b9b15766ab2..2bda6f5e14a 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts @@ -4,6 +4,7 @@ import { $__ } from "../i18n"; import { useMainStore } from "../stores/main"; import { useNavigationStore } from "../stores/navigation"; import { useVendorStore } from "../stores/vendors"; +import { useBookingStore } from "../stores/bookings"; /** * Represents a web component with an import function and optional configuration. @@ -42,6 +43,21 @@ type WebComponentDynamicImport = { */ export const componentRegistry: Map = new Map([ + [ + "booking-modal-island", + { + importFn: async () => { + const module = await import( + /* webpackChunkName: "booking-modal-island" */ + "../components/Bookings/BookingModal.vue" + ); + return module.default; + }, + config: { + stores: ["bookings"], + }, + }, + ], [ "acquisitions-menu", { @@ -85,6 +101,7 @@ export function hydrate(): void { mainStore: useMainStore(pinia), navigationStore: useNavigationStore(pinia), vendorStore: useVendorStore(pinia), + bookings: useBookingStore(pinia), }; const islandTagNames = Array.from(componentRegistry.keys()).join(", "); diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js new file mode 100644 index 00000000000..7613ce2697c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js @@ -0,0 +1,496 @@ +// bookings.js +// Pinia store for booking modal state management + +import { defineStore } from "pinia"; +import { processApiError } from "../utils/apiErrors.js"; +import * as bookingApi from "@bookingApi"; +import { + transformPatronData, + transformPatronsData, +} from "../components/Bookings/lib/adapters/patron.mjs"; +import { + formatYMD, + addMonths, + addDays, +} from "../components/Bookings/lib/booking/BookingDate.mjs"; +import { + HOLIDAY_PREFETCH_THRESHOLD_DAYS, + HOLIDAY_PREFETCH_MONTHS, +} from "../components/Bookings/lib/booking/constants.mjs"; + +/** + * Higher-order function to standardize async operation error handling + * Eliminates repetitive try-catch-finally patterns + */ +function withErrorHandling(operation, loadingKey, errorKey = null) { + return async function (...args) { + // Use errorKey if provided, otherwise derive from loadingKey + const errorField = errorKey || loadingKey; + + this.loading[loadingKey] = true; + this.error[errorField] = null; + + try { + const result = await operation.call(this, ...args); + return result; + } catch (error) { + this.error[errorField] = processApiError(error); + // Re-throw to allow caller to handle if needed + throw error; + } finally { + this.loading[loadingKey] = false; + } + }; +} + +/** + * State shape with improved organization and consistency + * Maintains backward compatibility with existing API + */ + +export const useBookingStore = defineStore("bookings", { + state: () => ({ + // System state + dataFetched: false, + + // Collections - consistent naming and organization + bookableItems: [], + bookings: [], + checkouts: [], + pickupLocations: [], + itemTypes: [], + circulationRules: [], + circulationRulesContext: null, // Track the context used for the last rules fetch + unavailableByDate: {}, + holidays: [], // Closed days for the selected pickup library + /** @type {{ from: string|null, to: string|null, libraryId: string|null }} */ + holidaysFetchedRange: { from: null, to: null, libraryId: null }, // Track fetched range to enable on-demand extension + + // Current booking state - normalized property names + bookingId: null, + bookingItemId: null, // kept for backward compatibility + bookingPatron: null, + bookingItemtypeId: null, // kept for backward compatibility + patronId: null, + pickupLibraryId: null, + /** + * Canonical date representation for the bookings UI. + * Always store ISO 8601 strings here (e.g., "2025-03-14T00:00:00.000Z"). + * - Widgets (Flatpickr) work with Date objects and must convert to ISO when writing + * - Computation utilities convert ISO -> Date close to the boundary + * - API payloads use ISO strings as-is + */ + selectedDateRange: [], + + // Async operation state - organized structure + loading: { + bookableItems: false, + bookings: false, + checkouts: false, + patrons: false, + bookingPatron: false, + pickupLocations: false, + circulationRules: false, + holidays: false, + submit: false, + }, + error: { + bookableItems: null, + bookings: null, + checkouts: null, + patrons: null, + bookingPatron: null, + pickupLocations: null, + circulationRules: null, + holidays: null, + submit: null, + }, + + // UI-level error state (validation messages, user feedback) + uiError: { + message: "", + code: null, + }, + }), + + getters: { + /** + * Returns true if any data fetching operation is in progress + */ + isAnyLoading: state => { + return Object.values(state.loading).some(Boolean); + }, + /** + * Returns true if core booking data is loaded (bookableItems, bookings, checkouts) + */ + isCoreDataReady: state => { + return ( + !state.loading.bookableItems && + !state.loading.bookings && + !state.loading.checkouts && + state.bookableItems.length > 0 + ); + }, + /** + * Returns true if all required data for the modal is loaded + */ + isDataReady: state => { + return ( + !state.loading.bookableItems && + !state.loading.bookings && + !state.loading.checkouts && + !state.loading.pickupLocations && + state.dataFetched + ); + }, + /** + * Returns list of currently loading operations + */ + loadingOperations: state => { + return Object.entries(state.loading) + .filter(([, isLoading]) => isLoading) + .map(([key]) => key); + }, + /** + * Returns true if there are any errors + */ + hasErrors: state => { + return Object.values(state.error).some(Boolean); + }, + /** + * Returns all current errors as an array + */ + allErrors: state => { + return Object.entries(state.error) + .filter(([, error]) => error) + .map(([key, error]) => ({ source: key, error })); + }, + /** + * Returns first circulation rule or empty object + */ + effectiveCirculationRules: state => { + return state.circulationRules?.[0] || {}; + }, + /** + * Returns true if there is a UI error message + */ + hasUiError: state => { + return !!state.uiError.message; + }, + }, + + actions: { + /** + * Invalidate backend-calculated due values to avoid stale UI when inputs change. + * Keeps the rules object shape but removes calculated fields so consumers + * fall back to maxPeriod-based logic until fresh rules arrive. + */ + invalidateCalculatedDue() { + if ( + Array.isArray(this.circulationRules) && + this.circulationRules.length > 0 + ) { + const first = { ...this.circulationRules[0] }; + if ("calculated_due_date" in first) + delete first.calculated_due_date; + if ("calculated_period_days" in first) + delete first.calculated_period_days; + this.circulationRules = [first]; + } + }, + resetErrors() { + Object.keys(this.error).forEach(key => { + this.error[key] = null; + }); + }, + /** + * Set UI-level error message + * @param {string} message - Error message to display + * @param {string} code - Error code for categorization (e.g., 'api', 'validation', 'no_items') + */ + setUiError(message, code = "general") { + this.uiError = { + message: message || "", + code: message ? code : null, + }; + }, + /** + * Clear UI-level error + */ + clearUiError() { + this.uiError = { message: "", code: null }; + }, + /** + * Clear all errors (both API errors and UI errors) + */ + clearAllErrors() { + this.resetErrors(); + this.clearUiError(); + }, + setUnavailableByDate(unavailableByDate) { + this.unavailableByDate = unavailableByDate; + }, + /** + * Fetch bookable items for a biblionumber + */ + fetchBookableItems: withErrorHandling(async function (biblionumber) { + const data = await bookingApi.fetchBookableItems(biblionumber); + this.bookableItems = data; + return data; + }, "bookableItems"), + /** + * Fetch bookings for a biblionumber + */ + fetchBookings: withErrorHandling(async function (biblionumber) { + const data = await bookingApi.fetchBookings(biblionumber); + this.bookings = data; + return data; + }, "bookings"), + /** + * Fetch checkouts for a biblionumber + */ + fetchCheckouts: withErrorHandling(async function (biblionumber) { + const data = await bookingApi.fetchCheckouts(biblionumber); + this.checkouts = data; + return data; + }, "checkouts"), + /** + * Fetch patrons by search term and page + */ + fetchPatron: withErrorHandling(async function (patronId) { + const data = await bookingApi.fetchPatron(patronId); + return transformPatronData(Array.isArray(data) ? data[0] : data); + }, "bookingPatron"), + /** + * Fetch patrons by search term and page + */ + fetchPatrons: withErrorHandling(async function (term, page = 1) { + const data = await bookingApi.fetchPatrons(term, page); + return transformPatronsData(data); + }, "patrons"), + /** + * Fetch pickup locations for a biblionumber (optionally filtered by patron) + */ + fetchPickupLocations: withErrorHandling(async function ( + biblionumber, + patron_id + ) { + const data = await bookingApi.fetchPickupLocations( + biblionumber, + patron_id + ); + this.pickupLocations = data; + return data; + }, "pickupLocations"), + /** + * Fetch circulation rules for given context + */ + fetchCirculationRules: withErrorHandling(async function (params) { + // Only include defined (non-null, non-undefined) params + const filteredParams = {}; + for (const key in params) { + if ( + params[key] !== null && + params[key] !== undefined && + params[key] !== "" + ) { + filteredParams[key] = params[key]; + } + } + const data = await bookingApi.fetchCirculationRules(filteredParams); + this.circulationRules = data; + // Store the context we requested so we know what specificity we have + this.circulationRulesContext = { + patron_category_id: filteredParams.patron_category_id ?? null, + item_type_id: filteredParams.item_type_id ?? null, + library_id: filteredParams.library_id ?? null, + }; + return data; + }, "circulationRules"), + /** + * Fetch holidays (closed days) for a library. + * Tracks fetched range and accumulates holidays to support on-demand extension. + * @param {string} libraryId - The library branchcode + * @param {string} [from] - Start date (ISO format), defaults to today + * @param {string} [to] - End date (ISO format), defaults to 1 year from start + */ + fetchHolidays: withErrorHandling(async function (libraryId, from, to) { + if (!libraryId) { + this.holidays = []; + this.holidaysFetchedRange = { + from: null, + to: null, + libraryId: null, + }; + return []; + } + + // If library changed, reset and fetch fresh + const fetchedRange = this.holidaysFetchedRange || { + from: null, + to: null, + libraryId: null, + }; + if (fetchedRange.libraryId !== libraryId) { + this.holidays = []; + this.holidaysFetchedRange = { + from: null, + to: null, + libraryId: null, + }; + } + + const data = await bookingApi.fetchHolidays(libraryId, from, to); + + // Accumulate holidays using Set to avoid duplicates + const existingSet = new Set(this.holidays); + data.forEach(date => existingSet.add(date)); + this.holidays = Array.from(existingSet).sort(); + + // Update fetched range (expand to cover new range) + const currentFrom = this.holidaysFetchedRange.from; + const currentTo = this.holidaysFetchedRange.to; + this.holidaysFetchedRange = { + libraryId, + from: !currentFrom || from < currentFrom ? from : currentFrom, + to: !currentTo || to > currentTo ? to : currentTo, + }; + + return data; + }, "holidays"), + /** + * Extend holidays range if the visible calendar range exceeds fetched data. + * Also prefetches upcoming months when approaching the edge of fetched data. + * @param {string} libraryId - The library branchcode + * @param {Date} visibleStart - Start of visible calendar range + * @param {Date} visibleEnd - End of visible calendar range + */ + async extendHolidaysIfNeeded(libraryId, visibleStart, visibleEnd) { + if (!libraryId) return; + + const visibleFrom = formatYMD(visibleStart); + const visibleTo = formatYMD(visibleEnd); + + const { + from: fetchedFrom, + to: fetchedTo, + libraryId: fetchedLib, + } = this.holidaysFetchedRange; + + // If different library or no data yet, fetch visible range + prefetch buffer + if (fetchedLib !== libraryId || !fetchedFrom || !fetchedTo) { + const prefetchEnd = formatYMD(addMonths(visibleEnd, 6)); + await this.fetchHolidays(libraryId, visibleFrom, prefetchEnd); + return; + } + + // Check if we need to extend for current view (YYYY-MM-DD strings are lexicographically sortable) + const needsExtensionBefore = visibleFrom < fetchedFrom; + const needsExtensionAfter = visibleTo > fetchedTo; + + if (needsExtensionBefore) { + const prefetchStart = formatYMD(addMonths(visibleStart, -3)); + // End at day before fetchedFrom to avoid overlap + const extensionEnd = formatYMD(addDays(fetchedFrom, -1)); + await this.fetchHolidays( + libraryId, + prefetchStart, + extensionEnd + ); + } + if (needsExtensionAfter) { + // Start at day after fetchedTo to avoid overlap + const extensionStart = formatYMD(addDays(fetchedTo, 1)); + const prefetchEnd = formatYMD(addMonths(visibleEnd, 6)); + await this.fetchHolidays( + libraryId, + extensionStart, + prefetchEnd + ); + } + + // Prefetch ahead if approaching the edge + if (!needsExtensionAfter && fetchedTo) { + const daysToEdge = addDays(fetchedTo, 0).diff( + visibleEnd, + "day" + ); + if (daysToEdge < HOLIDAY_PREFETCH_THRESHOLD_DAYS) { + // Start at day after fetchedTo to avoid overlap + const extensionStart = formatYMD(addDays(fetchedTo, 1)); + const prefetchEnd = formatYMD( + addMonths(fetchedTo, HOLIDAY_PREFETCH_MONTHS) + ); + // Fire and forget - don't await to avoid blocking, but catch errors + this.fetchHolidays( + libraryId, + extensionStart, + prefetchEnd + ).catch(() => {}); + } + } + + if (!needsExtensionBefore && fetchedFrom) { + const daysToEdge = addDays(visibleStart, 0).diff( + fetchedFrom, + "day" + ); + if (daysToEdge < HOLIDAY_PREFETCH_THRESHOLD_DAYS) { + const prefetchStart = formatYMD( + addMonths(fetchedFrom, -HOLIDAY_PREFETCH_MONTHS) + ); + // End at day before fetchedFrom to avoid overlap + const extensionEnd = formatYMD(addDays(fetchedFrom, -1)); + // Fire and forget - don't await to avoid blocking, but catch errors + this.fetchHolidays( + libraryId, + prefetchStart, + extensionEnd + ).catch(() => {}); + } + } + }, + /** + * Derive item types from bookableItems + */ + deriveItemTypesFromBookableItems() { + const typesMap = {}; + this.bookableItems.forEach(item => { + // Use effective_item_type_id if present, fallback to item_type_id + const typeId = item.effective_item_type_id || item.item_type_id; + if (typeId) { + // Use the human-readable string if available + const label = item._strings?.item_type_id?.str ?? typeId; + typesMap[typeId] = label; + } + }); + this.itemTypes = Object.entries(typesMap).map( + ([item_type_id, description]) => ({ + item_type_id, + description, + }) + ); + }, + /** + * Save (POST) or update (PUT) a booking + * If bookingId is present, update; else, create + */ + saveOrUpdateBooking: withErrorHandling(async function (bookingData) { + let result; + if (bookingData.bookingId || bookingData.booking_id) { + // Use bookingId from either field + const id = bookingData.bookingId || bookingData.booking_id; + result = await bookingApi.updateBooking(id, bookingData); + // Update in store + const idx = this.bookings.findIndex( + b => b.booking_id === result.booking_id + ); + if (idx !== -1) this.bookings[idx] = result; + } else { + result = await bookingApi.createBooking(bookingData); + this.bookings.push(result); + } + return result; + }, "submit"), + }, +}); diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/apiErrors.js b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/apiErrors.js new file mode 100644 index 00000000000..4b8a9bc8b1c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/apiErrors.js @@ -0,0 +1,138 @@ +import { $__ } from "../i18n/index.js"; + +/** + * Map API error messages to translated versions + * + * This utility translates common Mojolicious::Plugin::OpenAPI and JSON::Validator + * error messages into user-friendly, localized strings. + * + * @param {string} errorMessage - The raw API error message + * @returns {string} - Translated error message + */ +export function translateApiError(errorMessage) { + if (!errorMessage || typeof errorMessage !== "string") { + return $__("An error occurred."); + } + + // Common OpenAPI/JSON::Validator error patterns + const errorMappings = [ + // Missing required fields + { + pattern: /Missing property/i, + translation: $__("Required field is missing."), + }, + { + pattern: /Expected (\w+) - got (\w+)/i, + translation: $__("Invalid data type provided."), + }, + { + pattern: /String is too (long|short)/i, + translation: $__("Text length is invalid."), + }, + { + pattern: /Not in enum list/i, + translation: $__("Invalid value selected."), + }, + { + pattern: /Failed to parse JSON/i, + translation: $__("Invalid data format."), + }, + { + pattern: /Schema validation failed/i, + translation: $__("Data validation failed."), + }, + { + pattern: /Bad Request/i, + translation: $__("Invalid request."), + }, + // Generic fallbacks + { + pattern: /Something went wrong/i, + translation: $__("An unexpected error occurred."), + }, + { + pattern: /Internal Server Error/i, + translation: $__("A server error occurred."), + }, + { + pattern: /Not Found/i, + translation: $__("The requested resource was not found."), + }, + { + pattern: /Unauthorized/i, + translation: $__("You are not authorized to perform this action."), + }, + { + pattern: /Forbidden/i, + translation: $__("Access to this resource is forbidden."), + }, + { + pattern: /Object not found/i, + translation: $__("The requested item was not found."), + }, + ]; + + // Try to match error patterns + for (const mapping of errorMappings) { + if (mapping.pattern.test(errorMessage)) { + return mapping.translation; + } + } + + // If no pattern matches, return a generic translated error + return $__("An error occurred: %s").format(errorMessage); +} + +/** + * Extract error message from various error response formats + * @param {Error|Object|string} error - API error response + * @returns {string} - Raw error message + */ +function extractErrorMessage(error) { + const extractors = [ + // Direct string + err => (typeof err === "string" ? err : null), + + // OpenAPI validation errors format: { errors: [{ message: "...", path: "..." }] } + err => { + const errors = err?.response?.data?.errors; + if (Array.isArray(errors) && errors.length > 0) { + return errors.map(e => e.message || e).join(", "); + } + return null; + }, + + // Standard API error response with 'error' field + err => err?.response?.data?.error, + + // Standard API error response with 'message' field + err => err?.response?.data?.message, + + // Error object message + err => err?.message, + + // HTTP status text + err => err?.statusText, + + // Default fallback + () => "Unknown error", + ]; + + for (const extractor of extractors) { + const message = extractor(error); + if (message) return message; + } + + return "Unknown error"; // This should never be reached due to the fallback extractor +} + +/** + * Process API error response and extract user-friendly message + * + * @param {Error|Object|string} error - API error response + * @returns {string} - Translated error message + */ +export function processApiError(error) { + const errorMessage = extractErrorMessage(error); + return translateApiError(errorMessage); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/dayjs.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/dayjs.mjs new file mode 100644 index 00000000000..5d0c6f3d387 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/dayjs.mjs @@ -0,0 +1,28 @@ +// Adapter for dayjs to use the globally loaded instance from js-date-format.inc +// This prevents duplicate bundling and maintains TypeScript support + +/** @typedef {typeof import('dayjs')} DayjsModule */ +/** @typedef {import('dayjs').PluginFunc} DayjsPlugin */ + +if (!window["dayjs"]) { + throw new Error("dayjs is not available globally. Please ensure js-date-format.inc is included before this module."); +} + +/** @type {DayjsModule} */ +const dayjs = /** @type {DayjsModule} */ (window["dayjs"]); + +// Required plugins for booking functionality +const requiredPlugins = [ + { name: 'isSameOrBefore', global: 'dayjs_plugin_isSameOrBefore' }, + { name: 'isSameOrAfter', global: 'dayjs_plugin_isSameOrAfter' } +]; + +// Verify and extend required plugins +for (const plugin of requiredPlugins) { + if (!(plugin.global in window)) { + throw new Error(`Required dayjs plugin '${plugin.name}' is not available. Please ensure js-date-format.inc loads the ${plugin.name} plugin.`); + } + dayjs.extend(/** @type {DayjsPlugin} */ (window[plugin.global])); +} + +export default dayjs; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs new file mode 100644 index 00000000000..9f0cb3fea5e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/functions.mjs @@ -0,0 +1,40 @@ +/** + * Generic utility functions for Vue components + */ + +/** + * Creates a debounced version of a function that delays invocation + * until after `delay` milliseconds have elapsed since the last call. + * + * @template {(...args: any[]) => any} T + * @param {T} fn - The function to debounce + * @param {number} delay - Delay in milliseconds + * @returns {(...args: Parameters) => void} + */ +export function debounce(fn, delay) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => fn.apply(this, args), delay); + }; +} + +/** + * Creates a throttled version of a function that only invokes + * at most once per `limit` milliseconds. + * + * @template {(...args: any[]) => any} T + * @param {T} fn - The function to throttle + * @param {number} limit - Minimum time between invocations in milliseconds + * @returns {(...args: Parameters) => void} + */ +export function throttle(fn, limit) { + let inThrottle; + return function (...args) { + if (!inThrottle) { + fn.apply(this, args); + inThrottle = true; + setTimeout(() => (inThrottle = false), limit); + } + }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/validationErrors.js b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/validationErrors.js new file mode 100644 index 00000000000..7a7afe885e3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/utils/validationErrors.js @@ -0,0 +1,69 @@ +import { $__ } from "../i18n/index.js"; + +/** + * Generic validation error factory + * + * Creates a validation error handler with injected message mappings + * @param {Object} messageMappings - Object mapping error keys to translation functions + * @returns {Object} - Object with validation error methods + */ +export function createValidationErrorHandler(messageMappings) { + /** + * Create a validation error with translated message + * @param {string} errorKey - The error key to look up + * @param {Object} params - Optional parameters for string formatting + * @returns {Error} - Error object with translated message + */ + function validationError(errorKey, params = {}) { + const messageFunc = messageMappings[errorKey]; + + if (!messageFunc) { + // Fallback for unknown error keys + return new Error($__("Validation error: %s").format(errorKey)); + } + + // Call the message function with params to get translated message + const message = messageFunc(params); + /** @type {Error & { status?: number }} */ + const error = Object.assign(new Error(message), {}); + + // If status is provided in params, set it on the error object + if (params.status !== undefined) { + error.status = params.status; + } + + return error; + } + + /** + * Validate required fields + * @param {Object} data - Data object to validate + * @param {Array} requiredFields - List of required field names + * @param {string} errorKey - Error key to use if validation fails + * @returns {Error|null} - Error if validation fails, null if passes + */ + function validateRequiredFields( + data, + requiredFields, + errorKey = "missing_required_fields" + ) { + if (!data) { + return validationError("data_required"); + } + + const missingFields = requiredFields.filter(field => !data[field]); + + if (missingFields.length > 0) { + return validationError(errorKey, { + fields: missingFields.join(", "), + }); + } + + return null; + } + + return { + validationError, + validateRequiredFields, + }; +} diff --git a/rspack.config.js b/rspack.config.js index 62cf6b1956c..617e7e4fac9 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -11,6 +11,10 @@ module.exports = [ __dirname, "koha-tmpl/intranet-tmpl/prog/js/fetch" ), + "@bookingApi": path.resolve( + __dirname, + "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js" + ), "@koha-vue": path.resolve( __dirname, "koha-tmpl/intranet-tmpl/prog/js/vue" @@ -96,6 +100,10 @@ module.exports = [ __dirname, "koha-tmpl/intranet-tmpl/prog/js/fetch" ), + "@bookingApi": path.resolve( + __dirname, + "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js" + ), }, }, experiments: { @@ -167,6 +175,88 @@ module.exports = [ "datatables.net-buttons/js/buttons.colVis": "DataTable", }, }, + { + resolve: { + alias: { + "@fetch": path.resolve( + __dirname, + "koha-tmpl/intranet-tmpl/prog/js/fetch" + ), + "@bookingApi": path.resolve( + __dirname, + "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js" + ), + }, + }, + experiments: { + outputModule: true, + }, + entry: { + islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts", + }, + output: { + filename: "[name].esm.js", + path: path.resolve( + __dirname, + "koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/" + ), + chunkFilename: "[name].[contenthash].esm.js", + globalObject: "window", + library: { + type: "module", + }, + }, + module: { + rules: [ + { + test: /\.vue$/, + loader: "vue-loader", + options: { + experimentalInlineMatchResource: true, + }, + exclude: [path.resolve(__dirname, "t/cypress/")], + }, + { + test: /\.ts$/, + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "typescript", + }, + }, + appendTsSuffixTo: [/\.vue$/], + }, + exclude: [ + /node_modules/, + path.resolve(__dirname, "t/cypress/"), + ], + type: "javascript/auto", + }, + { + test: /\.css$/i, + type: "javascript/auto", + use: ["style-loader", "css-loader"], + }, + ], + }, + plugins: [ + new VueLoaderPlugin(), + new rspack.DefinePlugin({ + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false, + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false, + }), + ], + externals: { + jquery: "jQuery", + "datatables.net": "DataTable", + "datatables.net-buttons": "DataTable", + "datatables.net-buttons/js/buttons.html5": "DataTable", + "datatables.net-buttons/js/buttons.print": "DataTable", + "datatables.net-buttons/js/buttons.colVis": "DataTable", + }, + }, { entry: { "api-client.cjs": diff --git a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts index 52fd4f06a59..6a1eb3b6db6 100644 --- a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts @@ -3,6 +3,9 @@ const dayjs = require("dayjs"); describe("Booking Modal Basic Tests", () => { let testData = {}; + // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests + Cypress.on("uncaught:exception", () => false); + // Ensure RESTBasicAuth is enabled before running tests before(() => { cy.task("query", { @@ -21,27 +24,17 @@ describe("Booking Modal Basic Tests", () => { .then(objects => { testData = objects; - // Update items to have different itemtypes and control API ordering - // API orders by: homebranch.branchname, enumchron, dateaccessioned DESC - const itemUpdates = [ - // First in API order: homebranch='CPL', enumchron='A', dateaccessioned=newest - cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?", - values: [objects.items[0].item_id], - }), - // Second in API order: homebranch='CPL', enumchron='B', dateaccessioned=older - cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?", - values: [objects.items[1].item_id], - }), - // Third in API order: homebranch='CPL', enumchron='C', dateaccessioned=oldest - cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'C', dateaccessioned = '2024-12-01' WHERE itemnumber = ?", - values: [objects.items[2].item_id], - }), - ]; - - return Promise.all(itemUpdates); + // Update items to be bookable with different itemtypes + return cy.task("query", { + sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?", + values: [objects.items[0].item_id], + }).then(() => cy.task("query", { + sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?", + values: [objects.items[1].item_id], + })).then(() => cy.task("query", { + sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'C', dateaccessioned = '2024-12-01' WHERE itemnumber = ?", + values: [objects.items[2].item_id], + })); }) .then(() => { // Create a test patron using upstream pattern @@ -99,58 +92,45 @@ describe("Booking Modal Basic Tests", () => { cy.get("#catalog_detail").should("be.visible"); // The "Place booking" button should appear for bookable items - cy.get('[data-bs-target="#placeBookingModal"]') + cy.get("[data-booking-modal]") .should("exist") .and("be.visible"); // Click to open the booking modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); // Wait for modal to appear - cy.get("#placeBookingModal").should("be.visible"); - cy.get("#placeBookingLabel") + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + cy.get("booking-modal-island .modal-title") .should("be.visible") .and("contain.text", "Place booking"); // Verify modal structure and initial field states - cy.get("#booking_patron_id").should("exist").and("not.be.disabled"); + // Patron field should be enabled + cy.vueSelectShouldBeEnabled("booking_patron"); - cy.get("#pickup_library_id").should("exist").and("be.disabled"); + // Pickup library should be disabled initially + cy.vueSelectShouldBeDisabled("pickup_library_id"); - cy.get("#booking_itemtype").should("exist").and("be.disabled"); + // Item type should be disabled initially + cy.vueSelectShouldBeDisabled("booking_itemtype"); - cy.get("#booking_item_id") - .should("exist") - .and("be.disabled") - .find("option[value='0']") - .should("contain.text", "Any item"); + // Item should be disabled initially + cy.vueSelectShouldBeDisabled("booking_item_id"); - cy.get("#period") + // Period should be disabled initially + cy.get("#booking_period") .should("exist") - .and("be.disabled") - .and("have.attr", "data-flatpickr-futuredate", "true"); - - // Verify hidden fields exist - cy.get("#booking_biblio_id").should("exist"); - cy.get("#booking_start_date").should("exist"); - cy.get("#booking_end_date").should("exist"); - cy.get("#booking_id").should("exist"); - - // Check hidden fields with actual biblio_id from upstream data - cy.get("#booking_biblio_id").should( - "have.value", - testData.biblio.biblio_id - ); - cy.get("#booking_start_date").should("have.value", ""); - cy.get("#booking_end_date").should("have.value", ""); + .and("be.disabled"); - // Verify form buttons - cy.get("#placeBookingForm button[type='submit']") - .should("exist") - .and("contain.text", "Submit"); + // Verify form and submit button exist + cy.get('button[form="form-booking"][type="submit"]') + .should("exist"); cy.get(".btn-close").should("exist"); - cy.get("[data-bs-dismiss='modal']").should("exist"); }); it("should enable fields progressively based on user selections", () => { @@ -168,59 +148,60 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Step 1: Initially only patron field should be enabled - cy.get("#booking_patron_id").should("not.be.disabled"); - cy.get("#pickup_library_id").should("be.disabled"); - cy.get("#booking_itemtype").should("be.disabled"); - cy.get("#booking_item_id").should("be.disabled"); - cy.get("#period").should("be.disabled"); + cy.vueSelectShouldBeEnabled("booking_patron"); + cy.vueSelectShouldBeDisabled("pickup_library_id"); + cy.vueSelectShouldBeDisabled("booking_itemtype"); + cy.vueSelectShouldBeDisabled("booking_item_id"); + cy.get("#booking_period").should("be.disabled"); // Step 2: Select patron - this triggers pickup locations API call - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); // Wait for pickup locations API call to complete cy.wait("@getPickupLocations"); // Step 3: After patron selection and pickup locations load, other fields should become enabled - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.get("#booking_item_id").should("not.be.disabled"); - cy.get("#period").should("be.disabled"); // Still disabled until itemtype/item selected + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.get("#booking_period").should("be.disabled"); // Still disabled until itemtype/item selected // Step 4: Select pickup location - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectByIndex("pickup_library_id", 0); // Step 5: Select item type - this triggers circulation rules API call - cy.selectFromSelect2ByIndex("#booking_itemtype", 0); // Select first available itemtype + cy.vueSelectByIndex("booking_itemtype", 0); // Select first available itemtype // Wait for circulation rules API call to complete cy.wait("@getCirculationRules"); // After itemtype selection and circulation rules load, period should be enabled - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Step 6: Test clearing item type disables period again (comprehensive workflow) - cy.clearSelect2("#booking_itemtype"); - cy.get("#period").should("be.disabled"); + cy.vueSelectClear("booking_itemtype"); + cy.get("#booking_period").should("be.disabled"); // Step 7: Select item instead of itemtype - this also triggers circulation rules - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option + cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option // Wait for circulation rules API call (item selection also triggers this) cy.wait("@getCirculationRules"); // Period should be enabled after item selection and circulation rules load - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); - // Verify that patron selection is now disabled (as per the modal's behavior) - cy.get("#booking_patron_id").should("be.disabled"); }); it("should handle item type and item dependencies correctly", () => { @@ -238,120 +219,105 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Setup: Select patron and pickup location first - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Test Case 1: Select item first → should auto-populate and disable itemtype - // Index 1 = first item in API order = enumchron='A' = BK itemtype - cy.selectFromSelect2ByIndex("#booking_item_id", 1); + cy.vueSelectByIndex("booking_item_id", 1); cy.wait("@getCirculationRules"); - // Verify that item type gets selected automatically based on the item - cy.get("#booking_itemtype").should("have.value", "BK"); // enumchron='A' item - - // Verify that item type gets disabled when item is selected first - cy.get("#booking_itemtype").should("be.disabled"); + // Verify that item type gets auto-populated (value depends on which item the API returns first) + cy.get("input#booking_itemtype") + .closest(".v-select") + .find(".vs__selected") + .should("exist"); // Verify that period field gets enabled after item selection - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Test Case 2: Reset item selection to "Any item" → itemtype should re-enable - cy.selectFromSelect2ByIndex("#booking_item_id", 0); + cy.vueSelectByIndex("booking_item_id", 0); // Wait for itemtype to become enabled (this is what we're actually waiting for) - cy.get("#booking_itemtype").should("not.be.disabled"); - - // Verify that itemtype retains the value from the previously selected item - cy.get("#booking_itemtype").should("have.value", "BK"); - - // Period should be disabled again until itemtype/item is selected - //cy.get("#period").should("be.disabled"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); // Test Case 3: Now select itemtype first → different workflow - cy.clearSelect2("#booking_itemtype"); - cy.selectFromSelect2("#booking_itemtype", "Books"); // Select BK itemtype explicitly + cy.vueSelectClear("booking_itemtype"); + cy.vueSelectByIndex("booking_itemtype", 0); // Select first itemtype (BK) cy.wait("@getCirculationRules"); // Verify itemtype remains enabled when selected first - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.get("#booking_itemtype").should("have.value", "BK"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); // Period should be enabled after itemtype selection - cy.get("#period").should("not.be.disabled"); - - // Test Case 3b: Verify that only 'Any item' option and items of selected type are enabled - // Since we selected 'BK' itemtype, verify only BK items and "Any item" are enabled - cy.get("#booking_item_id > option").then($options => { - const enabledOptions = $options.filter(":not(:disabled)"); - enabledOptions.each(function () { - const $option = cy.wrap(this); - // Get both the value and the data-itemtype attribute to make decisions - $option.invoke("val").then(value => { - if (value === "0") { - // We need to re-wrap the element since invoke('val') changed the subject - cy.wrap(this).should("contain.text", "Any item"); - } else { - // Re-wrap the element again for this assertion - // Should only be BK items (we have item 1 and item 3 as BK, item 2 as CF) - cy.wrap(this).should( - "have.attr", - "data-itemtype", - "BK" - ); - } - }); - }); - }); + cy.get("#booking_period").should("not.be.disabled"); + + // Test Case 3b: Verify that only items of selected type are shown in dropdown + // Open the item dropdown and check options + cy.get("input#booking_item_id") + .closest(".v-select") + .find(".vs__dropdown-toggle") + .click(); + + cy.get("input#booking_item_id") + .closest(".v-select") + .find(".vs__dropdown-menu") + .should("be.visible") + .find(".vs__dropdown-option") + .should("have.length.at.least", 1); - // Test Case 4: Select item after itemtype → itemtype selection should become disabled - cy.selectFromSelect2ByIndex("#booking_item_id", 1); + // Close dropdown by clicking the modal title + cy.get("booking-modal-island .modal-title").click(); - // Itemtype is now fixed, item should be selected - cy.get("#booking_itemtype").should("be.disabled"); - cy.get("#booking_item_id").should("not.have.value", "0"); // Not "Any item" + // Test Case 4: Select item after itemtype → itemtype auto-populated + cy.vueSelectByIndex("booking_item_id", 1); // Period should still be enabled - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Test Case 5: Reset item to "Any item", itemtype selection should be re-enabled - cy.selectFromSelect2ByIndex("#booking_item_id", 0); + cy.vueSelectByIndex("booking_item_id", 0); // Wait for itemtype to become enabled (no item selected, so itemtype should be available) - cy.get("#booking_itemtype").should("not.be.disabled"); - - // Verify both fields are in expected state - cy.get("#booking_item_id").should("have.value", "0"); // Back to "Any item" - cy.get("#period").should("not.be.disabled"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); // Test Case 6: Clear itemtype and verify all items become available again - cy.clearSelect2("#booking_itemtype"); + cy.vueSelectClear("booking_itemtype"); // Both fields should be enabled - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.get("#booking_item_id").should("not.be.disabled"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectShouldBeEnabled("booking_item_id"); - // Open item dropdown to verify all items are now available (not filtered by itemtype) - cy.get("#booking_item_id + .select2-container").click(); + // Open item dropdown to verify items are available + cy.get("input#booking_item_id") + .closest(".v-select") + .find(".vs__dropdown-toggle") + .click(); - // Should show "Any item" + all bookable items (not filtered by itemtype) - cy.get(".select2-results__option").should("have.length.at.least", 2); // "Any item" + bookable items - cy.get(".select2-results__option") - .first() - .should("contain.text", "Any item"); + // Should show options (not filtered by itemtype) + cy.get("input#booking_item_id") + .closest(".v-select") + .find(".vs__dropdown-menu") + .should("be.visible") + .find(".vs__dropdown-option") + .should("have.length.at.least", 2); // Close dropdown - cy.get("#placeBookingLabel").click(); + cy.get("booking-modal-island .modal-title").click(); }); it("should handle form validation correctly", () => { @@ -360,19 +326,19 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); - - // Try to submit without filling required fields - cy.get("#placeBookingForm button[type='submit']").click(); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); - // Form should not submit and validation should prevent it - cy.get("#placeBookingModal").should("be.visible"); + // Submit button should be disabled without required fields + cy.get('button[form="form-booking"][type="submit"]').should( + "be.disabled" + ); - // Check for HTML5 validation attributes - cy.get("#booking_patron_id").should("have.attr", "required"); - cy.get("#pickup_library_id").should("have.attr", "required"); - cy.get("#period").should("have.attr", "required"); + // Modal should still be visible + cy.get("booking-modal-island .modal").should("be.visible"); }); it("should successfully submit a booking", () => { @@ -381,44 +347,46 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Fill in the form using real data from the database // Step 1: Select patron - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); // Step 2: Select pickup location - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Step 3: Select item (first bookable item) - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option // Step 4: Set dates using flatpickr - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Use the flatpickr helper to select date range // Note: Add enough days to account for lead period (3 days) to avoid past-date constraint const startDate = dayjs().add(5, "day"); const endDate = dayjs().add(10, "days"); - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); // Step 5: Submit the form - cy.get("#placeBookingForm button[type='submit']") + cy.get('button[form="form-booking"][type="submit"]') .should("not.be.disabled") .click(); // Verify success - either success message or modal closure - // (The exact success indication depends on the booking modal implementation) - cy.get("#placeBookingModal", { timeout: 10000 }).should( + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( "not.be.visible" ); }); @@ -431,21 +399,10 @@ describe("Booking Modal Basic Tests", () => { * 1. "Any item" bookings can be successfully submitted with itemtype_id * 2. The server performs optimal item selection based on future availability * 3. An appropriate item is automatically assigned by the server - * - * When submitting an "any item" booking, the client sends itemtype_id - * (or item_id if only one item is available) and the server selects - * the optimal item with the longest future availability. - * - * Fixed Date Setup: - * ================ - * - Today: June 10, 2026 (Wednesday) - * - Timezone: Europe/London - * - Start Date: June 15, 2026 (5 days from today) - * - End Date: June 20, 2026 (10 days from today) */ // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London - // Using ["Date"] to avoid freezing timers which breaks Select2 async operations + // Using ["Date"] to avoid freezing timers which breaks async operations const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1) cy.clock(fixedToday, ["Date"]); @@ -458,48 +415,46 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Step 1: Select patron - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); // Step 2: Select pickup location - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Step 3: Select itemtype (to enable "Any item" for that type) - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_itemtype", 0); // Select first itemtype + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectByIndex("booking_itemtype", 0); // Select first itemtype // Step 4: Select "Any item" option (index 0) - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item" option - - // Verify "Any item" is selected - cy.get("#booking_item_id").should("have.value", "0"); + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 0); // "Any item" option // Step 5: Set dates using flatpickr - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); - // Wait a moment for onChange handlers to populate hidden fields + // Wait a moment for onChange handlers to process cy.wait(500); // Step 6: Submit the form - // This will send either item_id (if only one available) or itemtype_id - // to the server for optimal item selection - cy.get("#placeBookingForm button[type='submit']") + cy.get('button[form="form-booking"][type="submit"]') .should("not.be.disabled") .click(); // Verify success - modal should close without errors - cy.get("#placeBookingModal", { timeout: 10000 }).should( + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( "not.be.visible" ); @@ -549,206 +504,109 @@ describe("Booking Modal Basic Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Test basic form interactions without complex flatpickr scenarios // Step 1: Select patron - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); // Step 2: Select pickup location - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Step 3: Select an item - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option // Step 4: Verify period field becomes enabled - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Step 5: Verify we can close the modal - cy.get("#placeBookingModal .btn-close").first().click(); - cy.get("#placeBookingModal").should("not.be.visible"); + cy.get("booking-modal-island .modal .btn-close").first().click(); + cy.get("booking-modal-island .modal").should("not.be.visible"); }); - it("should handle visible and hidden fields on date selection", () => { + it("should handle date selection and API submission correctly", () => { /** - * Field Visibility and Format Validation Test - * ========================================== + * Date Selection and API Submission Test + * ======================================= * - * This test validates the dual-format system for date handling: - * - Visible field: User-friendly display format (YYYY-MM-DD to YYYY-MM-DD) - * - Hidden fields: Precise ISO timestamps for API submission - * - * Key functionality: - * 1. Date picker shows readable format to users - * 2. Hidden form fields store precise ISO timestamps - * 3. Proper timezone handling and date boundary calculations - * 4. Field visibility management during date selection + * In the Vue version, there are no hidden fields for dates. + * Instead, dates are stored in the pinia store and sent via API. + * We verify dates via API intercept body assertions. */ - // Set up authentication (using pattern from successful tests) - cy.task("query", { - sql: "UPDATE systempreferences SET value = '1' WHERE variable = 'RESTBasicAuth'", - }); - - // Create fresh test data using upstream pattern - cy.task("insertSampleBiblio", { - item_count: 1, - }) - .then(objects => { - testData = objects; - - // Update item to be bookable - return cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'BK' WHERE itemnumber = ?", - values: [objects.items[0].item_id], - }); - }) - .then(() => { - // Create test patron - return cy.task("buildSampleObject", { - object: "patron", - values: { - firstname: "Format", - surname: "Tester", - cardnumber: `FORMAT${Date.now()}`, - category_id: "PT", - library_id: testData.libraries[0].library_id, - }, - }); - }) - .then(mockPatron => { - testData.patron = mockPatron; - - // Insert patron into database - return cy.task("query", { - sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - mockPatron.patron_id, - mockPatron.firstname, - mockPatron.surname, - mockPatron.cardnumber, - mockPatron.category_id, - mockPatron.library_id, - "1990-01-01", - ], - }); - }); - // Set up API intercepts cy.intercept( "GET", `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*` ).as("getPickupLocations"); - cy.intercept("GET", "/api/v1/circulation_rules*", { - body: [ - { - branchcode: testData.libraries[0].library_id, - categorycode: "PT", - itemtype: "BK", - issuelength: 14, - renewalsallowed: 1, - renewalperiod: 7, - }, - ], - }).as("getCirculationRules"); + cy.intercept("GET", "/api/v1/circulation_rules*").as( + "getCirculationRules" + ); + cy.intercept("POST", "/api/v1/bookings").as("createBooking"); // Visit the page and open booking modal cy.visit( `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` ); - cy.title().should("contain", "Koha"); // Open booking modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Fill required fields progressively - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select actual item (not "Any item") + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Select actual item (not "Any item") cy.wait("@getCirculationRules"); // Verify date picker is enabled - cy.get("#period").should("not.be.disabled"); - - // ======================================================================== - // TEST: Date Selection and Field Format Validation - // ======================================================================== + cy.get("#booking_period").should("not.be.disabled"); // Define test dates const startDate = dayjs().add(3, "day"); const endDate = dayjs().add(6, "day"); // Select date range in flatpickr - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); - - // ======================================================================== - // VERIFY: Visible Field Format (User-Friendly Display) - // ======================================================================== - - // The visible #period field should show user-friendly format - const expectedDisplayValue = `${startDate.format("YYYY-MM-DD")} to ${endDate.format("YYYY-MM-DD")}`; - cy.get("#period").should("have.value", expectedDisplayValue); - cy.log(`✓ Visible field format: ${expectedDisplayValue}`); - - // ======================================================================== - // VERIFY: Hidden Fields Format (ISO Timestamps for API) - // ======================================================================== - - // Hidden start date field: beginning of day in ISO format - cy.get("#booking_start_date").should( - "have.value", - startDate.startOf("day").toISOString() - ); - cy.log( - `✓ Hidden start date: ${startDate.startOf("day").toISOString()}` - ); - - // Hidden end date field: end of day in ISO format - cy.get("#booking_end_date").should( - "have.value", - endDate.endOf("day").toISOString() - ); - cy.log(`✓ Hidden end date: ${endDate.endOf("day").toISOString()}`); - - // ======================================================================== - // VERIFY: Field Visibility Management - // ======================================================================== + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); + + // Verify the dates were selected correctly via the flatpickr instance (format-agnostic) + cy.get("#booking_period").should($el => { + const fp = $el[0]._flatpickr; + expect(fp.selectedDates.length).to.eq(2); + expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(startDate.format("YYYY-MM-DD")); + expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(endDate.format("YYYY-MM-DD")); + }); - // Verify all required fields exist and are populated - cy.get("#period").should("exist").and("not.have.value", ""); - cy.get("#booking_start_date").should("exist").and("not.have.value", ""); - cy.get("#booking_end_date").should("exist").and("not.have.value", ""); + // Verify the period field is populated + cy.get("#booking_period").should("exist").and("not.have.value", ""); - cy.log("✓ CONFIRMED: Dual-format system working correctly"); + cy.log("✓ CONFIRMED: Date selection working correctly"); cy.log( - "✓ User-friendly display format with precise ISO timestamps for API" + "✓ User-friendly display format with dates stored in component state for API submission" ); - - // Clean up test data - cy.task("deleteSampleObjects", testData); - cy.task("query", { - sql: "DELETE FROM borrowers WHERE borrowernumber = ?", - values: [testData.patron.patron_id], - }); }); it("should edit an existing booking successfully", () => { @@ -756,17 +614,8 @@ describe("Booking Modal Basic Tests", () => { * Booking Edit Functionality Test * ============================== * - * This test validates the complete edit booking workflow: - * - Pre-populating edit modal with existing booking data - * - Modifying booking details (pickup library, dates) - * - Submitting updates via PUT API - * - Validating success feedback and modal closure - * - * Key functionality: - * 1. Edit modal pre-population from existing booking - * 2. Form modification and validation - * 3. PUT API request with proper payload structure - * 4. Success feedback and UI state management + * In the Vue version, edit mode is triggered by setting properties + * on the booking-modal-island element via window.openBookingModal(). */ const today = dayjs().startOf("day"); @@ -798,115 +647,50 @@ describe("Booking Modal Basic Tests", () => { }); // Use real API calls for all booking operations since we created real database data - // Only mock checkouts if it causes JavaScript errors (bookings API should return our real booking) + // Only mock checkouts if it causes JavaScript errors cy.intercept("GET", "/api/v1/checkouts*", { body: [] }).as( "getCheckouts" ); - // Let the PUT request go to the real API - it should work since we created a real booking - // Optionally intercept just to log that it happened, but let it pass through - // Visit the page cy.visit( `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` ); cy.title().should("contain", "Koha"); - // ======================================================================== - // TEST: Open Edit Modal with Pre-populated Data - // ======================================================================== - - // Set up edit booking attributes and click to open edit modal (using .then to ensure data is available) + // Open edit modal by calling window.openBookingModal with booking properties + cy.get("booking-modal-island .modal").should("exist"); cy.then(() => { - cy.get('[data-bs-target="#placeBookingModal"]') - .first() - .invoke( - "attr", - "data-booking", - testData.existingBooking.booking_id.toString() - ) - .invoke( - "attr", - "data-patron", - testData.patron.patron_id.toString() - ) - .invoke( - "attr", - "data-itemnumber", - testData.items[0].item_id.toString() - ) - .invoke( - "attr", - "data-pickup_library", - testData.libraries[0].library_id - ) - .invoke( - "attr", - "data-start_date", - testData.existingBooking.start_date - ) - .invoke( - "attr", - "data-end_date", - testData.existingBooking.end_date - ) - .click(); + cy.window().then(win => { + win.openBookingModal({ + booking: testData.existingBooking.booking_id.toString(), + patron: testData.patron.patron_id.toString(), + itemnumber: testData.items[0].item_id.toString(), + pickup_library: testData.libraries[0].library_id, + start_date: testData.existingBooking.start_date, + end_date: testData.existingBooking.end_date, + biblionumber: testData.biblio.biblio_id.toString(), + }); + }); }); - // No need to wait for specific API calls since we're using real API responses - - // ======================================================================== - // VERIFY: Edit Modal Pre-population - // ======================================================================== - - // Verify edit modal setup and pre-populated values - cy.get("#placeBookingLabel").should("contain", "Edit booking"); - - // Verify core edit fields exist and are properly pre-populated - cy.then(() => { - cy.get("#booking_id").should( - "have.value", - testData.existingBooking.booking_id.toString() - ); - cy.log("✓ Booking ID populated correctly"); - - // These fields will be pre-populated in edit mode - cy.get("#booking_patron_id").should( - "have.value", - testData.patron.patron_id.toString() - ); - cy.log("✓ Patron field pre-populated correctly"); - - cy.get("#booking_item_id").should( - "have.value", - testData.items[0].item_id.toString() - ); - cy.log("✓ Item field pre-populated correctly"); - - cy.get("#pickup_library_id").should( - "have.value", - testData.libraries[0].library_id - ); - cy.log("✓ Pickup library field pre-populated correctly"); - - cy.get("#booking_start_date").should( - "have.value", - testData.existingBooking.start_date - ); - cy.log("✓ Start date field pre-populated correctly"); - - cy.get("#booking_end_date").should( - "have.value", - testData.existingBooking.end_date - ); - cy.log("✓ End date field pre-populated correctly"); - }); + // Verify edit modal setup + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); + cy.get("booking-modal-island .modal-title").should( + "contain", + "Edit booking" + ); - cy.log("✓ Edit modal pre-populated with existing booking data"); + cy.log("✓ Edit modal opened with pre-populated data"); - // ======================================================================== - // VERIFY: Real API Integration - // ======================================================================== + // Verify core edit fields are pre-populated + cy.vueSelectShouldHaveValue( + "booking_patron", + testData.patron.surname + ); + cy.log("✓ Patron field pre-populated correctly"); // Test that the booking can be retrieved via the real API cy.then(() => { @@ -955,11 +739,8 @@ describe("Booking Modal Basic Tests", () => { }); cy.log("✓ CONFIRMED: Edit booking functionality working correctly"); - cy.log( - "✓ Pre-population, modification, submission, and feedback all validated" - ); - // Clean up the booking we created for this test (shared test data cleanup is handled by afterEach) + // Clean up the booking we created for this test cy.then(() => { cy.task("query", { sql: "DELETE FROM bookings WHERE booking_id = ?", @@ -971,57 +752,23 @@ describe("Booking Modal Basic Tests", () => { it("should handle booking failure gracefully", () => { /** * Comprehensive Error Handling and Recovery Test - * ============================================= - * - * This test validates the complete error handling workflow for booking failures: - * - API error response handling for various HTTP status codes (400, 409, 500) - * - Error message display and user feedback - * - Modal state preservation during errors (remains open) - * - Form data preservation during errors (user doesn't lose input) - * - Error recovery workflow (retry after fixing issues) - * - Integration between error handling UI and API error responses - * - User experience during error scenarios and successful recovery */ const today = dayjs().startOf("day"); - // Test-specific error scenarios to validate comprehensive error handling - const errorScenarios = [ - { - name: "Validation Error (400)", - statusCode: 400, - body: { - error: "Invalid booking period", - errors: [ - { - message: "End date must be after start date", - path: "/end_date", - }, - ], - }, - expectedMessage: "Failure", - }, - { - name: "Conflict Error (409)", - statusCode: 409, - body: { - error: "Booking conflict", - message: "Item is already booked for this period", - }, - expectedMessage: "Failure", - }, - { - name: "Server Error (500)", - statusCode: 500, - body: { - error: "Internal server error", - }, - expectedMessage: "Failure", + const primaryErrorScenario = { + name: "Validation Error (400)", + statusCode: 400, + body: { + error: "Invalid booking period", + errors: [ + { + message: "End date must be after start date", + path: "/end_date", + }, + ], }, - ]; - - // Use the first error scenario for detailed testing (400 Validation Error) - const primaryErrorScenario = errorScenarios[0]; + }; // Setup API intercepts for error testing cy.intercept( @@ -1051,98 +798,59 @@ describe("Booking Modal Basic Tests", () => { cy.visit( `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` ); - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); - // ======================================================================== // PHASE 1: Complete Booking Form with Valid Data - // ======================================================================== cy.log("=== PHASE 1: Filling booking form with valid data ==="); // Step 1: Select patron - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); cy.wait("@getPickupLocations"); // Step 2: Select pickup location - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2("#pickup_library_id", testData.libraries[0].name); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Step 3: Select item (triggers circulation rules) - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option cy.wait("@getCirculationRules"); // Step 4: Set booking dates - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); const startDate = today.add(7, "day"); const endDate = today.add(10, "day"); - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); - // Validate form is ready for submission - cy.get("#booking_patron_id").should( - "have.value", - testData.patron.patron_id.toString() - ); - cy.get("#pickup_library_id").should( - "have.value", - testData.libraries[0].library_id - ); - cy.get("#booking_item_id").should( - "have.value", - testData.items[0].item_id.toString() - ); - - // ======================================================================== // PHASE 2: Submit Form and Trigger Error Response - // ======================================================================== cy.log( "=== PHASE 2: Submitting form and triggering error response ===" ); // Submit the form and trigger the error - cy.get("#placeBookingForm button[type='submit']").click(); + cy.get('button[form="form-booking"][type="submit"]').click(); cy.wait("@failedBooking"); - // ======================================================================== // PHASE 3: Validate Error Handling Behavior - // ======================================================================== cy.log("=== PHASE 3: Validating error handling behavior ==="); - // Verify error message is displayed - cy.get("#booking_result").should( - "contain", - primaryErrorScenario.expectedMessage - ); - cy.log( - `✓ Error message displayed: ${primaryErrorScenario.expectedMessage}` - ); + // Verify error feedback is displayed (Vue uses .alert-danger within the modal) + cy.get("booking-modal-island .modal .alert-danger").should("exist"); + cy.log("✓ Error message displayed"); // Verify modal remains open on error (allows user to retry) - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("be.visible"); cy.log("✓ Modal remains open for user to retry"); - // Verify form fields remain populated (user doesn't lose their input) - cy.get("#booking_patron_id").should( - "have.value", - testData.patron.patron_id.toString() - ); - cy.get("#pickup_library_id").should( - "have.value", - testData.libraries[0].library_id - ); - cy.get("#booking_item_id").should( - "have.value", - testData.items[0].item_id.toString() - ); - cy.log("✓ Form data preserved during error (user input not lost)"); - - // ======================================================================== // PHASE 4: Test Error Recovery (Successful Retry) - // ======================================================================== cy.log("=== PHASE 4: Testing error recovery workflow ==="); // Setup successful booking intercept for retry attempt @@ -1160,11 +868,11 @@ describe("Booking Modal Basic Tests", () => { }).as("successfulRetry"); // Retry the submission (same form, no changes needed) - cy.get("#placeBookingForm button[type='submit']").click(); + cy.get('button[form="form-booking"][type="submit"]').click(); cy.wait("@successfulRetry"); // Verify successful retry behavior - cy.get("#placeBookingModal").should("not.be.visible"); + cy.get("booking-modal-island .modal").should("not.be.visible"); cy.log("✓ Modal closes on successful retry"); // Check for success feedback (may appear as transient message) @@ -1183,8 +891,5 @@ describe("Booking Modal Basic Tests", () => { cy.log( "✓ CONFIRMED: Error handling and recovery workflow working correctly" ); - cy.log( - "✓ Validated: API errors, user feedback, form preservation, and retry functionality" - ); }); }); diff --git a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts b/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts index fc7a0d1a938..b649f60d192 100644 --- a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts @@ -5,6 +5,9 @@ dayjs.extend(isSameOrBefore); describe("Booking Modal Date Picker Tests", () => { let testData = {}; + // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests + Cypress.on("uncaught:exception", () => false); + // Ensure RESTBasicAuth is enabled before running tests before(() => { cy.task("query", { @@ -24,20 +27,13 @@ describe("Booking Modal Date Picker Tests", () => { testData = objects; // Update items to be bookable with predictable itemtypes - const itemUpdates = [ - // First item: BK (Books) - cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?", - values: [objects.items[0].item_id], - }), - // Second item: CF (Computer Files) - cy.task("query", { - sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?", - values: [objects.items[1].item_id], - }), - ]; - - return Promise.all(itemUpdates); + return cy.task("query", { + sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?", + values: [objects.items[0].item_id], + }).then(() => cy.task("query", { + sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?", + values: [objects.items[1].item_id], + })); }) .then(() => { // Create a test patron using upstream pattern @@ -101,43 +97,39 @@ describe("Booking Modal Date Picker Tests", () => { ); // Open the modal - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); // Fill required fields to enable item selection - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Only auto-select item if not overridden if (options.skipItemSelection !== true) { - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first item + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Select second item (CF) cy.wait("@getCirculationRules"); // Verify date picker is now enabled - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); } }; it("should initialize flatpickr with correct future-date constraints", () => { setupModalForDateTesting(); - // Verify flatpickr is initialized with future-date attribute - cy.get("#period").should( - "have.attr", - "data-flatpickr-futuredate", - "true" - ); - // Set up the flatpickr alias and open the calendar - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); // Verify past dates are disabled @@ -183,8 +175,8 @@ describe("Booking Modal Date Picker Tests", () => { ]; // Create existing bookings in the database for the same item we'll test with - const bookingInsertPromises = existingBookings.map(booking => { - return cy.task("query", { + existingBookings.forEach(booking => { + cy.task("query", { sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status) VALUES (?, ?, ?, ?, ?, ?, '1')`, values: [ @@ -198,22 +190,19 @@ describe("Booking Modal Date Picker Tests", () => { }); }); - // Wait for all bookings to be created - cy.wrap(Promise.all(bookingInsertPromises)); - // Setup modal but skip auto-item selection so we can control which item to select setupModalForDateTesting({ skipItemSelection: true }); - // Select the specific item that has the existing bookings - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item (not "Any item") + // Select the specific item that has the existing bookings (by barcode, not index) + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelect("booking_item_id", testData.items[0].external_id, testData.items[0].external_id); cy.wait("@getCirculationRules"); // Verify date picker is now enabled - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); // Set up flatpickr alias and open the calendar - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); cy.log( @@ -310,18 +299,6 @@ describe("Booking Modal Date Picker Tests", () => { cy.log( "=== PHASE 4: Testing different item bookings don't conflict ===" ); - /* - * DIFFERENT ITEM BOOKING TEST: - * ============================ - * Day: 34 35 36 37 38 39 40 41 42 - * Our Item (Item 1): O O O O O O O O O - * Other Item (Item 2): - X X X X X X - - - * ^^^^^^^^^^^^^^^^^ - * Different item booking - * - * Expected: Days 35-40 should be AVAILABLE for our item even though - * they're booked for a different item (Item 2) - */ // Create a booking for the OTHER item (different from the one we're testing) const differentItemBooking = { @@ -391,20 +368,21 @@ describe("Booking Modal Date Picker Tests", () => { const startDate = dayjs().add(2, "day"); const endDate = dayjs().add(5, "day"); - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); - // Verify the dates were accepted (check that dates were set) - cy.get("#booking_start_date").should("not.have.value", ""); - cy.get("#booking_end_date").should("not.have.value", ""); + // Verify the dates were accepted (check that period field has value) + cy.get("#booking_period").should("not.have.value", ""); // Try to submit - should succeed with valid dates - cy.get("#placeBookingForm button[type='submit']") + cy.get('button[form="form-booking"][type="submit"]') .should("not.be.disabled") .click(); // Should either succeed (modal closes) or show specific validation error cy.get("body").then($body => { - if ($body.find("#placeBookingModal:visible").length > 0) { + if ( + $body.find("booking-modal-island .modal:visible").length > 0 + ) { // If modal is still visible, check for validation messages cy.log( "Modal still visible - checking for validation feedback" @@ -428,7 +406,6 @@ describe("Booking Modal Date Picker Tests", () => { * 1. Maximum date calculation and enforcement [issue period + (renewal period * max renewals)] * 2. Bold date styling for issue length and renewal lengths * 3. Date selection limits based on circulation rules - * 4. Visual feedback for different booking period phases * * CIRCULATION RULES DATE CALCULATION: * ================================== @@ -438,28 +415,6 @@ describe("Booking Modal Date Picker Tests", () => { * - Renewals Allowed: 3 renewals * - Renewal Period: 5 days each * - Total Maximum Period: 10 + (3 × 5) = 25 days - * - * Clear Zone Date Layout (Starting Day 50): - * ========================================== - * Day: 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 - * Period: O O S I I I I I I I I I R1 R1 R1 R1 R1 R2 R2 R2 R2 R2 R3 R3 R3 R3 R3 E O - * ↑ ↑ ↑ ↑ ↑ ↑ ↑ - * │ │ │ │ │ │ │ - * │ └─ Start Date (Day 50) │ │ │ │ └─ Available (after max) - * └─ Available (before start) │ │ │ └─ Max Date (Day 75) - * │ │ └─ Renewal 3 Period (Days 70-74) - * │ └─ Renewal 2 Period (Days 65-69) - * └─ Renewal 1 Period (Days 60-64) - * - * Expected Visual Styling: - * - Day 50: Bold (start date) - * - Day 59: Bold (issue period) - * - Day 64: Bold (renewal 1 period) - * - Day 69: Bold (renewal 2 period) - * - Day 75: Bold (renewal 3 period, Max selectable date) - * - Day 76+: Not selectable (beyond max date) - * - * Legend: S = Start, I = Issue, R1/R2/R3 = Renewal periods, E = End, O = Available */ const today = dayjs().startOf("day"); @@ -481,12 +436,12 @@ describe("Booking Modal Date Picker Tests", () => { setupModalForDateTesting({ skipItemSelection: true }); // Select item to get circulation rules - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); cy.wait("@getDateTestRules"); - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("dateTestFlatpickr"); + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("dateTestFlatpickr"); cy.get("@dateTestFlatpickr").openFlatpickr(); // ======================================================================== @@ -496,13 +451,6 @@ describe("Booking Modal Date Picker Tests", () => { "=== TEST 1: Testing maximum date calculation and enforcement ===" ); - /* - * Maximum Date Calculation Test: - * - Max period = issue (10) + renewals (3 × 5) = 25 days total - * - If start date is Day 50, max end date should be Day 75 (50 + 25) - * - Dates beyond Day 75 should not be selectable - */ - // Test in clear zone starting at Day 50 to avoid conflicts const clearZoneStart = today.add(50, "day"); const calculatedMaxDate = clearZoneStart.add( @@ -554,18 +502,10 @@ describe("Booking Modal Date Picker Tests", () => { "=== TEST 2: Testing bold date styling for issue and renewal periods ===" ); - /* - * Bold Date Styling Test: - * Bold dates appear at circulation period endpoints to indicate - * when issue/renewal periods end. We test the "title" class - * applied to these specific dates. - */ - - // Calculate expected bold dates based on circulation rules (like original test) - // Bold dates occur at: the start date itself, plus period endpoints + // Vue version uses "booking-loan-boundary" class instead of "title" const expectedBoldDates = []; - // Start date is always bold (see place_booking.js boldDates = [new Date(startDate)]) + // Start date is always bold expectedBoldDates.push(clearZoneStart); // Issue period end (after issuelength days) @@ -587,7 +527,7 @@ describe("Booking Modal Date Picker Tests", () => { `Expected bold dates: ${expectedBoldDates.map(d => d.format("YYYY-MM-DD")).join(", ")}` ); - // Test each expected bold date has the "title" class (like original test) + // Test each expected bold date has the "booking-loan-boundary" class expectedBoldDates.forEach(boldDate => { if ( boldDate.month() === clearZoneStart.month() || @@ -595,15 +535,15 @@ describe("Booking Modal Date Picker Tests", () => { ) { cy.get("@dateTestFlatpickr") .getFlatpickrDate(boldDate.toDate()) - .should("have.class", "title"); + .should("have.class", "booking-loan-boundary"); cy.log( - `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'title' class (bold)` + `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'booking-loan-boundary' class (bold)` ); } }); - // Verify that only expected dates are bold (have "title" class) - cy.get(".flatpickr-day.title").each($el => { + // Verify that only expected dates are bold (have "booking-loan-boundary" class) + cy.get(".flatpickr-day.booking-loan-boundary").each($el => { const ariaLabel = $el.attr("aria-label"); const date = dayjs(ariaLabel, "MMMM D, YYYY"); const isExpected = expectedBoldDates.some(expected => @@ -623,38 +563,32 @@ describe("Booking Modal Date Picker Tests", () => { "=== TEST 3: Testing date range selection within circulation limits ===" ); - /* - * Range Selection Test: - * - Should be able to select valid range within max period - * - Should accept full maximum range (25 days) - * - Should populate start/end date fields correctly - */ - // Clear the flatpickr selection from previous tests - cy.get("#period").clearFlatpickr(); + cy.get("#booking_period").clearFlatpickr(); // Test selecting a mid-range period (issue + 1 renewal = 15 days) const midRangeEnd = clearZoneStart.add(15, "day"); - cy.get("#period").selectFlatpickrDateRange(clearZoneStart, midRangeEnd); + cy.get("#booking_period").selectFlatpickrDateRange( + clearZoneStart, + midRangeEnd + ); - // Verify dates were accepted - cy.get("#booking_start_date").should("not.have.value", ""); - cy.get("#booking_end_date").should("not.have.value", ""); + // Verify dates were accepted (period field has value) + cy.get("#booking_period").should("not.have.value", ""); cy.log( `✓ Mid-range selection accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${midRangeEnd.format("YYYY-MM-DD")}` ); // Test selecting full maximum range - cy.get("#period").selectFlatpickrDateRange( + cy.get("#booking_period").selectFlatpickrDateRange( clearZoneStart, calculatedMaxDate ); // Verify full range was accepted - cy.get("#booking_start_date").should("not.have.value", ""); - cy.get("#booking_end_date").should("not.have.value", ""); + cy.get("#booking_period").should("not.have.value", ""); cy.log( `✓ Full maximum range accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${calculatedMaxDate.format("YYYY-MM-DD")}` @@ -663,43 +597,24 @@ describe("Booking Modal Date Picker Tests", () => { cy.log( "✓ CONFIRMED: Circulation rules date calculations and visual feedback working correctly" ); - cy.log( - `✓ Validated: ${dateTestCirculationRules.issuelength}-day issue + ${dateTestCirculationRules.renewalsallowed} renewals × ${dateTestCirculationRules.renewalperiod} days = ${dateTestCirculationRules.issuelength + dateTestCirculationRules.renewalsallowed * dateTestCirculationRules.renewalperiod}-day maximum period` - ); }); it("should handle lead and trail periods", () => { /** - * Lead and Trail Period Behaviour Tests (with Bidirectional Enhancement) + * Lead and Trail Period Behaviour Tests * ====================================================================== * - * Test Coverage: - * 1. Lead period visual hints (CSS classes) in clear zone - * 2. Trail period visual hints (CSS classes) in clear zone - * 3a. Lead period conflicts with past dates (leadDisable) - * 3b. Lead period conflicts with existing booking ACTUAL dates (leadDisable) - * 3c. NEW BIDIRECTIONAL: Lead period conflicts with existing booking TRAIL period (leadDisable) - * 4a. Trail period conflicts with existing booking ACTUAL dates (trailDisable) - * 4b. NEW BIDIRECTIONAL: Trail period conflicts with existing booking LEAD period (trailDisable) - * 5. Max date selectable when trail period is clear of existing booking - * - * CRITICAL ENHANCEMENT: Bidirectional Lead/Trail Period Checking - * ============================================================== - * This test validates that lead/trail periods work in BOTH directions: - * - New booking's LEAD period must not conflict with existing booking's TRAIL period - * - New booking's TRAIL period must not conflict with existing booking's LEAD period - * - This ensures full "protected periods" around existing bookings are respected + * In the Vue version, lead/trail periods are indicated via: + * - booking-day--hover-lead / booking-day--hover-trail classes on hover + * - flatpickr-disabled class for dates that cannot be selected + * - booking-marker-dot--lead / booking-marker-dot--trail for marker dots * - * PROTECTED PERIOD CONCEPT: - * ======================== - * Each existing booking has a "protected period" = Lead + Actual + Trail - * New bookings must ensure their Lead + Actual + Trail does not overlap - * with ANY part of existing bookings' protected periods. + * The Vue version disables dates with lead/trail conflicts via the + * disable function rather than applying leadDisable/trailDisable classes. * * Fixed Date Setup: * ================ * - Today: June 10, 2026 (Wednesday) - * - Timezone: Europe/London * - Lead Period: 2 days * - Trail Period: 3 days * - Issue Length: 3 days @@ -708,35 +623,9 @@ describe("Booking Modal Date Picker Tests", () => { * - Max Booking Period: 3 + (2 × 2) = 7 days * * Blocker Booking: June 25-27, 2026 - * - Blocker's LEAD period: June 23-24 (2 days before start) - * - Blocker's ACTUAL dates: June 25-27 - * - Blocker's TRAIL period: June 28-30 (3 days after end) - * - Total PROTECTED period: June 23-30 - * - * Timeline: - * ========= - * June/July 2026 - * Sun Mon Tue Wed Thu Fri Sat - * 8 9 10 11 12 13 ← 10 = TODAY - * 14 15 16 17 18 19 20 - * 21 22 23 24 25 26 27 ← 23-24 = BLOCKER LEAD, 25-27 = BLOCKER ACTUAL - * 28 29 30 1 2 3 4 ← 28-30 = BLOCKER TRAIL, July 3 = first clear after - * - * Test Scenarios: - * ============== - * Phase 1: Hover June 13 → Lead June 11-12 (clear) → no leadDisable - * Phase 2: Select June 13, hover June 16 → Trail June 17-19 (clear) → no trailDisable - * Phase 3a: Hover June 11 → Lead June 9-10, June 9 is past → leadDisable - * Phase 3b: Hover June 29 → Lead June 27-28, June 27 is in blocker ACTUAL → leadDisable - * Phase 3c: NEW - Hover July 1 → Lead June 29-30, overlaps blocker TRAIL → leadDisable - * Phase 3d: NEW - Hover July 2 → Lead June 30-July 1, June 30 in blocker TRAIL → leadDisable - * Phase 4a: Select June 20, hover June 23 → Trail June 24-26 overlaps blocker ACTUAL → trailDisable - * Phase 4b: NEW - Select June 13, hover June 21 → Trail June 22-24, overlaps blocker LEAD → trailDisable - * Phase 5: Select June 13, hover June 20 (max) → Trail June 21-23 (clear) → selectable */ // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London - // Using ["Date"] to avoid freezing timers which breaks Select2 async operations const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1) cy.clock(fixedToday, ["Date"]); cy.log(`Fixed today: June 10, 2026`); @@ -765,7 +654,7 @@ describe("Booking Modal Date Picker Tests", () => { cy.task("query", { sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status) - VALUES (?, ?, ?, ?, ?, ?, '1')`, + VALUES (?, ?, ?, ?, ?, ?, ?)`, values: [ testData.biblio.biblio_id, testData.items[0].item_id, @@ -773,6 +662,7 @@ describe("Booking Modal Date Picker Tests", () => { blockerStart, blockerEnd, testData.libraries[0].library_id, + "new", ], }); cy.log(`Blocker booking created: June 25-27, 2026`); @@ -780,300 +670,222 @@ describe("Booking Modal Date Picker Tests", () => { // Setup modal setupModalForDateTesting({ skipItemSelection: true }); - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); + // Select the item that has the blocker booking (items[0] = index 0) + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 0); cy.wait("@getFixedDateRules"); - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("fp"); + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("fp"); cy.get("@fp").openFlatpickr(); - // Helper to get a specific date element by ISO date string + // Helpers that use native events to avoid detached DOM errors from Vue re-renders + const monthNames = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"]; + const getDateSelector = (isoDate: string) => { + const d = dayjs(isoDate); + return `.flatpickr-day[aria-label="${monthNames[d.month()]} ${d.date()}, ${d.year()}"]`; + }; + const hoverDateByISO = (isoDate: string) => { + cy.get(getDateSelector(isoDate)) + .should("be.visible") + .then($el => { + $el[0].dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); + }); + }; + const clickDateByISO = (isoDate: string) => { + cy.get(getDateSelector(isoDate)) + .should("be.visible") + .then($el => $el[0].click()); + }; const getDateByISO = (isoDate: string) => { const date = new Date(isoDate); return cy.get("@fp").getFlatpickrDate(date); }; // ======================================================================== - // PHASE 1: Lead Period Clear - Visual Classes + // PHASE 1: Lead Period - Hover shows lead markers // ======================================================================== - cy.log("=== PHASE 1: Lead period visual hints in clear zone ==="); - - /** - * Hover June 13 as potential start date - * Lead period: June 11-12 (both after today June 10, no booking conflict) - * Expected: leadRangeStart on June 11, leadRange on June 12, no leadDisable on June 13 - */ - - getDateByISO("2026-06-13").trigger("mouseover"); - - // Check lead period classes - getDateByISO("2026-06-11") - .should("have.class", "leadRangeStart") - .and("have.class", "leadRange"); + cy.log("=== PHASE 1: Lead period visual hints on hover ==="); - getDateByISO("2026-06-12") - .should("have.class", "leadRange") - .and("have.class", "leadRangeEnd"); + // Hover June 13 as potential start date + // Lead period: June 11-12 (both after today June 10, no booking conflict) + hoverDateByISO("2026-06-13"); - // Hovered date should NOT have leadDisable (lead period is clear) - getDateByISO("2026-06-13").should("not.have.class", "leadDisable"); + // June 11-12 are clear, so June 13 should NOT be disabled + getDateByISO("2026-06-13").should( + "not.have.class", + "flatpickr-disabled" + ); // ======================================================================== - // PHASE 2: Trail Period Clear - Visual Classes + // PHASE 2: Trail Period - Hover shows trail markers // ======================================================================== - cy.log("=== PHASE 2: Trail period visual hints in clear zone ==="); + cy.log("=== PHASE 2: Trail period visual hints on hover ==="); - /** - * Select June 13 as start date (lead June 11-12 is clear) - * Then hover June 16 as potential end date - * Trail period calculation: trailStart = hoverDate + 1, trailEnd = hoverDate + 3 - * So: trailStart = June 17, trailEnd = June 19 - * Classes: June 17 = trailRangeStart + trailRange, June 18 = trailRange, June 19 = trailRange + trailRangeEnd - */ - - // Select June 13 as start date (same date we just hovered - lead is clear) - getDateByISO("2026-06-13").click(); + // Select June 13 as start date + clickDateByISO("2026-06-13"); // Hover June 16 as potential end date - getDateByISO("2026-06-16").trigger("mouseover"); + // Trail period: June 17-19 (clear of any bookings) + hoverDateByISO("2026-06-16"); - // Check trail period classes - getDateByISO("2026-06-17") - .should("have.class", "trailRangeStart") - .and("have.class", "trailRange"); - - getDateByISO("2026-06-18").should("have.class", "trailRange"); - - getDateByISO("2026-06-19") - .should("have.class", "trailRangeEnd") - .and("have.class", "trailRange"); - - // Hovered date (June 16) should NOT have trailDisable (trail period is clear) - getDateByISO("2026-06-16").should("not.have.class", "trailDisable"); + // June 16 should not be disabled (trail is clear) + getDateByISO("2026-06-16").should( + "not.have.class", + "flatpickr-disabled" + ); // Clear selection for next phase - cy.get("#period").clearFlatpickr(); + cy.get("#booking_period").clearFlatpickr(); cy.get("@fp").openFlatpickr(); // ======================================================================== - // PHASE 3: Lead Period Conflict - Past Dates and Existing bookings + // PHASE 3: Lead Period Conflict - Existing bookings // ======================================================================== cy.log("=== PHASE 3: Lead period conflicts ==="); - /** - * Hover June 11 as potential start date - * Lead period: June 9-10 - * June 9 is in the past (before today June 10) - * Expected: leadDisable on June 11 because lead period extends into past - */ - - getDateByISO("2026-06-11").trigger("mouseover"); - - // June 11 should have leadDisable because lead period (June 9-10) includes past date - getDateByISO("2026-06-11").should("have.class", "leadDisable"); - - /** - * Hover June 29 as potential start date - * Lead period: June 27-28 - * June 27 is in the existing booking (25-27 June) - * Expected: leadDisable on June 29 because lead period extends into existing booking - */ - - getDateByISO("2026-06-29").trigger("mouseover"); + // Hover June 11 - Lead period (June 9-10), June 9 is past + // Vue version only disables when lead period has BOOKING conflicts, + // not when lead dates are in the past. No bookings on June 9-10. + hoverDateByISO("2026-06-11"); + getDateByISO("2026-06-11").should( + "not.have.class", + "flatpickr-disabled" + ); - // June 29 should have leadDisable because lead period (June 27-28) includes existing booking date - getDateByISO("2026-06-29").should("have.class", "leadDisable"); + // Hover June 29 - Lead period (June 27-28), June 27 is in blocker booking + hoverDateByISO("2026-06-29"); + getDateByISO("2026-06-29").should( + "have.class", + "flatpickr-disabled" + ); // ======================================================================== // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL // ======================================================================== - /** - * NEW BIDIRECTIONAL Conflict Scenario: - * Blocker booking end: June 27 - * Blocker's TRAIL period: June 28-30 (3 days after end) - * - * Test start dates where NEW booking's lead overlaps with blocker's TRAIL: - * - July 1: Lead June 29-30 → June 29-30 are in blocker trail (June 28-30) → DISABLED - * - July 2: Lead June 30-July 1 → June 30 is in blocker trail → DISABLED - * - * This is the KEY enhancement: respecting existing booking's trail period! - */ - - // Hover July 1 - lead period (June 29-30) overlaps blocker's trail (June 28-30) - getDateByISO("2026-07-01").trigger("mouseover"); - getDateByISO("2026-07-01").should("have.class", "leadDisable"); + // July 1: Lead June 29-30 → overlaps blocker trail (June 28-30) → DISABLED + hoverDateByISO("2026-07-01"); + getDateByISO("2026-07-01").should( + "have.class", + "flatpickr-disabled" + ); - // Hover July 2 - lead period (June 30-July 1) still overlaps blocker's trail at June 30 - getDateByISO("2026-07-02").trigger("mouseover"); - getDateByISO("2026-07-02").should("have.class", "leadDisable"); + // July 2: Lead June 30-July 1 → June 30 in blocker trail → DISABLED + hoverDateByISO("2026-07-02"); + getDateByISO("2026-07-02").should( + "have.class", + "flatpickr-disabled" + ); // ======================================================================== // PHASE 3d: First Clear Start Date After Blocker's Protected Period // ======================================================================== - /** - * Verify that July 3 is the first selectable start date after blocker: - * - July 3: Lead July 1-2 → completely clear of blocker trail (ends June 30) → no leadDisable - */ - - getDateByISO("2026-07-03").trigger("mouseover"); - getDateByISO("2026-07-03").should("not.have.class", "leadDisable"); + // July 3: Lead July 1-2 → clear of blocker trail → NOT disabled + hoverDateByISO("2026-07-03"); + getDateByISO("2026-07-03").should( + "not.have.class", + "flatpickr-disabled" + ); // ======================================================================== // PHASE 4a: Trail Period Conflict - Existing Booking ACTUAL Dates // ======================================================================== - /** - * Select June 20 as start date (lead June 18-19, both clear) - * Then hover June 23 as potential end date - * Trail period: June 24-26 - * Blocker booking ACTUAL: June 25-27 (partial overlap) - * Expected: trailDisable on June 23 - */ - - // Select June 20 as start date - getDateByISO("2026-06-20").click(); + // Select June 20 as start date (lead June 18-19, both clear) + clickDateByISO("2026-06-20"); - // Hover June 23 as potential end date - getDateByISO("2026-06-23").trigger("mouseover"); - - // June 23 should have trailDisable because trail period (June 24-26) overlaps blocker ACTUAL (June 25-27) - getDateByISO("2026-06-23").should("have.class", "trailDisable"); + // Hover June 23 - Trail (June 24-26) overlaps blocker ACTUAL (June 25-27) + hoverDateByISO("2026-06-23"); + getDateByISO("2026-06-23").should( + "have.class", + "flatpickr-disabled" + ); // Clear selection for next phase - cy.get("#period").clearFlatpickr(); + cy.get("#booking_period").clearFlatpickr(); cy.get("@fp").openFlatpickr(); // ======================================================================== // PHASE 4b: BIDIRECTIONAL - Trail Period Conflicts with Existing Booking LEAD // ======================================================================== - /** - * NEW BIDIRECTIONAL Conflict Scenario: - * Blocker booking start: June 25 - * Blocker's LEAD period: June 23-24 (2 days before start) - * - * Test end dates where NEW booking's trail overlaps with blocker's LEAD: - * - Select June 13 as start, hover June 21 as end - * - Trail period: June 22-24 (3 days after June 21) - * - June 23-24 overlap with blocker LEAD (June 23-24) → DISABLED - * - * This is the KEY enhancement: respecting existing booking's lead period! - */ - - // Select June 13 as start date (lead June 11-12, both clear) - getDateByISO("2026-06-13").click(); + // Select June 13 as start (lead June 11-12, both clear) + clickDateByISO("2026-06-13"); - // Hover June 21 as potential end date - // Trail period: June 22-24, Blocker LEAD: June 23-24 - // Overlap at June 23-24 → should have trailDisable - getDateByISO("2026-06-21").trigger("mouseover"); - getDateByISO("2026-06-21").should("have.class", "trailDisable"); + // Hover June 21 - Trail (June 22-24) overlaps blocker LEAD (June 23-24) → DISABLED + hoverDateByISO("2026-06-21"); + getDateByISO("2026-06-21").should( + "have.class", + "flatpickr-disabled" + ); - // Also test June 20 - trail June 21-23, June 23 overlaps blocker lead - getDateByISO("2026-06-20").trigger("mouseover"); - getDateByISO("2026-06-20").should("have.class", "trailDisable"); + // June 20 - Trail (June 21-23), June 23 overlaps blocker lead → DISABLED + hoverDateByISO("2026-06-20"); + getDateByISO("2026-06-20").should( + "have.class", + "flatpickr-disabled" + ); - // Verify June 19 is clear - trail June 20-22, doesn't reach blocker lead (starts June 23) - getDateByISO("2026-06-19").trigger("mouseover"); - getDateByISO("2026-06-19").should("not.have.class", "trailDisable"); + // June 19 - Trail (June 20-22) doesn't reach blocker lead (starts June 23) → NOT disabled + hoverDateByISO("2026-06-19"); + getDateByISO("2026-06-19").should( + "not.have.class", + "flatpickr-disabled" + ); // Clear selection for next phase - cy.get("#period").clearFlatpickr(); + cy.get("#booking_period").clearFlatpickr(); cy.get("@fp").openFlatpickr(); // ======================================================================== // PHASE 5: Max Date Selectable When Trail is Clear // ======================================================================== - /** - * Select June 13 as start date (lead June 11-12, both clear) - * Max end date by circulation rules: June 20 (13 + 7 days) - * But June 20's trail period (June 21-23) overlaps blocker's lead (June 23-24) at June 23 - * So June 20 WILL have trailDisable - * - * June 19's trail period (June 20-22) is clear of blocker's lead (June 23-24) - * So June 19 should be selectable (no trailDisable) - */ - // Select June 13 as start date - getDateByISO("2026-06-13").click(); - - // First, verify June 20 HAS trailDisable (trail June 21-23 overlaps blocker lead June 23-24) - getDateByISO("2026-06-20").trigger("mouseover"); - getDateByISO("2026-06-20").should("have.class", "trailDisable"); + clickDateByISO("2026-06-13"); - // June 19 should NOT have trailDisable (trail June 20-22 is clear of blocker lead) - getDateByISO("2026-06-19").trigger("mouseover"); - getDateByISO("2026-06-19").should("not.have.class", "trailDisable"); + // June 20: trail (June 21-23) overlaps blocker lead (June 23-24) → DISABLED + hoverDateByISO("2026-06-20"); + getDateByISO("2026-06-20").should( + "have.class", + "flatpickr-disabled" + ); - // June 19 should not be disabled by flatpickr + // June 19: trail (June 20-22) clear of blocker lead → NOT disabled + hoverDateByISO("2026-06-19"); getDateByISO("2026-06-19").should( "not.have.class", "flatpickr-disabled" ); // Actually select June 19 to confirm booking can be made - getDateByISO("2026-06-19").click(); + clickDateByISO("2026-06-19"); // Verify dates were accepted in the form - cy.get("#booking_start_date").should("not.have.value", ""); - cy.get("#booking_end_date").should("not.have.value", ""); + cy.get("#booking_period").should("not.have.value", ""); cy.log( "✓ CONFIRMED: Lead/trail period behavior with bidirectional conflict detection working correctly" ); }); - it("should show event dots for dates with existing bookings", () => { + it("should show booking marker dots for dates with existing bookings", () => { /** - * Comprehensive Event Dots Visual Indicator Test - * ============================================== - * - * This test validates the visual booking indicators (event dots) displayed on calendar dates - * to show users which dates already have existing bookings. - * - * Test Coverage: - * 1. Single booking event dots (one dot per date) - * 2. Multiple bookings on same date (multiple dots) - * 3. Dates without bookings (no dots) - * 4. Item-specific dot styling with correct CSS classes - * 5. Event dot container structure and attributes - * - * EVENT DOTS FUNCTIONALITY: - * ========================= - * - * Algorithm Overview: - * 1. Bookings array is processed into bookingsByDate hash (date -> [item_ids]) - * 2. onDayCreate hook checks bookingsByDate[dateString] for each calendar day - * 3. If bookings exist, creates .event-dots container with .event.item_{id} children - * 4. Sets data attributes for booking metadata and item-specific information - * - * Visual Structure: - * - *
- *
- *
- *
- *
- * - * Event Dot Test Layout: - * ====================== - * Day: 5 6 7 8 9 10 11 12 13 14 15 16 17 - * Booking: MM O O O O S S S O O T O O - * Dots: •• - - - - • • • - - • - - + * Booking Marker Dots Visual Indicator Test + * ========================================== * - * Legend: MM = Multiple bookings (items 301+302), S = Single booking (item 303), - * T = Test booking (item 301), O = Available, - = No dots, • = Event dot + * Vue version uses .booking-marker-grid with .booking-marker-dot children + * instead of the jQuery .event-dots / .event pattern. */ const today = dayjs().startOf("day"); - // Set up circulation rules for event dots testing - const eventDotsCirculationRules = { - bookings_lead_period: 1, // Minimal to avoid conflicts + // Set up circulation rules for marker testing + const markerCirculationRules = { + bookings_lead_period: 1, bookings_trail_period: 1, issuelength: 7, renewalsallowed: 1, @@ -1081,32 +893,32 @@ describe("Booking Modal Date Picker Tests", () => { }; cy.intercept("GET", "/api/v1/circulation_rules*", { - body: [eventDotsCirculationRules], - }).as("getEventDotsRules"); + body: [markerCirculationRules], + }).as("getMarkerRules"); - // Create strategic bookings for event dots testing + // Create strategic bookings for marker testing const testBookings = [ - // Multiple bookings on same dates (Days 5-6): Items 301 + 302 + // Multiple bookings on same dates (Days 5-6): Items 0 + 1 { - item_id: testData.items[0].item_id, // Will be item 301 equivalent + item_id: testData.items[0].item_id, start: today.add(5, "day"), end: today.add(6, "day"), name: "Multi-booking 1", }, { - item_id: testData.items[1].item_id, // Will be item 302 equivalent + item_id: testData.items[1].item_id, start: today.add(5, "day"), end: today.add(6, "day"), name: "Multi-booking 2", }, - // Single booking spanning multiple days (Days 10-12): Item 303 + // Single booking spanning multiple days (Days 10-12): Item 0 { - item_id: testData.items[0].item_id, // Reuse first item + item_id: testData.items[0].item_id, start: today.add(10, "day"), end: today.add(12, "day"), name: "Single span booking", }, - // Isolated single booking (Day 15): Item 301 + // Isolated single booking (Day 15): Item 0 { item_id: testData.items[0].item_id, start: today.add(15, "day"), @@ -1133,20 +945,19 @@ describe("Booking Modal Date Picker Tests", () => { setupModalForDateTesting({ skipItemSelection: true }); - // Select item to trigger event dots loading - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item - cy.wait("@getEventDotsRules"); + // Select item to trigger marker loading + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 1); // Select first actual item + cy.wait("@getMarkerRules"); - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("eventDotsFlatpickr"); - cy.get("@eventDotsFlatpickr").openFlatpickr(); + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("markerFlatpickr"); + cy.get("@markerFlatpickr").openFlatpickr(); // ======================================================================== - // TEST 1: Single Booking Event Dots (Days 10, 11, 12) + // TEST 1: Single Booking Marker Dots (Days 10, 11, 12) // ======================================================================== - // Days 10-12 have single booking from same item - should create one event dot each const singleDotDates = [ today.add(10, "day"), today.add(11, "day"), @@ -1158,15 +969,15 @@ describe("Booking Modal Date Picker Tests", () => { date.month() === today.month() || date.month() === today.add(1, "month").month() ) { - cy.get("@eventDotsFlatpickr") + cy.get("@markerFlatpickr") .getFlatpickrDate(date.toDate()) .within(() => { - cy.get(".event-dots") + cy.get(".booking-marker-grid") .should("exist") .and("have.length", 1); - cy.get(".event-dots .event") + cy.get(".booking-marker-grid .booking-marker-dot") .should("exist") - .and("have.length", 1); + .and("have.length.at.least", 1); }); } }); @@ -1175,7 +986,6 @@ describe("Booking Modal Date Picker Tests", () => { // TEST 2: Multiple Bookings on Same Date (Days 5-6) // ======================================================================== - // Days 5-6 have TWO different bookings (different items) - should create two dots const multipleDotDates = [today.add(5, "day"), today.add(6, "day")]; multipleDotDates.forEach(date => { @@ -1183,20 +993,23 @@ describe("Booking Modal Date Picker Tests", () => { date.month() === today.month() || date.month() === today.add(1, "month").month() ) { - cy.get("@eventDotsFlatpickr") + cy.get("@markerFlatpickr") .getFlatpickrDate(date.toDate()) .within(() => { - cy.get(".event-dots").should("exist"); - cy.get(".event-dots .event").should("have.length", 2); + cy.get(".booking-marker-grid").should("exist"); + // Dots are aggregated by type (booked/checked-out), not per-booking. + // 2 bookings of type "booked" = 1 dot with count 2. + cy.get( + ".booking-marker-grid .booking-marker-dot" + ).should("have.length.at.least", 1); }); } }); // ======================================================================== - // TEST 3: Dates Without Bookings (No Event Dots) + // TEST 3: Dates Without Bookings (No Marker Dots) // ======================================================================== - // Dates without bookings should have no .event-dots container const emptyDates = [ today.add(3, "day"), // Before any bookings today.add(8, "day"), // Between booking periods @@ -1209,10 +1022,10 @@ describe("Booking Modal Date Picker Tests", () => { date.month() === today.month() || date.month() === today.add(1, "month").month() ) { - cy.get("@eventDotsFlatpickr") + cy.get("@markerFlatpickr") .getFlatpickrDate(date.toDate()) .within(() => { - cy.get(".event-dots").should("not.exist"); + cy.get(".booking-marker-grid").should("not.exist"); }); } }); @@ -1221,34 +1034,35 @@ describe("Booking Modal Date Picker Tests", () => { // TEST 4: Isolated Single Booking (Day 15) - Boundary Detection // ======================================================================== - // Day 15 has booking (should have dot), adjacent days 14 and 16 don't (no dots) const isolatedBookingDate = today.add(15, "day"); if ( isolatedBookingDate.month() === today.month() || isolatedBookingDate.month() === today.add(1, "month").month() ) { - // Verify isolated booking day HAS dot - cy.get("@eventDotsFlatpickr") + // Verify isolated booking day HAS marker dot + cy.get("@markerFlatpickr") .getFlatpickrDate(isolatedBookingDate.toDate()) .within(() => { - cy.get(".event-dots").should("exist"); - cy.get(".event-dots .event") + cy.get(".booking-marker-grid").should("exist"); + cy.get(".booking-marker-grid .booking-marker-dot") .should("exist") - .and("have.length", 1); + .and("have.length.at.least", 1); }); - // Verify adjacent dates DON'T have dots + // Verify adjacent dates DON'T have marker dots [today.add(14, "day"), today.add(16, "day")].forEach( adjacentDate => { if ( adjacentDate.month() === today.month() || adjacentDate.month() === today.add(1, "month").month() ) { - cy.get("@eventDotsFlatpickr") + cy.get("@markerFlatpickr") .getFlatpickrDate(adjacentDate.toDate()) .within(() => { - cy.get(".event-dots").should("not.exist"); + cy.get(".booking-marker-grid").should( + "not.exist" + ); }); } } @@ -1256,7 +1070,7 @@ describe("Booking Modal Date Picker Tests", () => { } cy.log( - "✓ CONFIRMED: Event dots display correctly (single, multiple, empty dates, boundaries)" + "✓ CONFIRMED: Booking marker dots display correctly (single, multiple, empty dates, boundaries)" ); }); @@ -1266,16 +1080,9 @@ describe("Booking Modal Date Picker Tests", () => { * * Key principle: Once an item is removed from the pool (becomes unavailable), * it is NEVER re-added even if it becomes available again later. - * - * Booking pattern: - * - ITEM 0: Booked days 10-15 - * - ITEM 1: Booked days 13-20 - * - ITEM 2: Booked days 18-25 - * - ITEM 3: Booked days 1-7, then 23-30 */ // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London - // Using ["Date"] to avoid freezing timers which breaks Select2 async operations const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1) cy.clock(fixedToday, ["Date"]); const today = dayjs(fixedToday); @@ -1299,18 +1106,19 @@ describe("Booking Modal Date Picker Tests", () => { testBiblio = objects.biblio; testItems = objects.items; - const itemUpdates = testItems.map((item, index) => { + let chain = cy.wrap(null); + testItems.forEach((item, index) => { const enumchron = String.fromCharCode(65 + index); - return cy.task("query", { + chain = chain.then(() => cy.task("query", { sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?", values: [ enumchron, `2024-12-0${4 - index}`, item.item_id, ], - }); + })); }); - return Promise.all(itemUpdates); + return chain; }) .then(() => { return cy.task("buildSampleObject", { @@ -1341,80 +1149,31 @@ describe("Booking Modal Date Picker Tests", () => { }); }) .then(() => { - // Create strategic bookings - const bookingInserts = [ - // ITEM 0: Booked 10-15 - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[0].item_id, - "CPL", - today.add(10, "day").format("YYYY-MM-DD"), - today.add(15, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 1: Booked 13-20 - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[1].item_id, - "CPL", - today.add(13, "day").format("YYYY-MM-DD"), - today.add(20, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 2: Booked 18-25 - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[2].item_id, - "CPL", - today.add(18, "day").format("YYYY-MM-DD"), - today.add(25, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 3: Booked 1-7 - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[3].item_id, - "CPL", - today.add(1, "day").format("YYYY-MM-DD"), - today.add(7, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 3: Booked 23-30 - cy.task("query", { + // Create strategic bookings sequentially + const bookings = [ + { item: testItems[0], start: 10, end: 15 }, // ITEM 0 + { item: testItems[1], start: 13, end: 20 }, // ITEM 1 + { item: testItems[2], start: 18, end: 25 }, // ITEM 2 + { item: testItems[3], start: 1, end: 7 }, // ITEM 3 + { item: testItems[3], start: 23, end: 30 }, // ITEM 3 + ]; + let chain = cy.wrap(null); + bookings.forEach(b => { + chain = chain.then(() => cy.task("query", { sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) VALUES (?, ?, ?, ?, ?, ?, ?)`, values: [ testBiblio.biblio_id, testPatron.patron_id, - testItems[3].item_id, + b.item.item_id, "CPL", - today.add(23, "day").format("YYYY-MM-DD"), - today.add(30, "day").format("YYYY-MM-DD"), + today.add(b.start, "day").format("YYYY-MM-DD"), + today.add(b.end, "day").format("YYYY-MM-DD"), "new", ], - }), - ]; - return Promise.all(bookingInserts); + })); + }); + return chain; }) .then(() => { cy.intercept( @@ -1429,28 +1188,31 @@ describe("Booking Modal Date Picker Tests", () => { `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}` ); - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { + timeout: 10000, + }).should("be.visible"); - cy.selectFromSelect2( - "#booking_patron_id", - `${testPatron.surname}, ${testPatron.firstname}`, - testPatron.cardnumber + cy.vueSelect( + "booking_patron", + testPatron.cardnumber, + `${testPatron.surname} ${testPatron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_itemtype", 0); + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectByIndex("booking_itemtype", 0); cy.wait("@getCirculationRules"); - cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item" - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("flatpickrInput"); + // "Any item" = no item selected (null) = leave dropdown at placeholder + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("flatpickrInput"); - // Helper to check date availability - checks boundaries + random middle date + // Helper to check date availability const checkDatesAvailable = (fromDay, toDay) => { const daysToCheck = [fromDay, toDay]; if (toDay - fromDay > 1) { @@ -1484,41 +1246,33 @@ describe("Booking Modal Date Picker Tests", () => { }; // SCENARIO 1: Start day 5 - // Pool starts: ITEM0, ITEM1, ITEM2 (ITEM3 booked 1-7) - // Day 10: lose ITEM0, Day 13: lose ITEM1, Day 18: lose ITEM2 → disabled cy.log("=== Scenario 1: Start day 5 ==="); cy.get("@flatpickrInput").openFlatpickr(); cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(5, "day").toDate()) - .click(); + .selectFlatpickrDate(today.add(5, "day").toDate()); - checkDatesAvailable(6, 17); // Available through day 17 - checkDatesDisabled(18, 20); // Disabled from day 18 + checkDatesAvailable(6, 17); + checkDatesDisabled(18, 20); // SCENARIO 2: Start day 8 - // Pool starts: ALL 4 items (ITEM3 booking 1-7 ended) - // Progressive reduction until day 23 when ITEM3's second booking starts - cy.log("=== Scenario 2: Start day 8 (all items available) ==="); + cy.log( + "=== Scenario 2: Start day 8 (all items available) ===" + ); cy.get("@flatpickrInput").clearFlatpickr(); cy.get("@flatpickrInput").openFlatpickr(); cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(8, "day").toDate()) - .click(); + .selectFlatpickrDate(today.add(8, "day").toDate()); - checkDatesAvailable(9, 22); // Can book through day 22 - checkDatesDisabled(23, 25); // Disabled from day 23 + checkDatesAvailable(9, 22); + checkDatesDisabled(23, 25); // SCENARIO 3: Start day 19 - // Pool starts: ITEM0 (booking ended day 15), ITEM3 - // ITEM0 stays available indefinitely, ITEM3 loses at day 23 cy.log("=== Scenario 3: Start day 19 ==="); cy.get("@flatpickrInput").clearFlatpickr(); cy.get("@flatpickrInput").openFlatpickr(); cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(19, "day").toDate()) - .click(); + .selectFlatpickrDate(today.add(19, "day").toDate()); - // ITEM0 remains in pool, so dates stay available past day 23 checkDatesAvailable(20, 25); }); @@ -1546,43 +1300,9 @@ describe("Booking Modal Date Picker Tests", () => { it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => { /** * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings - * ======================================================================== - * - * This test validates that lead/trail period conflict detection works correctly - * when "any item of itemtype X" is selected. The key principle is: - * - * - Only block date selection when ALL items of the itemtype have conflicts - * - Allow selection when at least one item is free from lead/trail conflicts - * - * The bug occurred because the mouseover handler was checking conflicts against - * ALL bookings regardless of itemtype, rather than tracking per-item conflicts. - * - * Test Setup: - * =========== - * - Fixed date: June 1, 2026 (keeps all test dates in same month) - * - 3 items of itemtype BK - * - Lead period: 2 days, Trail period: 2 days - * - ITEM 0: Booking on days 10-12 (June 11-13, trail period: June 14-15) - * - ITEM 1: Booking on days 10-12 (same as item 0) - * - ITEM 2: No bookings (always available) - * - * Test Scenarios: - * ============== - * 1. Hover day 15 (June 16): ITEM 0 and ITEM 1 have trail period conflict - * (lead period June 14-15 overlaps their trail June 14-15), but ITEM 2 is free - * → Should NOT be blocked (at least one item available) - * - * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again: - * → ALL items now have trail period conflicts - * → Should BE blocked - * - * 3. Visual feedback: Check existingBookingTrail on days 13-14 (June 14-15) - * - * 4. Visual feedback: Check existingBookingLead on days 8-9 (June 9-10) */ // Fix the browser Date object to June 1, 2026 at 09:00 Europe/London - // This ensures all test dates (days 5-17) fall within June const fixedToday = new Date("2026-06-01T08:00:00Z"); // 09:00 BST (UTC+1) cy.clock(fixedToday, ["Date"]); @@ -1609,18 +1329,19 @@ describe("Booking Modal Date Picker Tests", () => { testLibraries = objects.libraries; // Make all items the same itemtype (BK) - const itemUpdates = testItems.map((item, index) => { + let chain = cy.wrap(null); + testItems.forEach((item, index) => { const enumchron = String.fromCharCode(65 + index); - return cy.task("query", { + chain = chain.then(() => cy.task("query", { sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?", values: [ enumchron, `2024-12-0${4 - index}`, item.item_id, ], - }); + })); }); - return Promise.all(itemUpdates); + return chain; }) .then(() => { return cy.task("buildSampleObject", { @@ -1652,39 +1373,31 @@ describe("Booking Modal Date Picker Tests", () => { }) .then(() => { // Create bookings on ITEM 0 and ITEM 1 for days 10-12 - // ITEM 2 remains free - const bookingInserts = [ - // ITEM 0: Booked days 10-12 - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[0].item_id, - "CPL", - today.add(10, "day").format("YYYY-MM-DD"), - today.add(12, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 1: Booked days 10-12 (same period) - cy.task("query", { - sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - values: [ - testBiblio.biblio_id, - testPatron.patron_id, - testItems[1].item_id, - "CPL", - today.add(10, "day").format("YYYY-MM-DD"), - today.add(12, "day").format("YYYY-MM-DD"), - "new", - ], - }), - // ITEM 2: No booking - remains free - ]; - return Promise.all(bookingInserts); + return cy.task("query", { + sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + values: [ + testBiblio.biblio_id, + testPatron.patron_id, + testItems[0].item_id, + "CPL", + today.add(10, "day").format("YYYY-MM-DD"), + today.add(12, "day").format("YYYY-MM-DD"), + "new", + ], + }).then(() => cy.task("query", { + sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + values: [ + testBiblio.biblio_id, + testPatron.patron_id, + testItems[1].item_id, + "CPL", + today.add(10, "day").format("YYYY-MM-DD"), + today.add(12, "day").format("YYYY-MM-DD"), + "new", + ], + })); }) .then(() => { cy.intercept( @@ -1699,30 +1412,31 @@ describe("Booking Modal Date Picker Tests", () => { `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}` ); - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { + timeout: 10000, + }).should("be.visible"); - cy.selectFromSelect2( - "#booking_patron_id", - `${testPatron.surname}, ${testPatron.firstname}`, - testPatron.cardnumber + cy.vueSelect( + "booking_patron", + testPatron.cardnumber, + `${testPatron.surname} ${testPatron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Select itemtype BK - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.selectFromSelect2("#booking_itemtype", "Books"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectByIndex("booking_itemtype", 0); cy.wait("@getCirculationRules"); - // Select "Any item" (index 0) - cy.selectFromSelect2ByIndex("#booking_item_id", 0); - cy.get("#booking_item_id").should("have.value", "0"); + // "Any item" = no item selected (null) = leave dropdown at placeholder - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("flatpickrInput"); // ================================================================ // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked @@ -1733,24 +1447,16 @@ describe("Booking Modal Date Picker Tests", () => { cy.get("@flatpickrInput").openFlatpickr(); cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(15, "day").toDate()) - .trigger("mouseover"); + .hoverFlatpickrDate(today.add(15, "day").toDate()); - // Day 15 should NOT have leadDisable class (at least one item is free) + // Day 15 should NOT be disabled (at least one item is free) cy.get("@flatpickrInput") .getFlatpickrDate(today.add(15, "day").toDate()) - .should("not.have.class", "leadDisable"); + .should("not.have.class", "flatpickr-disabled"); // Actually click day 15 to verify it's selectable cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(15, "day").toDate()) - .should("not.have.class", "flatpickr-disabled") - .click(); - - // Verify day 15 was selected as start date - cy.get("@flatpickrInput") - .getFlatpickrDate(today.add(15, "day").toDate()) - .should("have.class", "selected"); + .selectFlatpickrDate(today.add(15, "day").toDate()); // Reset for next scenario cy.get("@flatpickrInput").clearFlatpickr(); @@ -1781,76 +1487,91 @@ describe("Booking Modal Date Picker Tests", () => { `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}` ); - cy.get('[data-bs-target="#placeBookingModal"]') - .first() - .click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]").first().then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { + timeout: 10000, + }).should("be.visible"); - cy.selectFromSelect2( - "#booking_patron_id", - `${testPatron.surname}, ${testPatron.firstname}`, - testPatron.cardnumber + cy.vueSelect( + "booking_patron", + testPatron.cardnumber, + `${testPatron.surname} ${testPatron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); // Select itemtype BK - cy.get("#booking_itemtype").should("not.be.disabled"); - cy.selectFromSelect2("#booking_itemtype", "Books"); + cy.vueSelectShouldBeEnabled("booking_itemtype"); + cy.vueSelectByIndex("booking_itemtype", 0); cy.wait("@getCirculationRules"); - // Select "Any item" (index 0) - cy.selectFromSelect2ByIndex("#booking_item_id", 0); - cy.get("#booking_item_id").should("have.value", "0"); + // "Any item" = no item selected (null) = leave dropdown at placeholder - cy.get("#period").should("not.be.disabled"); - cy.get("#period").as("flatpickrInput2"); + cy.get("#booking_period").should("not.be.disabled"); + cy.get("#booking_period").as("flatpickrInput2"); cy.get("@flatpickrInput2").openFlatpickr(); cy.get("@flatpickrInput2") - .getFlatpickrDate(today.add(15, "day").toDate()) - .trigger("mouseover"); + .hoverFlatpickrDate(today.add(15, "day").toDate()); - // Day 15 should NOW have leadDisable class (all items have conflicts) + // Day 15 should NOW be disabled (all items have conflicts) cy.get("@flatpickrInput2") .getFlatpickrDate(today.add(15, "day").toDate()) - .should("have.class", "leadDisable"); + .should("have.class", "flatpickr-disabled"); // ================================================================ - // SCENARIO 3: Visual feedback - existingBookingTrail for days 13-14 + // SCENARIO 3: Visual feedback - booking marker dots for trail period // ================================================================ cy.log( - "=== Scenario 3: Visual feedback - Trail period display ===" + "=== Scenario 3: Visual feedback - Trail period marker dots ===" ); + // Days 13-14 should have trail marker dots cy.get("@flatpickrInput2") .getFlatpickrDate(today.add(13, "day").toDate()) - .should("have.class", "existingBookingTrail"); + .within(() => { + cy.get( + ".booking-marker-grid .booking-marker-dot" + ).should("exist"); + }); cy.get("@flatpickrInput2") .getFlatpickrDate(today.add(14, "day").toDate()) - .should("have.class", "existingBookingTrail"); + .within(() => { + cy.get( + ".booking-marker-grid .booking-marker-dot" + ).should("exist"); + }); // ================================================================ - // SCENARIO 4: Visual feedback - existingBookingLead for days 8-9 + // SCENARIO 4: Visual feedback - booking marker dots for lead period // ================================================================ cy.log( - "=== Scenario 4: Visual feedback - Lead period display ===" + "=== Scenario 4: Visual feedback - Lead period marker dots ===" ); cy.get("@flatpickrInput2") - .getFlatpickrDate(today.add(5, "day").toDate()) - .trigger("mouseover"); + .hoverFlatpickrDate(today.add(5, "day").toDate()); + // Days 8-9 should have lead marker dots cy.get("@flatpickrInput2") .getFlatpickrDate(today.add(8, "day").toDate()) - .should("have.class", "existingBookingLead"); + .within(() => { + cy.get( + ".booking-marker-grid .booking-marker-dot" + ).should("exist"); + }); cy.get("@flatpickrInput2") .getFlatpickrDate(today.add(9, "day").toDate()) - .should("have.class", "existingBookingLead"); + .within(() => { + cy.get( + ".booking-marker-grid .booking-marker-dot" + ).should("exist"); + }); }); }); diff --git a/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts b/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts index 9ed5ed01823..084bb1cacd0 100644 --- a/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts +++ b/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts @@ -7,6 +7,9 @@ dayjs.extend(timezone); describe("Booking Modal Timezone Tests", () => { let testData = {}; + // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests + Cypress.on("uncaught:exception", () => false); + // Ensure RESTBasicAuth is enabled before running tests before(() => { cy.task("query", { @@ -98,72 +101,42 @@ describe("Booking Modal Timezone Tests", () => { `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}` ); - cy.get('[data-bs-target="#placeBookingModal"]').first().click(); - cy.get("#placeBookingModal").should("be.visible"); + cy.get("booking-modal-island .modal").should("exist"); + cy.get("[data-booking-modal]") + .first() + .then($btn => $btn[0].click()); + cy.get("booking-modal-island .modal", { timeout: 10000 }).should( + "be.visible" + ); - cy.selectFromSelect2( - "#booking_patron_id", - `${testData.patron.surname}, ${testData.patron.firstname}`, - testData.patron.cardnumber + cy.vueSelect( + "booking_patron", + testData.patron.cardnumber, + `${testData.patron.surname} ${testData.patron.firstname}` ); cy.wait("@getPickupLocations"); - cy.get("#pickup_library_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#pickup_library_id", 0); + cy.vueSelectShouldBeEnabled("pickup_library_id"); + cy.vueSelectByIndex("pickup_library_id", 0); - cy.get("#booking_item_id").should("not.be.disabled"); - cy.selectFromSelect2ByIndex("#booking_item_id", 1); + cy.vueSelectShouldBeEnabled("booking_item_id"); + cy.vueSelectByIndex("booking_item_id", 0); cy.wait("@getCirculationRules"); - cy.get("#period").should("not.be.disabled"); + cy.get("#booking_period").should("not.be.disabled"); }; /** * TIMEZONE TEST 1: Date Index Creation Consistency - * ================================================= - * - * This test validates the critical fix for date index creation using - * dayjs().format('YYYY-MM-DD') instead of toISOString().split('T')[0]. - * - * The Problem: - * - toISOString() converts Date to UTC, which can shift dates - * - In PST (UTC-8), midnight PST becomes 08:00 UTC - * - Splitting on 'T' gives "2024-01-15" but this is the UTC date - * - For western timezones, this causes dates to appear shifted - * - * The Fix: - * - dayjs().format('YYYY-MM-DD') maintains browser timezone - * - Dates are indexed by their local representation - * - No timezone conversion happens during indexing - * - * Test Approach: - * - Create a booking with known UTC datetime - * - Verify calendar displays booking on correct date - * - Check that bookingsByDate index uses correct date */ it("should display bookings on correct calendar dates regardless of timezone offset", () => { cy.log("=== Testing date index creation consistency ==="); const today = dayjs().startOf("day"); - /** - * Create a booking with specific UTC time that tests boundary crossing. - * - * Scenario: Booking starts at 08:00 UTC on January 15 - * - In UTC: January 15 08:00 - * - In PST (UTC-8): January 15 00:00 (midnight PST) - * - In HST (UTC-10): January 14 22:00 (10pm HST on Jan 14) - * - * The booking should display on January 15 in all timezones except HST, - * where it would show on January 14 (because 08:00 UTC = 22:00 previous day HST). - * - * However, our fix ensures dates are parsed correctly in browser timezone. - */ const bookingDate = today.add(10, "day"); - const bookingStart = bookingDate.hour(0).minute(0).second(0); // Midnight local time - const bookingEnd = bookingDate.hour(23).minute(59).second(59); // End of day local time - - // Creating booking for bookingDate in local timezone + const bookingStart = bookingDate.hour(0).minute(0).second(0); + const bookingEnd = bookingDate.hour(23).minute(59).second(59); // Create booking in database cy.task("query", { @@ -181,7 +154,7 @@ describe("Booking Modal Timezone Tests", () => { setupModal(); - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); // The date should be disabled (has existing booking) on the correct day @@ -193,11 +166,12 @@ describe("Booking Modal Timezone Tests", () => { .getFlatpickrDate(bookingDate.toDate()) .should("have.class", "flatpickr-disabled"); - // Verify event dot is present (visual indicator) + // Verify booking marker dot is present (visual indicator) + // Vue version uses .booking-marker-grid with .booking-marker-dot children cy.get("@flatpickrInput") .getFlatpickrDate(bookingDate.toDate()) .within(() => { - cy.get(".event-dots").should("exist"); + cy.get(".booking-marker-grid").should("exist"); }); // Verify adjacent dates are NOT disabled (no date shift) @@ -228,20 +202,6 @@ describe("Booking Modal Timezone Tests", () => { /** * TIMEZONE TEST 2: Multi-Day Booking Span - * ======================================== - * - * Validates that multi-day bookings span the correct number of days - * without adding extra days due to timezone conversion. - * - * The Problem: - * - When iterating dates, using toISOString() to create date keys - * could cause UTC conversion to add extra days - * - A 3-day booking in PST could appear as 4 days if boundaries cross - * - * The Fix: - * - Using dayjs().format('YYYY-MM-DD') maintains date boundaries - * - Each date increments by exactly 1 day in browser timezone - * - No extra days added from UTC conversion */ it("should correctly span multi-day bookings without timezone-induced extra days", () => { const today = dayjs().startOf("day"); @@ -265,10 +225,10 @@ describe("Booking Modal Timezone Tests", () => { setupModal(); - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); - // All three days should be disabled with event dots + // All three days should be disabled with booking marker dots const expectedDays = [ bookingStart, bookingStart.add(1, "day"), @@ -287,7 +247,7 @@ describe("Booking Modal Timezone Tests", () => { cy.get("@flatpickrInput") .getFlatpickrDate(date.toDate()) .within(() => { - cy.get(".event-dots").should("exist"); + cy.get(".booking-marker-grid").should("exist"); }); } }); @@ -321,20 +281,6 @@ describe("Booking Modal Timezone Tests", () => { /** * TIMEZONE TEST 3: Date Comparison Consistency - * ============================================= - * - * Validates that date comparisons work correctly when checking for - * booking conflicts, using normalized start-of-day comparisons. - * - * The Problem: - * - Comparing Date objects with time components is unreliable - * - Mixing flatpickr.parseDate() and direct Date comparisons - * - Time components can cause false negatives/positives - * - * The Fix: - * - All dates normalized to start-of-day using dayjs().startOf('day') - * - Consistent parsing using dayjs() for RFC3339 strings - * - Reliable date-level comparisons */ it("should correctly detect conflicts using timezone-aware date comparisons", () => { const today = dayjs().startOf("day"); @@ -358,7 +304,7 @@ describe("Booking Modal Timezone Tests", () => { setupModal(); - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); // Test: Date within existing booking should be disabled @@ -401,20 +347,9 @@ describe("Booking Modal Timezone Tests", () => { /** * TIMEZONE TEST 4: API Submission Round-Trip - * =========================================== - * - * Validates that dates selected in the browser are correctly submitted - * to the API and can be retrieved without date shifts. * - * The Flow: - * 1. User selects date in browser (e.g., January 15) - * 2. JavaScript converts to ISO string with timezone offset - * 3. API receives RFC3339 datetime, converts to server timezone - * 4. Stores in database - * 5. API retrieves, converts to RFC3339 with offset - * 6. Browser receives and displays - * - * Expected: Date should remain January 15 throughout the flow + * In the Vue version, dates are stored in the pinia store and submitted + * via API. We verify dates via the flatpickr display value and API intercept. */ it("should correctly round-trip dates through API without timezone shifts", () => { const today = dayjs().startOf("day"); @@ -427,39 +362,18 @@ describe("Booking Modal Timezone Tests", () => { cy.intercept("POST", `/api/v1/bookings`).as("createBooking"); - cy.get("#period").selectFlatpickrDateRange(startDate, endDate); - - // Verify hidden fields have ISO strings - cy.get("#booking_start_date").then($input => { - const value = $input.val(); - expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format - }); - - cy.get("#booking_end_date").then($input => { - const value = $input.val(); - expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format - }); - - // Verify dates were set in hidden fields and match selected dates - cy.get("#booking_start_date").should("not.have.value", ""); - cy.get("#booking_end_date").should("not.have.value", ""); - - cy.get("#booking_start_date").then($startInput => { - cy.get("#booking_end_date").then($endInput => { - const startValue = $startInput.val() as string; - const endValue = $endInput.val() as string; - - const submittedStart = dayjs(startValue); - const submittedEnd = dayjs(endValue); - - // Verify dates match what user selected (in browser timezone) - expect(submittedStart.format("YYYY-MM-DD")).to.equal( - startDate.format("YYYY-MM-DD") - ); - expect(submittedEnd.format("YYYY-MM-DD")).to.equal( - endDate.format("YYYY-MM-DD") - ); - }); + cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate); + + // Verify the dates were selected correctly via the flatpickr instance (format-agnostic) + cy.get("#booking_period").should($el => { + const fp = $el[0]._flatpickr; + expect(fp.selectedDates.length).to.eq(2); + expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq( + startDate.format("YYYY-MM-DD") + ); + expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq( + endDate.format("YYYY-MM-DD") + ); }); cy.log("✓ CONFIRMED: API round-trip maintains correct dates"); @@ -467,10 +381,6 @@ describe("Booking Modal Timezone Tests", () => { /** * TIMEZONE TEST 5: Cross-Month Boundary - * ====================================== - * - * Validates that bookings spanning month boundaries are handled - * correctly without timezone-induced date shifts. */ it("should correctly handle bookings that span month boundaries", () => { const today = dayjs().startOf("day"); @@ -499,7 +409,7 @@ describe("Booking Modal Timezone Tests", () => { setupModal(); - cy.get("#period").as("flatpickrInput"); + cy.get("#booking_period").as("flatpickrInput"); cy.get("@flatpickrInput").openFlatpickr(); // Test last day of first month is disabled diff --git a/t/cypress/support/e2e.js b/t/cypress/support/e2e.js index 287616198fb..7265d99c709 100644 --- a/t/cypress/support/e2e.js +++ b/t/cypress/support/e2e.js @@ -27,6 +27,7 @@ // Import Select2 helpers import "./select2"; import "./flatpickr.js"; +import "./vue-select"; // Error on JS warnings function safeToString(arg) { diff --git a/t/cypress/support/flatpickr.js b/t/cypress/support/flatpickr.js index 7cffbd83472..cb21ab81da2 100644 --- a/t/cypress/support/flatpickr.js +++ b/t/cypress/support/flatpickr.js @@ -256,10 +256,10 @@ Cypress.Commands.add( const dayjsDate = dayjs(date); return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => { - // Click the date - break chain to avoid DOM detachment + // Click the date - use native click to avoid DOM detachment from Vue re-renders cy.get(_getFlatpickrDateSelector(dayjsDate)) .should("be.visible") - .click(); + .then($el => $el[0].click()); // Re-query and validate selection based on mode return cy @@ -267,9 +267,12 @@ Cypress.Commands.add( .getFlatpickrMode() .then(mode => { if (mode === "single") { - const expectedDate = dayjsDate.format("YYYY-MM-DD"); - - cy.wrap($input).should("have.value", expectedDate); + // Validate via flatpickr instance (format-agnostic) + cy.wrap($input).should($el => { + const fp = $el[0]._flatpickr; + expect(fp.selectedDates.length).to.eq(1); + expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(dayjsDate.format("YYYY-MM-DD")); + }); cy.get(".flatpickr-calendar.open").should( "not.exist", { timeout: 5000 } @@ -319,7 +322,7 @@ Cypress.Commands.add( ); } - // Select start date - break chain to avoid DOM detachment + // Select start date - use native click to avoid DOM detachment from Vue re-renders return ensureDateIsVisible( startDayjsDate, $input, @@ -327,7 +330,7 @@ Cypress.Commands.add( ).then(() => { cy.get(_getFlatpickrDateSelector(startDayjsDate)) .should("be.visible") - .click(); + .then($el => $el[0].click()); // Wait for complex date recalculations (e.g., booking availability) to complete cy.get( @@ -351,16 +354,20 @@ Cypress.Commands.add( ).then(() => { cy.get(_getFlatpickrDateSelector(endDayjsDate)) .should("be.visible") - .click(); + .then($el => $el[0].click()); cy.get(".flatpickr-calendar.open").should( "not.exist", { timeout: 5000 } ); - // Validate final range selection - const expectedRange = `${startDayjsDate.format("YYYY-MM-DD")} to ${endDayjsDate.format("YYYY-MM-DD")}`; - cy.wrap($input).should("have.value", expectedRange); + // Validate via flatpickr instance (format-agnostic) + cy.wrap($input).should($el => { + const fp = $el[0]._flatpickr; + expect(fp.selectedDates.length).to.eq(2); + expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(startDayjsDate.format("YYYY-MM-DD")); + expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(endDayjsDate.format("YYYY-MM-DD")); + }); return cy.wrap($input); }); @@ -381,9 +388,14 @@ Cypress.Commands.add( const dayjsDate = dayjs(date); return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => { + // Use native dispatchEvent to avoid detached DOM errors from Vue re-renders cy.get(_getFlatpickrDateSelector(dayjsDate)) .should("be.visible") - .trigger("mouseover"); + .then($el => { + $el[0].dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }) + ); + }); return cy.wrap($input); }); diff --git a/t/cypress/support/vue-select.js b/t/cypress/support/vue-select.js new file mode 100644 index 00000000000..11d6553f0bf --- /dev/null +++ b/t/cypress/support/vue-select.js @@ -0,0 +1,214 @@ +// VueSelectHelpers.js - Reusable Cypress functions for vue-select dropdowns + +/** + * Helper functions for interacting with vue-select dropdown components in Cypress tests. + * + * Uses direct Vue component instance access to bypass flaky DOM event chains. + * This approach is deterministic because it sets vue-select's reactive data + * properties directly, which triggers Vue's watcher → $emit('search') → API call + * without depending on synthetic Cypress events reaching v-on handlers reliably. + * + * vue-select DOM structure: + * div.v-select (.vs--disabled when disabled) + * div.vs__dropdown-toggle + * div.vs__selected-options + * span.vs__selected (selected value display) + * input.vs__search[id=""] (search input) + * div.vs__actions + * button.vs__clear (clear button) + * ul.vs__dropdown-menu[role="listbox"] + * li.vs__dropdown-option (each option) + * li.vs__dropdown-option--highlight (focused option) + */ + +/** + * Type in a vue-select search input and pick an option by matching text. + * Uses direct Vue instance access to set the search value, bypassing + * unreliable DOM event propagation through vue-select internals. + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * @param {string} searchText - Text to type into the search input + * @param {string} selectText - Text of the option to select (partial match) + * @param {Object} [options] - Additional options + * @param {number} [options.timeout=10000] - Timeout for waiting on results + * + * @example + * cy.vueSelect("booking_patron", "Doe", "Doe John"); + */ +Cypress.Commands.add( + "vueSelect", + (inputId, searchText, selectText, options = {}) => { + const { timeout = 10000 } = options; + + // Ensure the v-select component is enabled and interactive before proceeding + cy.get(`input#${inputId}`) + .closest(".v-select") + .should("not.have.class", "vs--disabled"); + + // Set search value directly on the Vue component instance. + // This triggers vue-select's ajax mixin watcher which emits the + // @search event, calling the parent's debounced search handler. + cy.get(`input#${inputId}`) + .closest(".v-select") + .then($vs => { + const vueInstance = $vs[0].__vueParentComponent; + if (vueInstance?.proxy) { + vueInstance.proxy.open = true; + vueInstance.proxy.search = searchText; + } else { + throw new Error( + `Could not access Vue instance on v-select for #${inputId}` + ); + } + }); + + // Wait for dropdown with matching option to appear + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__dropdown-menu", { timeout }) + .should("be.visible"); + + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__dropdown-option", { timeout }) + .should("have.length.at.least", 1); + + // Click the matching option using native DOM click to avoid detached DOM issues + cy.get(`input#${inputId}`) + .closest(".v-select") + .then($vs => { + const option = Array.from( + $vs[0].querySelectorAll(".vs__dropdown-option") + ).find(el => el.textContent.includes(selectText)); + expect(option, `Option containing "${selectText}" should exist`) + .to.exist; + option.click(); + }); + + // Verify selection was made (selected text visible) + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__selected") + .should("exist"); + } +); + +/** + * Pick a vue-select option by its 0-based index in the dropdown. + * Opens the dropdown via the Vue instance then clicks the option by index. + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * @param {number} index - 0-based index of the option to select + * @param {Object} [options] - Additional options + * @param {number} [options.timeout=10000] - Timeout for waiting on results + * + * @example + * cy.vueSelectByIndex("pickup_library_id", 0); + */ +Cypress.Commands.add("vueSelectByIndex", (inputId, index, options = {}) => { + const { timeout = 10000 } = options; + + // Ensure the v-select component is enabled before interacting + cy.get(`input#${inputId}`) + .closest(".v-select") + .should("not.have.class", "vs--disabled"); + + // Open the dropdown via Vue instance for deterministic behavior + cy.get(`input#${inputId}`) + .closest(".v-select") + .then($vs => { + const vueInstance = $vs[0].__vueParentComponent; + if (vueInstance?.proxy) { + vueInstance.proxy.open = true; + } else { + // Fallback to click if Vue instance not accessible + $vs[0].querySelector(`#${inputId}`)?.click(); + } + }); + + // Wait for dropdown and enough options to exist + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__dropdown-menu", { timeout }) + .should("be.visible"); + + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__dropdown-option", { timeout }) + .should("have.length.at.least", index + 1); + + // Click the option at the given index using native DOM click + cy.get(`input#${inputId}`) + .closest(".v-select") + .then($vs => { + const options = $vs[0].querySelectorAll(".vs__dropdown-option"); + options[index].click(); + }); +}); + +/** + * Clear the current selection in a vue-select dropdown. + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * + * @example + * cy.vueSelectClear("booking_itemtype"); + */ +Cypress.Commands.add("vueSelectClear", inputId => { + cy.get(`input#${inputId}`) + .closest(".v-select") + .then($vs => { + const clearBtn = $vs[0].querySelector(".vs__clear"); + if (clearBtn) { + clearBtn.click(); + } + }); +}); + +/** + * Assert that a vue-select displays a specific selected value text. + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * @param {string} text - Expected display text of the selected value + * + * @example + * cy.vueSelectShouldHaveValue("booking_itemtype", "Books"); + */ +Cypress.Commands.add( + "vueSelectShouldHaveValue", + (inputId, text, options = {}) => { + const { timeout = 10000 } = options; + cy.get(`input#${inputId}`) + .closest(".v-select") + .find(".vs__selected", { timeout }) + .should("contain.text", text); + } +); + +/** + * Assert that a vue-select dropdown is disabled. + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * + * @example + * cy.vueSelectShouldBeDisabled("pickup_library_id"); + */ +Cypress.Commands.add("vueSelectShouldBeDisabled", inputId => { + cy.get(`input#${inputId}`) + .closest(".v-select") + .should("have.class", "vs--disabled"); +}); + +/** + * Assert that a vue-select dropdown is enabled (not disabled). + * + * @param {string} inputId - The ID of the vue-select search input (without #) + * + * @example + * cy.vueSelectShouldBeEnabled("booking_patron"); + */ +Cypress.Commands.add("vueSelectShouldBeEnabled", inputId => { + cy.get(`input#${inputId}`) + .closest(".v-select") + .should("not.have.class", "vs--disabled"); +}); -- 2.53.0