From e15b6d5b5d7df757d7ef8e5e5e6cb63fe1d7a983 Mon Sep 17 00:00:00 2001 From: Paul Derscheid Date: Wed, 17 Sep 2025 18:21:47 +0200 Subject: [PATCH] Bug 41129: [DO NOT PUSH] Add BookingModal.vue, remove place_booking.{inc,js} - Migration to Vue - Pinia store integration - Flatpickr calendar integration with constraint highlighting - Multi-step booking form with validation - Availability checking - API adapter pattern (prep for full APIClient migration) Technical notes: The booking adapters currently use fetch() directly but have been structured to facilitate migration to APIClient pattern. Full APIClient migration requires: - BiblioAPIClient for bookable items/pickup locations - CheckoutAPIClient for checkout queries - CirculationRulesAPIClient for rules endpoint - PatronAPIClient.search() method enhancement This work should be completed as a team effort to ensure consistency across the codebase. Test plan: STAFF INTERFACE - Creating Bookings Preparation: - Run yarn js:build to emit the bundles 1. Navigate to a biblio detail page with bookable items 2. Click "Place booking" button 3. Observe the Vue booking modal opens Step 1 - Patron Selection: 4. Type patron name/cardnumber in search field 5. Verify typeahead search returns results 6. Select a patron 7. Verify patron is selected and displays correctly Step 2 - Booking Details: 8. Verify item type dropdown is populated 9. Select an item type 10. Verify pickup location dropdown is populated 11. Select a pickup location 12. If multiple bookable items exist, verify item dropdown appears 13. Select specific item (if applicable) Step 3 - Booking Period: 14. Verify flatpickr calendar opens 15. Verify calendar shows existing bookings, checkouts, and available dates 16. Select a date range 17. Verify date range validation works 18. Verify lead period/trail period restrictions are enforced 19. Verify maximum booking period is enforced 20. Verify constraint highlighting updates correctly when changing item type, specific item, or pickup location Validation Testing: 21. Try to submit without patron - verify error message 22. Try to submit without item type - verify error message 23. Try to submit without pickup location - verify error message 24. Try to submit without date range - verify error message 25. Try to select dates outside allowed range - verify validation Capacity Testing: 26. For item type with limited bookable items, verify capacity warnings 27. Verify zero-capacity items show appropriate message Submitting Bookings: 28. Complete all required fields 29. Click "Place booking" 30. Verify booking is created successfully 31. Verify modal closes 32. Verify booking appears in bookings list STAFF INTERFACE - Editing Bookings 33. Click "Edit" on an existing booking 34. Verify modal opens with pre-filled data (patron, item type, pickup location, date range) 35. Modify the date range 36. Click "Update booking" 37. Verify changes are saved 38. Verify updated booking reflects changes Calendar Navigation: 39. In date picker, navigate between months 40. Verify highlighting persists across navigation 41. Verify availability data loads correctly for new months Error Handling: 42. Disconnect network 43. Try to create a booking 44. Verify appropriate error message displays 45. Restore network and verify subsequent booking attempts work Store State Management: 46. Open booking modal and fill in some fields but don't submit 47. Close modal and re-open it 48. Verify modal state is reset (no data persists inappropriately) Browser Console: 49. Throughout all tests, verify no JavaScript errors in console 50. Verify no warnings about deprecated code Build Process: 51. Run yarn js:build{,:prod} 52. Verify build completes without errors 53. Verify dist files are generated correctly Additional Regression Testing: 54. Test with various system preferences configurations 55. Test with multiple bookable items per biblio 56. Test with various circulation rules configurations 57. Test with different patron categories 58. Test booking constraints at item level vs itemtype level --- .../prog/en/includes/cat-toolbar.inc | 9 +- .../includes/modals/booking/button-edit.inc | 3 + .../includes/modals/booking/button-place.inc | 3 + .../en/includes/modals/booking/island.inc | 92 ++ .../prog/en/includes/modals/place_booking.inc | 66 - .../prog/en/modules/bookings/list.tt | 36 +- .../prog/en/modules/catalogue/ISBDdetail.tt | 1 - .../prog/en/modules/catalogue/MARCdetail.tt | 1 - .../prog/en/modules/catalogue/detail.tt | 1 - .../prog/en/modules/catalogue/imageviewer.tt | 1 - .../en/modules/catalogue/labeledMARCdetail.tt | 1 - .../prog/en/modules/catalogue/moredetail.tt | 1 - .../prog/js/modals/place_booking.js | 1312 ---------------- .../js/vue/components/Bookings/.eslintrc.json | 42 + .../Bookings/BookingDetailsStep.vue | 268 ++++ .../vue/components/Bookings/BookingModal.vue | 1091 +++++++++++++ .../components/Bookings/BookingPatronStep.vue | 78 + .../components/Bookings/BookingPeriodStep.vue | 332 ++++ .../components/Bookings/BookingTooltip.vue | 92 ++ .../Bookings/PatronSearchSelect.vue | 110 ++ .../Bookings/composables/useAvailability.mjs | 93 ++ .../composables/useBookingValidation.mjs | 84 + .../Bookings/composables/useCapacityGuard.mjs | 155 ++ .../composables/useConstraintHighlighting.mjs | 32 + .../Bookings/composables/useDefaultPickup.mjs | 64 + .../composables/useDerivedItemType.mjs | 48 + .../Bookings/composables/useErrorState.mjs | 30 + .../Bookings/composables/useFlatpickr.mjs | 277 ++++ .../Bookings/composables/useRulesFetcher.mjs | 84 + .../Bookings/lib/adapters/api/opac.js | 216 +++ .../lib/adapters/api/staff-interface.js | 386 +++++ .../Bookings/lib/adapters/calendar.mjs | 726 +++++++++ .../lib/adapters/external-dependents.mjs | 242 +++ .../components/Bookings/lib/adapters/form.mjs | 17 + .../Bookings/lib/adapters/globals.mjs | 27 + .../Bookings/lib/adapters/modal-scroll.mjs | 36 + .../Bookings/lib/adapters/patron.mjs | 77 + .../lib/booking/algorithms/interval-tree.mjs | 610 ++++++++ .../algorithms/sweep-line-processor.mjs | 401 +++++ .../Bookings/lib/booking/constants.mjs | 28 + .../Bookings/lib/booking/date-utils.mjs | 42 + .../Bookings/lib/booking/id-utils.mjs | 39 + .../Bookings/lib/booking/logger.mjs | 279 ++++ .../Bookings/lib/booking/manager.mjs | 1355 +++++++++++++++++ .../Bookings/lib/booking/strategies.mjs | 245 +++ .../lib/booking/validation-messages.js | 69 + .../Bookings/lib/booking/validation.mjs | 110 ++ .../Bookings/lib/ui/marker-labels.mjs | 11 + .../Bookings/lib/ui/selection-message.mjs | 34 + .../vue/components/Bookings/lib/ui/steps.mjs | 58 + .../js/vue/components/Bookings/tsconfig.json | 30 + .../components/Bookings/types/bookings.d.ts | 286 ++++ .../Bookings/types/dayjs-plugins.d.ts | 13 + .../types/flatpickr-augmentations.d.ts | 17 + .../prog/js/vue/components/KohaAlert.vue | 40 + .../prog/js/vue/modules/islands.ts | 17 + .../prog/js/vue/stores/bookings.js | 239 +++ .../prog/js/vue/utils/apiErrors.js | 138 ++ .../intranet-tmpl/prog/js/vue/utils/dayjs.mjs | 28 + .../prog/js/vue/utils/validationErrors.js | 69 + rspack.config.js | 90 ++ 61 files changed, 8975 insertions(+), 1407 deletions(-) create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-edit.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-place.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc delete mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc delete mode 100644 koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/.eslintrc.json create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDefaultPickup.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDerivedItemType.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useErrorState.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/form.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/date-utils.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/manager.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/dayjs-plugins.d.ts create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/flatpickr-augmentations.d.ts create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/utils/apiErrors.js create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/utils/dayjs.mjs create mode 100644 koha-tmpl/intranet-tmpl/prog/js/vue/utils/validationErrors.js diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc index 7b4ac6920fb..f74bffe819a 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc @@ -281,10 +281,9 @@ [% END %] [% END %] [% END %] - [% IF ( Koha.Preference('EnableBooking') && CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %] -
+ [% IF ( Koha.Preference('EnableBooking') && CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %] + [% INCLUDE 'modals/booking/button-place.inc' %] + [% INCLUDE 'modals/booking/island.inc' %] [% END %] [% IF Koha.Preference('ArticleRequests') %] @@ -340,5 +339,3 @@ - -[% INCLUDE modals/place_booking.inc %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-edit.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-edit.inc new file mode 100644 index 00000000000..dd930c8b654 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-edit.inc @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-place.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-place.inc new file mode 100644 index 00000000000..f5609a17c42 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-place.inc @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc new file mode 100644 index 00000000000..2403f811259 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc @@ -0,0 +1,92 @@ +[% USE Koha %] + +
+ +
+[% SET islands = Asset.js("js/vue/dist/islands.esm.js").match('(src="([^"]+)")').1 %] + 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 3a583d03bee..506dccf758e 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt @@ -63,13 +63,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/BookingModal.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue new file mode 100644 index 00000000000..50dc0b78ec1 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue @@ -0,0 +1,1091 @@ + + + + + + + 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..c175fb66518 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue @@ -0,0 +1,78 @@ + + + + + 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..d368d9dfa78 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue @@ -0,0 +1,332 @@ + + + + + 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..cbef132a897 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue @@ -0,0 +1,92 @@ + + + + + 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..ff86cbc1cf7 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue @@ -0,0 +1,110 @@ + + + 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..31be74a6f9a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs @@ -0,0 +1,93 @@ +import { computed } from "vue"; +import { isoArrayToDates } from "../lib/booking/date-utils.mjs"; +import { + calculateDisabledDates, + toEffectiveRules, +} from "../lib/booking/manager.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 + * }} 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, + } = 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 = {}; + if (optionsRef && optionsRef.value) { + const { visibleStartDate, visibleEndDate } = optionsRef.value; + if (visibleStartDate && visibleEndDate) { + calcOptions = { + onDemand: true, + visibleStartDate, + 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..9a15578d3f5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs @@ -0,0 +1,84 @@ +/** + * 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, + validateDateSelection, +} from "../lib/booking/validation.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) { + // Extract reactive refs from store + const { + bookingPatron, + pickupLibraryId, + bookingItemtypeId, + itemTypes, + bookingItemId, + bookableItems, + selectedDateRange, + bookings, + checkouts, + circulationRules, + bookingId, + } = storeToRefs(store); + + // Computed property for step 3 validation + 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, + }); + }); + + // Computed property for form submission validation + 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); + }); + + // Method for validating date selections + const validateDates = selectedDates => { + return validateDateSelection( + 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..e40a7226af2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs @@ -0,0 +1,155 @@ +import { computed } from "vue"; +import { $__ } from "../../../i18n/index.js"; + +/** + * 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, + bookingPatron, + bookingItemId, + bookingItemtypeId, + pickupLibraryId, + 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; + + // Backend-calculated period (end_date_only mode) if present + const calculatedDays = + rules.calculated_period_days != null + ? Number(rules.calculated_period_days) || 0 + : null; + + // Respect explicit constraint if provided + if (dateRangeConstraint === "issuelength") return issuelength > 0; + if (dateRangeConstraint === "issuelength_with_renewals") + return withRenewals > 0; + + // Fallback logic: if backend provided a period, use it; otherwise consider both forms + if (calculatedDays != null) return calculatedDays > 0; + return issuelength > 0 || withRenewals > 0; + }); + + // Tailored error message based on rule values and available inputs + 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 rule explicitly set to zero, it's a circulation policy issue + 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 null, no specific rule exists - suggest trying different options + 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); + } + } + + // Fallback for other edge cases + 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." + ); + }); + + // Compute when to show the global capacity banner + 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; + + // Only show warning when we have complete context for circulation rules. + // Use the stored context from the last API request rather than inferring from UI state. + // Complete context means all three components were provided: patron_category, item_type, library. + const context = circulationRulesContext.value; + const hasCompleteContext = + context && + context.patron_category_id != null && + context.item_type_id != null && + context.library_id != null; + + // Only show warning after we have the most specific circulation rule (not while loading) + // and all required context is present + 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..b023c239c8c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs @@ -0,0 +1,32 @@ +import { computed } from "vue"; +import dayjs from "../../../utils/dayjs.mjs"; +import { + toEffectiveRules, + calculateConstraintHighlighting, +} from "../lib/booking/manager.mjs"; + +/** + * Provides reactive constraint highlighting data for the calendar based on + * selected start date, circulation rules, and constraint options. + * + * @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); + return calculateConstraintHighlighting( + dayjs(startISO).toDate(), + effectiveRules, + opts + ); + }); + + return { highlightingData }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDefaultPickup.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDefaultPickup.mjs new file mode 100644 index 00000000000..fee5eb5951f --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDefaultPickup.mjs @@ -0,0 +1,64 @@ +import { watch } from "vue"; +import { idsEqual } from "../lib/booking/id-utils.mjs"; + +/** + * Sets a sensible default pickup library when none is selected. + * Preference order: + * - OPAC default when enabled and valid + * - Patron's home library if available at pickup locations + * - First bookable item's home library if available at pickup locations + * + * @param {import('../types/bookings').DefaultPickupOptions} options + * @returns {{ stop: import('vue').WatchStopHandle }} + */ +export function useDefaultPickup(options) { + const { + bookingPickupLibraryId, // ref + bookingPatron, // ref + pickupLocations, // ref(Array) + bookableItems, // ref(Array) + opacDefaultBookingLibraryEnabled, // prop value + opacDefaultBookingLibrary, // prop value + } = options; + + const stop = watch( + [() => bookingPatron.value, () => pickupLocations.value], + ([patron, locations]) => { + if (bookingPickupLibraryId.value) return; + const list = Array.isArray(locations) ? locations : []; + + // 1) OPAC default override + 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; + } + + // 2) Patron library + if (patron && list.length > 0) { + const patronLib = patron.library_id; + if (list.some(l => idsEqual(l.library_id, patronLib))) { + bookingPickupLibraryId.value = patronLib; + return; + } + } + + // 3) First item's home library + 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 } + ); + + return { stop }; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDerivedItemType.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDerivedItemType.mjs new file mode 100644 index 00000000000..177745b57e6 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDerivedItemType.mjs @@ -0,0 +1,48 @@ +import { watch } from "vue"; +import { idsEqual } from "../lib/booking/id-utils.mjs"; + +/** + * Auto-derive item type: prefer a single constrained type; otherwise infer + * from currently selected item. + * + * @param {import('../types/bookings').DerivedItemTypeOptions} options + * @returns {import('vue').WatchStopHandle} Stop handle from Vue watch() + */ +export function useDerivedItemType(options) { + const { + bookingItemtypeId, + bookingItemId, + constrainedItemTypes, + bookableItems, + } = options; + + return 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 } + ); +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useErrorState.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useErrorState.mjs new file mode 100644 index 00000000000..934c715e61a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useErrorState.mjs @@ -0,0 +1,30 @@ +import { reactive, computed } from "vue"; + +/** + * Simple error state composable used across booking components. + * Exposes a reactive error object with message and code, and helpers + * to set/clear it consistently. + * + * @param {import('../types/bookings').ErrorStateInit} [initial] + * @returns {import('../types/bookings').ErrorStateResult} + */ +export function useErrorState(initial = {}) { + const state = reactive({ + message: initial.message || "", + code: initial.code || null, + }); + + function setError(message, code = "ui") { + state.message = message || ""; + state.code = message ? code || "ui" : null; + } + + function clear() { + state.message = ""; + state.code = null; + } + + const hasError = computed(() => !!state.message); + + return { error: state, setError, clear, hasError }; +} 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..bc7054a1406 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs @@ -0,0 +1,277 @@ +import { onMounted, onUnmounted, watch } from "vue"; +import flatpickr from "flatpickr"; +import { isoArrayToDates } from "../lib/booking/date-utils.mjs"; +import { useBookingStore } from "../../../stores/bookings.js"; +import { + applyCalendarHighlighting, + clearCalendarHighlighting, + createOnDayCreate, + createOnClose, + createOnChange, + getVisibleCalendarDates, + buildMarkerGrid, + getCurrentLanguageCode, + preloadFlatpickrLocale, +} from "../lib/adapters/calendar.mjs"; +import { + CLASS_FLATPICKR_DAY, + CLASS_BOOKING_MARKER_GRID, +} from "../lib/booking/constants.mjs"; +import { + getBookingMarkersForDate, + aggregateMarkersByType, +} from "../lib/booking/manager.mjs"; +import { useConstraintHighlighting } from "./useConstraintHighlighting.mjs"; +import { win } from "../lib/adapters/globals.mjs"; + +/** + * 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 {import('../types/bookings').RefLike} [options.tooltipMarkersRef] + * @param {import('../types/bookings').RefLike} [options.tooltipVisibleRef] + * @param {import('../types/bookings').RefLike} [options.tooltipXRef] + * @param {import('../types/bookings').RefLike} [options.tooltipYRef] + * @returns {{ clear: () => void, getInstance: () => import('../types/bookings').FlatpickrInstanceWithHighlighting | null }} + */ +export function useFlatpickr(elRef, options) { + const store = options.store || useBookingStore(); + + const disableFnRef = options.disableFnRef; // Ref + const constraintOptionsRef = options.constraintOptionsRef; // Ref<{dateRangeConstraint,maxBookingPeriod}> + const setError = options.setError; // function(string) + const tooltipMarkersRef = options.tooltipMarkersRef; // Ref + const tooltipVisibleRef = options.tooltipVisibleRef; // Ref + const tooltipXRef = options.tooltipXRef; // Ref + const tooltipYRef = options.tooltipYRef; // Ref + const visibleRangeRef = options.visibleRangeRef; // Ref<{visibleStartDate,visibleEndDate}> + + let fp = null; + + 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) { + // noop + } + } + + onMounted(async () => { + if (!elRef?.value) return; + + // Ensure locale is loaded before initializing flatpickr + await preloadFlatpickrLocale(); + + const dateFormat = + typeof win("flatpickr_dateformat_string") === "string" + ? /** @type {string} */ (win("flatpickr_dateformat_string")) + : "d.m.Y"; + + const langCode = getCurrentLanguageCode(); + // Use locale from window.flatpickr.l10ns (populated by dynamic import in preloadFlatpickrLocale) + // The ES module flatpickr import and window.flatpickr are different instances + const locale = langCode !== "en" ? win("flatpickr")?.["l10ns"]?.[langCode] : undefined; + + /** @type {Partial} */ + const baseConfig = { + mode: "range", + minDate: "today", + disable: [() => false], + clickOpens: true, + dateFormat, + ...(locale && { locale }), + allowInput: false, + onChange: createOnChange(store, { + setError, + tooltipVisibleRef: tooltipVisibleRef || { value: false }, + constraintOptions: constraintOptionsRef?.value || {}, + }), + onClose: createOnClose( + tooltipMarkersRef || { value: [] }, + tooltipVisibleRef || { value: false } + ), + onDayCreate: createOnDayCreate( + store, + tooltipMarkersRef || { value: [] }, + tooltipVisibleRef || { value: false }, + tooltipXRef || { value: 0 }, + tooltipYRef || { value: 0 } + ), + }; + + fp = flatpickr(elRef.value, { + ...baseConfig, + onReady: [ + function (_selectedDates, _dateStr, instance) { + try { + if (visibleRangeRef && instance) { + const visible = getVisibleCalendarDates(instance); + if (visible && visible.length > 0) { + visibleRangeRef.value = { + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + }; + } + } + } catch (e) { + // non-fatal + } + }, + ], + onMonthChange: [ + function (_selectedDates, _dateStr, instance) { + try { + if (visibleRangeRef && instance) { + const visible = getVisibleCalendarDates(instance); + if (visible && visible.length > 0) { + visibleRangeRef.value = { + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + }; + } + } + } catch (e) {} + }, + ], + onYearChange: [ + function (_selectedDates, _dateStr, instance) { + try { + if (visibleRangeRef && instance) { + const visible = getVisibleCalendarDates(instance); + if (visible && visible.length > 0) { + visibleRangeRef.value = { + visibleStartDate: visible[0], + visibleEndDate: visible[visible.length - 1], + }; + } + } + } catch (e) {} + }, + ], + }); + + setDisableOnInstance(); + syncInstanceDatesFromStore(); + }); + + // React to availability updates + if (disableFnRef) { + watch(disableFnRef, () => { + setDisableOnInstance(); + }); + } + + // Recalculate visual constraint highlighting when constraint options or rules change + if (constraintOptionsRef) { + const { highlightingData } = useConstraintHighlighting( + store, + constraintOptionsRef + ); + watch( + () => highlightingData.value, + data => { + if (!fp) return; + if (!data) { + // Clear the cache to prevent onDayCreate from reapplying stale data + const instWithCache = + /** @type {import('../types/bookings').FlatpickrInstanceWithHighlighting} */ ( + fp + ); + instWithCache._constraintHighlighting = null; + clearCalendarHighlighting(fp); + return; + } + applyCalendarHighlighting(fp, data); + } + ); + } + + // Refresh marker dots when unavailableByDate changes + 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) { + // non-fatal + } + }, + { deep: true } + ); + + // Sync UI when dates change programmatically + 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/useRulesFetcher.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs new file mode 100644 index 00000000000..5cacbf5476a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs @@ -0,0 +1,84 @@ +import { watchEffect, ref } from "vue"; + +/** + * Watch core selections and fetch pickup locations and circulation rules. + * 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, // ref(Object|null) + bookingPickupLibraryId, // ref(String|null) + bookingItemtypeId, // ref(String|Number|null) + constrainedItemTypes, // ref(Array) + selectedDateRange, // ref([ISO, ISO]) + biblionumber, // string or ref(optional) + } = options; + + const lastRulesKey = 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; + // Invalidate stale backend due so UI falls back to maxPeriod until fresh rules arrive + store.invalidateCalculatedDue(); + store.fetchCirculationRules(rulesParams); + } + }, + { flush: "post" } + ); + + return { lastRulesKey }; +} + +/** + * Stable, explicit, order-preserving key builder to avoid JSON quirks + * + * @param {import('../types/bookings').RulesParams} params + * @returns {string} + */ +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..b7ca08ed923 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js @@ -0,0 +1,216 @@ +/** + * @module opacBookingApi + * @description Service module for all OPAC booking-related API calls. + * All functions return promises and use async/await. + */ + +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?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.rules] - Comma-separated list of rule kinds (defaults to booking rules) + * @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/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(); +} + +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..3a379ec5504 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js @@ -0,0 +1,386 @@ +/** + * @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"; + +/** + * 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", + }, + } + ); + + 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?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 params = new URLSearchParams({ + patron_id: String(patronId), + }); + + const response = await fetch(`/api/v1/patrons?${params.toString()}`, { + headers: { "x-koha-embed": "library" }, + }); + + if (!response.ok) { + throw bookingValidation.validationError("fetch_patron_failed", { + status: response.status, + statusText: response.statusText, + }); + } + + return await response.json(); +} + +import { buildPatronSearchQuery } from "../patron.mjs"; + +/** + * 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), // Send the query as a JSON string + _page: String(page), + _per_page: "10", // Limit results per page + _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.rules] - Comma-separated list of rule kinds (defaults to booking rules) + * @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 = {}) { + // Only include defined (non-null, non-undefined, non-empty) params + const filteredParams = {}; + for (const key in params) { + if ( + params[key] !== null && + params[key] !== undefined && + params[key] !== "" + ) { + filteredParams[key] = params[key]; + } + } + + // Default to booking rules unless specified + 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(); +} + +/** + * 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.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar.mjs new file mode 100644 index 00000000000..68bca311d4a --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar.mjs @@ -0,0 +1,726 @@ +import { + handleBookingDateChange, + getBookingMarkersForDate, + calculateConstraintHighlighting, + getCalendarNavigationTarget, + aggregateMarkersByType, + deriveEffectiveRules, +} from "../booking/manager.mjs"; +import { toISO, formatYMD, toDayjs, startOfDayTs } from "../booking/date-utils.mjs"; +import { calendarLogger as logger } from "../booking/logger.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CLASS_BOOKING_CONSTRAINED_RANGE_MARKER, + CLASS_BOOKING_DAY_HOVER_LEAD, + CLASS_BOOKING_DAY_HOVER_TRAIL, + CLASS_BOOKING_INTERMEDIATE_BLOCKED, + CLASS_BOOKING_MARKER_COUNT, + CLASS_BOOKING_MARKER_DOT, + CLASS_BOOKING_MARKER_GRID, + CLASS_BOOKING_MARKER_ITEM, + CLASS_BOOKING_OVERRIDE_ALLOWED, + CLASS_FLATPICKR_DAY, + CLASS_FLATPICKR_DISABLED, + CLASS_FLATPICKR_NOT_ALLOWED, + CLASS_BOOKING_LOAN_BOUNDARY, + DATA_ATTRIBUTE_BOOKING_OVERRIDE, +} from "../booking/constants.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 + ); + }); +} + +/** + * 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) => { + if (retryCount === 0) { + logger.group("applyCalendarHighlighting"); + } + const dayElements = instance.calendarContainer.querySelectorAll( + `.${CLASS_FLATPICKR_DAY}` + ); + + if (dayElements.length === 0 && retryCount < 5) { + 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" + ); + } + } + + logger.groupEnd(); + }; + + requestAnimationFrame(() => applyHighlighting()); +} + +/** + * Fix incorrect target-end unavailability via a CSS-based override. + * + * @param {import('flatpickr/dist/types/instance').Instance} _instance + * @param {NodeListOf|Element[]} dayElements + * @param {Date} targetEndDate + * @returns {void} + */ +function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) { + if (!dayElements || typeof dayElements.length !== "number") { + logger.warn( + "Invalid dayElements passed to fixTargetEndDateAvailability", + dayElements + ); + return; + } + + const targetEndElem = Array.from(dayElements).find( + elem => + elem.dateObj && elem.dateObj.getTime() === targetEndDate.getTime() + ); + + if (!targetEndElem) { + logger.warn("Target end date element not found", targetEndDate); + return; + } + + // Mark the element as explicitly allowed, overriding Flatpickr's styles + targetEndElem.classList.remove(CLASS_FLATPICKR_NOT_ALLOWED); + targetEndElem.removeAttribute("tabindex"); + targetEndElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED); + + targetEndElem.setAttribute(DATA_ATTRIBUTE_BOOKING_OVERRIDE, "allowed"); + + logger.debug("Applied CSS override for target end date availability", { + targetDate: targetEndDate, + element: targetEndElem, + }); + + if (targetEndElem.classList.contains(CLASS_FLATPICKR_DISABLED)) { + targetEndElem.classList.remove( + CLASS_FLATPICKR_DISABLED, + CLASS_FLATPICKR_NOT_ALLOWED + ); + targetEndElem.removeAttribute("tabindex"); + targetEndElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED); + + logger.debug("Applied fix for target end date availability", { + finalClasses: Array.from(targetEndElem.classList), + }); + } +} + +/** + * Apply click prevention for intermediate dates in end_date_only mode. + * + * @param {import('flatpickr/dist/types/instance').Instance} instance + * @returns {void} + */ +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 }); + }); +} + +/** Click prevention handler. */ +function preventClick(e) { + e.preventDefault(); + e.stopPropagation(); + return false; +} + +/** + * Get the current language code from the HTML lang attribute + * + * @returns {string} + */ +export function getCurrentLanguageCode() { + const htmlLang = document.documentElement.lang || "en"; + return htmlLang.split("-")[0].toLowerCase(); +} + +/** + * Pre-load flatpickr locale based on current language + * This 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` + ); + } +} + +/** + * 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 + */ +export function createOnChange( + store, + { setError = null, tooltipVisibleRef = null, constraintOptions = {} } = {} +) { + // Allow tests to stub globals; fall back to imported functions + const _getVisibleCalendarDates = + globalThis.getVisibleCalendarDates || getVisibleCalendarDates; + const _calculateConstraintHighlighting = + globalThis.calculateConstraintHighlighting || + calculateConstraintHighlighting; + const _handleBookingDateChange = + globalThis.handleBookingDateChange || handleBookingDateChange; + const _getCalendarNavigationTarget = + globalThis.getCalendarNavigationTarget || getCalendarNavigationTarget; + + return function (selectedDates, _dateStr, instance) { + logger.debug("handleDateChange triggered", { selectedDates }); + + const validDates = (selectedDates || []).filter( + d => d instanceof Date && !Number.isNaN(d.getTime()) + ); + // clear any existing error and sync the store, but skip validation. + if ((selectedDates || []).length === 0) { + // Clear cached loan boundaries when clearing selection + if (instance) { + const instWithCache = + /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set }} */ ( + instance + ); + delete instWithCache._loanBoundaryTimes; + } + 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; + } + + 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; + + const baseRules = + (store.circulationRules && store.circulationRules[0]) || {}; + const effectiveRules = deriveEffectiveRules( + baseRules, + constraintOptions + ); + + // Compute loan boundary times (end of initial loan and renewals) and cache on instance + try { + if (instance && validDates.length > 0) { + const instWithCache = + /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set }} */ ( + 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(); + if (issuelength > 0) { + // End aligns with due date semantics: start + issuelength days + 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 + } + + let calcOptions = {}; + if (instance) { + const visible = _getVisibleCalendarDates(instance); + if (visible && 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 + ); + + if (typeof setError === "function") { + // Support multiple result shapes from handler (backward compatibility for tests) + 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); + } + if (tooltipVisibleRef && "value" in tooltipVisibleRef) { + tooltipVisibleRef.value = false; + } + + if (instance) { + if (selectedDates.length === 1) { + const highlightingData = _calculateConstraintHighlighting( + selectedDates[0], + effectiveRules, + constraintOptions + ); + if (highlightingData) { + applyCalendarHighlighting(instance, highlightingData); + // Compute current visible date range for smarter navigation + const visible = _getVisibleCalendarDates(instance); + const currentView = + visible && 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) { + // Fallback for older flatpickr builds: first ensure year, then adjust month absolutely + 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); + } + }, 100); + } + } + } + if (selectedDates.length === 0) { + const instWithCache = + /** @type {import('../../types/bookings').FlatpickrInstanceWithHighlighting} */ ( + instance + ); + instWithCache._constraintHighlighting = null; + clearCalendarHighlighting(instance); + } + } + }; +} + +/** + * Create Flatpickr `onDayCreate` handler. + * + * Renders per-day marker dots, hover classes, and shows a tooltip with + * aggregated markers. 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); + } + + // Existing tooltip mouseover logic - DO NOT CHANGE unless necessary for aggregation + 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 + ); // Clear first + 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.left + window.scrollX + rect.width / 2; + tooltipY.value = rect.top + window.scrollY - 10; // Adjust Y to be above the cell + } else { + tooltipMarkers.value = []; + tooltipVisible.value = false; + } + }); + + dayElem.addEventListener("mouseout", () => { + dayElem.classList.remove( + CLASS_BOOKING_DAY_HOVER_LEAD, + CLASS_BOOKING_DAY_HOVER_TRAIL + ); + tooltipVisible.value = false; // Hide tooltip when mouse leaves the day cell + }); + + // 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; + }; +} + +/** + * 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) { + return []; + } +} + +/** + * 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/external-dependents.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs new file mode 100644 index 00000000000..ad7f13e7a9b --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs @@ -0,0 +1,242 @@ +import dayjs from "../../../../utils/dayjs.mjs"; +import { win } from "./globals.mjs"; + +/** @typedef {import('../../types/bookings').ExternalDependencies} ExternalDependencies */ + +/** + * Debounce utility with simple trailing invocation. + * + * @param {(...args:any[]) => any} fn + * @param {number} delay + * @returns {(...args:any[]) => void} + */ +export function debounce(fn, delay) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => fn.apply(this, args), delay); + }; +} + +/** + * 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?.cardnumber) { + return bookingPatron.cardnumber; + } + + return ""; + } catch (error) { + dependencies.logger.error("Failed to render patron content", { + error, + bookingPatron, + }); + return 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 itemData = { + id: newBooking.booking_id, + booking: newBooking.booking_id, + patron: newBooking.patron_id, + start: dayjs(newBooking.start_date).toDate(), + end: dayjs(newBooking.end_date).toDate(), + content: renderPatronContent(bookingPatron, dependencies), + 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 }; + } +} + +/** + * 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 }, + }; + + // Update timeline if available + if (dependencies.timeline()) { + results.timeline = { + attempted: true, + ...updateTimelineComponent( + newBooking, + bookingPatron, + isUpdate, + dependencies + ), + }; + } + + // Update bookings table if available + if (dependencies.bookingsTable()) { + results.bookingsTable = { + attempted: true, + ...updateBookingsTable(dependencies), + }; + } + + // Update booking counts + results.bookingCounts = { + attempted: true, + ...updateBookingCounts(isUpdate, dependencies), + }; + + // Log summary for debugging + const successCount = Object.values(results).filter( + r => r.attempted && r.success + ).length; + const attemptedCount = Object.values(results).filter( + r => r.attempted + ).length; + + dependencies.logger.warn( + `External dependents update complete: ${successCount}/${attemptedCount} successful`, + { + isUpdate, + bookingId: newBooking.booking_id, + results, + } + ); + + 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..4c1fcacba25 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs @@ -0,0 +1,27 @@ +/** + * 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]; +} + +/** + * Get a value from window with default initialization + * + * @param {string} key + * @param {unknown} defaultValue + * @returns {unknown} + */ +export function getWindowValue(key, defaultValue) { + if (typeof window === "undefined") return defaultValue; + if (window[key] === undefined) window[key] = defaultValue; + return window[key]; +} diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs new file mode 100644 index 00000000000..d27ca655150 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs @@ -0,0 +1,36 @@ +/** + * Modal scroll helpers shared across components. + * Uses a window-scoped counter to manage body scroll lock for nested modals. + */ + +/** + * Enable body scroll when the last modal closes. + */ +export function enableBodyScroll() { + const count = Number(window["kohaModalCount"] ?? 0); + window["kohaModalCount"] = Math.max(0, count - 1); + + if ((window["kohaModalCount"] ?? 0) === 0) { + document.body.classList.remove("modal-open"); + if (document.body.style.paddingRight) { + document.body.style.paddingRight = ""; + } + } +} + +/** + * Disable body scroll while a modal is open. + */ +export function disableBodyScroll() { + const current = Number(window["kohaModalCount"] ?? 0); + window["kohaModalCount"] = current + 1; + + if (!document.body.classList.contains("modal-open")) { + const scrollbarWidth = + window.innerWidth - document.documentElement.clientWidth; + if (scrollbarWidth > 0) { + document.body.style.paddingRight = `${scrollbarWidth}px`; + } + document.body.classList.add("modal-open"); + } +} 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..173b2beb3eb --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs @@ -0,0 +1,77 @@ +import { win } from "./globals.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 + console.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 }]; +} + +/** + * Transforms patron data into a consistent format for display + * @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(), + }; +} + +/** + * 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/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..5cf10b7c9f9 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs @@ -0,0 +1,610 @@ +/** + * 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 dayjs from "../../../../../utils/dayjs.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 = dayjs(startDate).valueOf(); // Convert to timestamp for fast comparison + /** @type {number} Unix timestamp for end date */ + this.end = dayjs(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 : dayjs(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 = dayjs(this.start).format("YYYY-MM-DD"); + const endStr = dayjs(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) { + logger.debug(`Inserting interval: ${interval.toString()}`); + 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 : dayjs(date).valueOf(); + logger.debug( + `Querying intervals containing date: ${dayjs(timestamp).format( + "YYYY-MM-DD" + )}`, + { itemId } + ); + + const results = []; + this._queryNode(this.root, timestamp, results, itemId); + + logger.debug(`Found ${results.length} intervals`); + 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 + : dayjs(startDate).valueOf(); + const endTimestamp = + typeof endDate === "number" ? endDate : dayjs(endDate).valueOf(); + + logger.debug( + `Querying intervals in range: ${dayjs(startTimestamp).format( + "YYYY-MM-DD" + )} to ${dayjs(endTimestamp).format("YYYY-MM-DD")}`, + { itemId } + ); + + const queryInterval = new BookingInterval( + new Date(startTimestamp), + new Date(endTimestamp), + "", + "query" + ); + const results = []; + this._queryRangeNode(this.root, queryInterval, results, itemId); + + logger.debug(`Found ${results.length} overlapping intervals`); + 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--; + }); + + logger.debug(`Removed ${toRemove.length} intervals`); + 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; + logger.debug("Interval tree cleared"); + } + + /** + * Get statistics about the tree for debugging and monitoring + * @returns {Object} Statistics object + */ + getStats() { + const stats = { + size: this.size, + height: this._getHeight(this.root), + balanced: Math.abs(this._getBalance(this.root)) <= 1, + }; + + logger.debug("Interval tree stats:", stats); + return stats; + } +} + +/** + * 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) { + logger.time("buildIntervalTree"); + logger.info("Building interval tree", { + bookingsCount: bookings.length, + checkoutsCount: checkouts.length, + }); + + 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 leadStart = dayjs(booking.start_date).subtract( + leadDays, + "day" + ); + const leadEnd = dayjs(booking.start_date).subtract(1, "day"); + const leadInterval = new BookingInterval( + leadStart, + leadEnd, + 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 trailStart = dayjs(booking.end_date).add(1, "day"); + const trailEnd = dayjs(booking.end_date).add(trailDays, "day"); + const trailInterval = new BookingInterval( + trailStart, + trailEnd, + 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, + }); + } + }); + + const stats = tree.getStats(); + logger.info("Interval tree built", stats); + logger.timeEnd("buildIntervalTree"); + + 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..59428104a0d --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs @@ -0,0 +1,401 @@ +/** + * 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 dayjs from "../../../../../utils/dayjs.mjs"; +import { startOfDayTs, endOfDayTs, formatYMD } from "../date-utils.mjs"; +import { managerLogger as logger } from "../logger.mjs"; + +/** + * Event types for the sweep line algorithm + * @readonly + * @enum {string} + */ +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) { + logger.time("SweepLineProcessor.processIntervals"); + logger.debug("Processing intervals for date range", { + intervalCount: intervals.length, + viewStart: formatYMD(viewStart), + viewEnd: formatYMD(viewEnd), + itemCount: allItemIds.length, + }); + + 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 = dayjs(interval.end) + .add(1, "day") + .startOf("day") + .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; + }); + + logger.debug(`Created ${this.events.length} sweep events`); + + /** @type {Record>>} */ + const unavailableByDate = {}; + const activeIntervals = new Map(); // itemId -> Set of intervals + + allItemIds.forEach(itemId => { + activeIntervals.set(itemId, new Set()); + }); + + let currentDate = null; + let eventIndex = 0; + + for ( + let date = dayjs(viewStart).startOf("day"); + 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; + } + }); + } + + logger.debug("Sweep line processing complete", { + datesProcessed: Object.keys(unavailableByDate).length, + totalUnavailable: Object.values(unavailableByDate).reduce( + (sum, items) => sum + Object.keys(items).length, + 0 + ), + }); + + logger.timeEnd("SweepLineProcessor.processIntervals"); + + 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) { + logger.time("getDateRangeStatistics"); + + const stats = { + totalDays: 0, + daysWithBookings: 0, + daysWithCheckouts: 0, + fullyBookedDays: 0, + peakBookingCount: 0, + peakDate: null, + itemUtilization: new Map(), + }; + + const startDate = dayjs(viewStart).startOf("day"); + const endDate = dayjs(viewEnd).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 + ); + }); + } + + logger.info("Date range statistics calculated", stats); + logger.timeEnd("getDateRangeStatistics"); + + 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 = 365) { + logger.debug("Finding next available date", { + itemId, + startDate: dayjs(startDate).format("YYYY-MM-DD"), + }); + + const start = dayjs(startDate).startOf("day"); + 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) { + logger.debug("Found available date", { + date: checkDate.format("YYYY-MM-DD"), + daysFromStart: i, + }); + return checkDate.toDate(); + } + } + + logger.warn("No available date found within search limit"); + 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) { + logger.debug("Finding available gaps", { + itemId, + viewStart: dayjs(viewStart).format("YYYY-MM-DD"), + viewEnd: dayjs(viewEnd).format("YYYY-MM-DD"), + minGapDays, + }); + + const gaps = []; + const itemIntervals = intervals + .filter(interval => interval.itemId === itemId) + .sort((a, b) => a.start - b.start); + + const rangeStart = dayjs(viewStart).startOf("day").valueOf(); + const rangeEnd = dayjs(viewEnd).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 = dayjs(gapEnd).diff(dayjs(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 = dayjs(rangeEnd).diff(dayjs(lastEnd), "day"); + if (gapDays >= minGapDays) { + gaps.push({ + start: new Date(lastEnd), + end: new Date(rangeEnd), + days: gapDays, + }); + } + } + + logger.debug(`Found ${gaps.length} available gaps`); + return gaps; + } +} + +/** + * Create and process unavailability data using sweep line algorithm + * @param {import('./interval-tree.mjs').IntervalTree} intervalTree - The interval tree containing all bookings/checkouts + * @param {Date|import("dayjs").Dayjs} viewStart - Start of the calendar view date range + * @param {Date|import("dayjs").Dayjs} viewEnd - End of the calendar view date range + * @param {Array} allItemIds - All bookable item IDs + * @returns {Object>>} unavailableByDate map + */ +export function processCalendarView( + intervalTree, + viewStart, + viewEnd, + allItemIds +) { + logger.time("processCalendarView"); + + const relevantIntervals = intervalTree.queryRange(viewStart, viewEnd); + + const processor = new SweepLineProcessor(); + const unavailableByDate = processor.processIntervals( + relevantIntervals, + viewStart, + viewEnd, + allItemIds + ); + + logger.timeEnd("processCalendarView"); + return unavailableByDate; +} 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..337aa23acf0 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs @@ -0,0 +1,28 @@ +// Shared constants for booking system (business logic + UI) + +// Constraint modes +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"; diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/date-utils.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/date-utils.mjs new file mode 100644 index 00000000000..22a69a9dbc5 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/date-utils.mjs @@ -0,0 +1,42 @@ +import dayjs from "../../../../utils/dayjs.mjs"; + +// Convert an array of ISO strings (or Date-like values) to plain Date objects +export function isoArrayToDates(values) { + if (!Array.isArray(values)) return []; + return values.filter(Boolean).map(d => dayjs(d).toDate()); +} + +// Convert a Date-like input to ISO string +export function toISO(input) { + return dayjs( + /** @type {import('dayjs').ConfigType} */ (input) + ).toISOString(); +} + +// Normalize any Date-like input to a dayjs instance +export function toDayjs(input) { + return dayjs(/** @type {import('dayjs').ConfigType} */ (input)); +} + +// Get start-of-day timestamp for a Date-like input +export function startOfDayTs(input) { + return toDayjs(input).startOf("day").valueOf(); +} + +// Get end-of-day timestamp for a Date-like input +export function endOfDayTs(input) { + return toDayjs(input).endOf("day").valueOf(); +} + +// Format a Date-like input as YYYY-MM-DD +export function formatYMD(input) { + return toDayjs(input).format("YYYY-MM-DD"); +} + +// Add or subtract days returning a dayjs instance +export function addDays(input, days) { + return toDayjs(input).add(days, "day"); +} +export function subDays(input, days) { + return toDayjs(input).subtract(days, "day"); +} 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..3304b1a4269 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs @@ -0,0 +1,39 @@ +// Utilities for comparing and handling mixed string/number IDs consistently + +export function idsEqual(a, b) { + if (a == null || b == null) return false; + return String(a) === String(b); +} + +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); +} + +export function toIdSet(list) { + if (!Array.isArray(list)) return new Set(); + return new Set(list.map(v => String(v))); +} + +/** + * Normalize any value to a string ID (for Set/Map keys and comparisons) + * @param {unknown} value + * @returns {string} + */ +export function toStringId(value) { + return 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..9eb8b7430aa --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs @@ -0,0 +1,279 @@ +/** + * 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. + */ + +class BookingLogger { + constructor(module) { + this.module = module; + this.enabled = false; + this.logLevels = { + DEBUG: "debug", + INFO: "info", + WARN: "warn", + ERROR: "error", + }; + // 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 = []; + + // 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 = this._logBuffer || []; + 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 = this._timers || {}; + 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 && 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/manager.mjs b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/manager.mjs new file mode 100644 index 00000000000..2a5de46cde8 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/manager.mjs @@ -0,0 +1,1355 @@ +import dayjs from "../../../../utils/dayjs.mjs"; +import { + isoArrayToDates, + toDayjs, + addDays, + subDays, + formatYMD, +} from "./date-utils.mjs"; +import { managerLogger as logger } from "./logger.mjs"; +import { createConstraintStrategy } from "./strategies.mjs"; +import { + // eslint-disable-next-line no-unused-vars + IntervalTree, + buildIntervalTree, +} from "./algorithms/interval-tree.mjs"; +import { + SweepLineProcessor, + processCalendarView, +} from "./algorithms/sweep-line-processor.mjs"; +import { idsEqual, includesId } from "./id-utils.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CONSTRAINT_MODE_NORMAL, + SELECTION_ANY_AVAILABLE, + SELECTION_SPECIFIC_ITEM, +} from "./constants.mjs"; + +const $__ = globalThis.$__ || (str => str); + +/** + * Calculates the maximum end date for a booking period based on start date and maximum period. + * Follows Koha circulation behavior where maxPeriod represents days to ADD to start date. + * + * @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 = dayjs(startDate).startOf("day"); + // Add maxPeriod days (matches CalcDateDue behavior) + return start.add(maxPeriod, "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 = dayjs(endDate).startOf("day"); + + return !proposedEnd.isAfter(maxEndDate, "day"); +} + +/** + * Build unavailableByDate map from IntervalTree for backward compatibility + * @param {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 {Object} options - Additional options for optimization + * @param {Object} [options] - Additional options for optimization + * @param {Date} [options.visibleStartDate] - Start of visible calendar range + * @param {Date} [options.visibleEndDate] - End of visible calendar range + * @param {boolean} [options.onDemand] - Whether to build map on-demand for visible dates only + * @returns {import('../../types/bookings').UnavailableByDate} + */ +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, 7); + endDate = addDays(options.visibleEndDate, 7); + logger.debug("Building unavailableByDate map for visible range only", { + start: formatYMD(startDate), + end: formatYMD(endDate), + days: endDate.diff(startDate, "day") + 1, + }); + } else { + startDate = subDays(today, 7); + endDate = addDays(today, 90); + logger.debug("Building unavailableByDate map with limited range", { + start: formatYMD(startDate), + end: formatYMD(endDate), + days: endDate.diff(startDate, "day") + 1, + }); + } + + 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; +} + +// Small helper to standardize constraint function return shape +function buildConstraintResult(filtered, total) { + const filteredOutCount = total - filtered.length; + return { + filtered, + filteredOutCount, + total, + constraintApplied: filtered.length !== total, + }; +} + +/** + * 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 + */ +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"); + + logger.debug( + `Optimized lead period check: ${formatYMD(leadStart)} to ${formatYMD( + leadEnd + )}` + ); + + // Use range query to get all conflicts in the lead period at once + const leadConflicts = intervalTree.queryRange( + leadStart.valueOf(), + leadEnd.valueOf(), + selectedItem != null ? String(selectedItem) : null + ); + + const relevantLeadConflicts = leadConflicts.filter( + c => !editBookingId || c.metadata.booking_id != editBookingId + ); + + if (selectedItem) { + // For specific item, any conflict in lead period blocks the start date + return relevantLeadConflicts.length > 0; + } else { + // For "any item" mode, need to check if there are conflicts for ALL items + // on ANY day in the lead period + if (relevantLeadConflicts.length === 0) return false; + + const unavailableItemIds = new Set( + relevantLeadConflicts.map(c => c.itemId) + ); + const allUnavailable = + allItemIds.length > 0 && + allItemIds.every(id => unavailableItemIds.has(String(id))); + + logger.debug(`Lead period multi-item check (optimized):`, { + leadPeriod: `${formatYMD(leadStart)} to ${formatYMD(leadEnd)}`, + totalItems: allItemIds.length, + conflictsFound: relevantLeadConflicts.length, + unavailableItems: Array.from(unavailableItemIds), + allUnavailable: allUnavailable, + decision: allUnavailable ? "BLOCK" : "ALLOW", + }); + + return allUnavailable; + } +} + +/** + * 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 + */ +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"); + + logger.debug( + `Optimized trail period check: ${formatYMD(trailStart)} to ${formatYMD( + trailEnd + )}` + ); + + // Use range query to get all conflicts in the trail period at once + const trailConflicts = intervalTree.queryRange( + trailStart.valueOf(), + trailEnd.valueOf(), + selectedItem != null ? String(selectedItem) : null + ); + + const relevantTrailConflicts = trailConflicts.filter( + c => !editBookingId || c.metadata.booking_id != editBookingId + ); + + if (selectedItem) { + // For specific item, any conflict in trail period blocks the end date + return relevantTrailConflicts.length > 0; + } else { + // For "any item" mode, need to check if there are conflicts for ALL items + // on ANY day in the trail period + if (relevantTrailConflicts.length === 0) return false; + + const unavailableItemIds = new Set( + relevantTrailConflicts.map(c => c.itemId) + ); + const allUnavailable = + allItemIds.length > 0 && + allItemIds.every(id => unavailableItemIds.has(String(id))); + + logger.debug(`Trail period multi-item check (optimized):`, { + trailPeriod: `${trailStart.format( + "YYYY-MM-DD" + )} to ${trailEnd.format("YYYY-MM-DD")}`, + totalItems: allItemIds.length, + conflictsFound: relevantTrailConflicts.length, + unavailableItems: Array.from(unavailableItemIds), + allUnavailable: allUnavailable, + decision: allUnavailable ? "BLOCK" : "ALLOW", + }); + + return allUnavailable; + } +} + +/** + * 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 + */ +function extractBookingConfiguration(circulationRules, todayArg) { + const today = todayArg + ? toDayjs(todayArg).startOf("day") + : dayjs().startOf("day"); + 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 + ? dayjs(circulationRules.calculated_due_date).startOf("day") + : null; + const calculatedPeriodDays = Number( + circulationRules?.calculated_period_days + ) + ? Number(circulationRules.calculated_period_days) + : null; + + logger.debug("Booking configuration extracted:", { + today: today.format("YYYY-MM-DD"), + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + rawRules: circulationRules, + }); + + return { + today, + leadDays, + trailDays, + maxPeriod, + isEndDateOnly, + calculatedDueDate, + calculatedPeriodDays, + }; +} + +/** + * 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 + * @returns {(date: Date) => boolean} Disable function for Flatpickr + */ +function createDisableFunction( + intervalTree, + config, + bookableItems, + selectedItem, + editBookingId, + selectedDates +) { + 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 + ); + + return date => { + const dayjs_date = dayjs(date).startOf("day"); + + // Guard clause: Basic past date validation + if (dayjs_date.isBefore(today, "day")) return true; + + // Guard clause: No bookable items available + if (!bookableItems || bookableItems.length === 0) { + logger.debug( + `Date ${dayjs_date.format( + "YYYY-MM-DD" + )} disabled - no bookable items available` + ); + 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 + const pointConflicts = intervalTree.query( + dayjs_date.valueOf(), + selectedItem != null ? String(selectedItem) : null + ); + const relevantPointConflicts = pointConflicts.filter( + interval => + !editBookingId || interval.metadata.booking_id != editBookingId + ); + + // Guard clause: Specific item conflicts + if (selectedItem && relevantPointConflicts.length > 0) { + logger.debug( + `Date ${dayjs_date.format( + "YYYY-MM-DD" + )} blocked for item ${selectedItem}:`, + relevantPointConflicts.map(c => c.type) + ); + return true; + } + + // Guard clause: All items unavailable (any item mode) + if (!selectedItem) { + const unavailableItemIds = new Set( + relevantPointConflicts.map(c => c.itemId) + ); + const allUnavailable = + allItemIds.length > 0 && + allItemIds.every(id => unavailableItemIds.has(String(id))); + + logger.debug( + `Multi-item availability check for ${dayjs_date.format( + "YYYY-MM-DD" + )}:`, + { + totalItems: allItemIds.length, + allItemIds: allItemIds, + conflictsFound: relevantPointConflicts.length, + unavailableItemIds: Array.from(unavailableItemIds), + allUnavailable: allUnavailable, + decision: allUnavailable ? "BLOCK" : "ALLOW", + } + ); + + if (allUnavailable) { + logger.debug( + `Date ${dayjs_date.format( + "YYYY-MM-DD" + )} blocked - all items unavailable` + ); + return true; + } + } + + // Lead/trail period validation using optimized queries + if (!selectedDates || selectedDates.length === 0) { + // Potential start date - check lead period + if (leadDays > 0) { + logger.debug( + `Checking lead period for ${dayjs_date.format( + "YYYY-MM-DD" + )} (${leadDays} days)` + ); + } + + // Optimized lead period validation using range queries + if ( + validateLeadPeriodOptimized( + dayjs_date, + leadDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + logger.debug( + `Start date ${dayjs_date.format( + "YYYY-MM-DD" + )} blocked - lead period conflict (optimized check)` + ); + return true; + } + } else if ( + selectedDates[0] && + (!selectedDates[1] || + dayjs(selectedDates[1]).isSame(dayjs_date, "day")) + ) { + // Potential end date - check trail period + const start = dayjs(selectedDates[0]).startOf("day"); + + // Basic end date validations + if (dayjs_date.isBefore(start, "day")) return true; + // Respect backend-calculated due date in end_date_only mode only if it's not before start + if ( + isEndDateOnly && + config.calculatedDueDate && + !config.calculatedDueDate.isBefore(start, "day") + ) { + const targetEnd = config.calculatedDueDate; + if (dayjs_date.isAfter(targetEnd, "day")) return true; + } else if (maxPeriod > 0) { + const maxEndDate = calculateMaxEndDate(start, maxPeriod); + if (dayjs_date.isAfter(maxEndDate, "day")) + return true; + } + + // Optimized trail period validation using range queries + if ( + validateTrailPeriodOptimized( + dayjs_date, + trailDays, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ) + ) { + logger.debug( + `End date ${dayjs_date.format( + "YYYY-MM-DD" + )} blocked - trail period conflict (optimized check)` + ); + return true; + } + } + + return false; + }; +} + +/** + * Logs comprehensive debug information for OPAC booking selection debugging + * @param {Array} bookings - Array of booking objects + * @param {Array} checkouts - Array of checkout objects + * @param {Array} bookableItems - Array of bookable items + * @param {string|null} selectedItem - Selected item ID + * @param {Object} circulationRules - Circulation rules + */ +function logBookingDebugInfo( + bookings, + checkouts, + bookableItems, + selectedItem, + circulationRules +) { + logger.debug("OPAC Selection Debug:", { + selectedItem: selectedItem, + selectedItemType: + selectedItem === null + ? SELECTION_ANY_AVAILABLE + : SELECTION_SPECIFIC_ITEM, + bookableItems: bookableItems.map(item => ({ + item_id: item.item_id, + title: item.title, + item_type_id: item.item_type_id, + holding_library: item.holding_library, + available_pickup_locations: item.available_pickup_locations, + })), + circulationRules: { + booking_constraint_mode: circulationRules?.booking_constraint_mode, + maxPeriod: circulationRules?.maxPeriod, + bookings_lead_period: circulationRules?.bookings_lead_period, + bookings_trail_period: circulationRules?.bookings_trail_period, + }, + bookings: bookings.map(b => ({ + booking_id: b.booking_id, + item_id: b.item_id, + start_date: b.start_date, + end_date: b.end_date, + patron_id: b.patron_id, + })), + checkouts: checkouts.map(c => ({ + item_id: c.item_id, + checkout_date: c.checkout_date, + due_date: c.due_date, + patron_id: c.patron_id, + })), + }); +} + +/** + * 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 + * @returns {import('../../types/bookings').AvailabilityResult} + */ +export function calculateDisabledDates( + bookings, + checkouts, + bookableItems, + selectedItem, + editBookingId, + selectedDates = [], + circulationRules = {}, + todayArg = undefined, + options = {} +) { + logger.time("calculateDisabledDates"); + const normalizedSelectedItem = + selectedItem != null ? String(selectedItem) : null; + logger.debug("calculateDisabledDates called", { + bookingsCount: bookings.length, + checkoutsCount: checkouts.length, + itemsCount: bookableItems.length, + normalizedSelectedItem, + editBookingId, + selectedDates, + circulationRules, + }); + + // Log comprehensive debug information for OPAC debugging + logBookingDebugInfo( + bookings, + checkouts, + bookableItems, + normalizedSelectedItem, + circulationRules + ); + + // 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 + ); + + // Build unavailableByDate for backward compatibility and markers + // Pass options for performance optimization + + const unavailableByDate = buildUnavailableByDateMap( + intervalTree, + config.today, + allItemIds, + normalizedEditBookingId, + options + ); + + logger.debug("IntervalTree-based availability calculated", { + treeSize: intervalTree.size, + }); + logger.timeEnd("calculateDisabledDates"); + + return { + disable: disableFunction, + unavailableByDate: unavailableByDate, + }; +} + +/** + * 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; + } +} + +/** + * 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 = {} +) { + logger.time("handleBookingDateChange"); + logger.debug("handleBookingDateChange called", { + selectedDates, + circulationRules, + selectedItem, + editBookingId, + }); + const dayjsStart = selectedDates[0] + ? toDayjs(selectedDates[0]).startOf("day") + : null; + const dayjsEnd = selectedDates[1] + ? toDayjs(selectedDates[1]).endOf("day") + : null; + const errors = []; + let valid = true; + let newMaxEndDate = null; + let newMinEndDate = null; // Declare and initialize here + + // Validate: ensure start date is present + if (!dayjsStart) { + errors.push(String($__("Start date is required."))); + valid = false; + } else { + // Apply circulation rules: leadDays, trailDays, maxPeriod (in days) + const leadDays = circulationRules?.leadDays || 0; + const _trailDays = circulationRules?.trailDays || 0; // Still needed for start date check + 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 + ? toDayjs(todayArg).startOf("day") + : dayjs().startOf("day"); + 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 + 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") + 1 > 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, // Pass selectedDates + circulationRules, // Pass circulationRules + todayArg, // Pass 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; + } + } + } + + logger.debug("Date change validation result", { valid, errors }); + logger.timeEnd("handleBookingDateChange"); + + return { + valid, + errors, + newMaxEndDate: newMaxEndDate ? newMaxEndDate.toDate() : null, + newMinEndDate: newMinEndDate ? newMinEndDate.toDate() : null, + }; +} + +/** + * 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 = [] +) { + // Guard against unavailableByDate itself being undefined or null + if (!unavailableByDate) { + return []; // No data, so no markers + } + + const d = + typeof dateStr === "string" + ? dayjs(dateStr).startOf("day") + : dayjs(dateStr).isValid() + ? dayjs(dateStr).startOf("day") + : dayjs().startOf("day"); + 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]; // This was line 496 + + // Guard against the specific date key not being in the map + if (!entry) { + return []; // No data for this specific date, so no markers + } + + // Now it's safe to use Object.entries(entry) + for (const [item_id, reasons] of Object.entries(entry)) { + const item = findItem(item_id); + for (const reason of reasons) { + let type = reason; + // Map IntervalTree/Sweep reasons to CSS class names + if (type === "booking") type = "booked"; + if (type === "core") type = "booked"; + if (type === "checkout") type = "checked-out"; + // lead and trail periods keep their original names for CSS + 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; +} + +/** + * 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 +) { + logger.debug("constrainPickupLocations called", { + inputLocations: pickupLocations.length, + bookingItemtypeId, + bookingItemId, + bookableItems: bookableItems.length, + locationDetails: pickupLocations.map(loc => ({ + library_id: loc.library_id, + pickup_items: loc.pickup_items?.length || 0, + })), + }); + + if (!bookingItemtypeId && !bookingItemId) { + logger.debug( + "constrainPickupLocations: No constraints, returning all locations" + ); + return buildConstraintResult(pickupLocations, pickupLocations.length); + } + const filtered = pickupLocations.filter(loc => { + if (bookingItemId) { + return ( + loc.pickup_items && includesId(loc.pickup_items, bookingItemId) + ); + } + if (bookingItemtypeId) { + return ( + loc.pickup_items && + bookableItems.some( + item => + idsEqual(item.item_type_id, bookingItemtypeId) && + includesId(loc.pickup_items, item.item_id) + ) + ); + } + return true; + }); + logger.debug("constrainPickupLocations result", { + inputCount: pickupLocations.length, + outputCount: filtered.length, + filteredOutCount: pickupLocations.length - filtered.length, + constraints: { + bookingItemtypeId, + bookingItemId, + }, + }); + + return buildConstraintResult(filtered, pickupLocations.length); +} + +/** + * 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 +) { + logger.debug("constrainBookableItems called", { + inputItems: bookableItems.length, + pickupLibraryId, + bookingItemtypeId, + pickupLocations: pickupLocations.length, + itemDetails: bookableItems.map(item => ({ + item_id: item.item_id, + item_type_id: item.item_type_id, + title: item.title, + })), + }); + + if (!pickupLibraryId && !bookingItemtypeId) { + logger.debug( + "constrainBookableItems: No constraints, returning all items" + ); + return buildConstraintResult(bookableItems, bookableItems.length); + } + const filtered = bookableItems.filter(item => { + if (pickupLibraryId && bookingItemtypeId) { + const found = pickupLocations.find( + loc => + idsEqual(loc.library_id, pickupLibraryId) && + loc.pickup_items && + includesId(loc.pickup_items, item.item_id) + ); + const match = + idsEqual(item.item_type_id, bookingItemtypeId) && found; + return match; + } + if (pickupLibraryId) { + const found = pickupLocations.find( + loc => + idsEqual(loc.library_id, pickupLibraryId) && + loc.pickup_items && + includesId(loc.pickup_items, item.item_id) + ); + return found; + } + if (bookingItemtypeId) { + return idsEqual(item.item_type_id, bookingItemtypeId); + } + return true; + }); + logger.debug("constrainBookableItems result", { + inputCount: bookableItems.length, + outputCount: filtered.length, + filteredOutCount: bookableItems.length - filtered.length, + filteredItems: filtered.map(item => ({ + item_id: item.item_id, + item_type_id: item.item_type_id, + title: item.title, + })), + constraints: { + pickupLibraryId, + bookingItemtypeId, + }, + }); + + return buildConstraintResult(filtered, bookableItems.length); +} + +/** + * 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 +) { + if (!pickupLibraryId && !bookingItemId) { + return buildConstraintResult(itemTypes, itemTypes.length); + } + const filtered = itemTypes.filter(type => { + if (bookingItemId) { + return bookableItems.some( + item => + idsEqual(item.item_id, bookingItemId) && + idsEqual(item.item_type_id, type.item_type_id) + ); + } + if (pickupLibraryId) { + return bookableItems.some( + item => + idsEqual(item.item_type_id, type.item_type_id) && + pickupLocations.find( + loc => + idsEqual(loc.library_id, pickupLibraryId) && + loc.pickup_items && + includesId(loc.pickup_items, item.item_id) + ) + ); + } + return true; + }); + return buildConstraintResult(filtered, itemTypes.length); +} + +/** + * 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 + ); + const result = strategy.calculateConstraintHighlighting( + startDate, + circulationRules, + constraintOptions + ); + logger.debug("Constraint highlighting calculated", result); + return result; +} + +/** + * 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 = {} +) { + logger.debug("Checking calendar navigation", { + 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")) { + logger.debug("Target end before start; skip navigation"); + 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) { + logger.debug("Target end date already visible; no navigation"); + return { shouldNavigate: false }; + } + } + + // Fallback: navigate when target month differs from start month + if (start.month() !== target.month() || start.year() !== target.year()) { + const navigationTarget = { + shouldNavigate: true, + targetMonth: target.month(), + targetYear: target.year(), + targetDate: target.toDate(), + }; + logger.debug("Calendar should navigate", navigationTarget); + return navigationTarget; + } + + logger.debug("No navigation needed - same month"); + return { shouldNavigate: false }; +} + +/** + * 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) { + logger.debug("Aggregating markers", { count: markers.length }); + + const aggregated = 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; + }, {}); + + logger.debug("Markers aggregated", aggregated); + return aggregated; +} + +// Re-export the new efficient data structure builders +export { buildIntervalTree, processCalendarView }; 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..d5a4ec76bbd --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs @@ -0,0 +1,245 @@ +import dayjs from "../../../../utils/dayjs.mjs"; +import { addDays, formatYMD } from "./date-utils.mjs"; +import { managerLogger as logger } from "./logger.mjs"; +import { calculateMaxEndDate } from "./manager.mjs"; +import { + CONSTRAINT_MODE_END_DATE_ONLY, + CONSTRAINT_MODE_NORMAL, +} from "./constants.mjs"; +import { toStringId } from "./id-utils.mjs"; + +// Internal helpers for end_date_only mode +function validateEndDateOnlyStartDateInternal( + 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; + } + + logger.debug( + `Checking ${CONSTRAINT_MODE_END_DATE_ONLY} range: ${formatYMD( + date + )} to ${formatYMD(targetEndDate)}` + ); + + if (selectedItem) { + const conflicts = intervalTree.queryRange( + date.valueOf(), + targetEndDate.valueOf(), + toStringId(selectedItem) + ); + const relevantConflicts = conflicts.filter( + interval => + !editBookingId || interval.metadata.booking_id != editBookingId + ); + return relevantConflicts.length > 0; + } else { + // Any item mode: block if all items are unavailable on any date in the range + for ( + let checkDate = date; + checkDate.isSameOrBefore(targetEndDate, "day"); + checkDate = checkDate.add(1, "day") + ) { + const dayConflicts = intervalTree.query(checkDate.valueOf()); + const relevantDayConflicts = dayConflicts.filter( + interval => + !editBookingId || + interval.metadata.booking_id != editBookingId + ); + const unavailableItemIds = new Set( + relevantDayConflicts.map(c => toStringId(c.itemId)) + ); + const allItemsUnavailableOnThisDay = + allItemIds.length > 0 && + allItemIds.every(id => unavailableItemIds.has(toStringId(id))); + if (allItemsUnavailableOnThisDay) { + return true; + } + } + return false; + } +} + +function handleEndDateOnlyIntermediateDatesInternal( + date, + selectedDates, + maxPeriod +) { + if (!selectedDates || selectedDates.length !== 1) { + return null; // Not applicable + } + const startDate = dayjs(selectedDates[0]).startOf("day"); + const expectedEndDate = calculateMaxEndDate(startDate, maxPeriod); + if (date.isSame(expectedEndDate, "day")) { + return null; // Allow normal validation for expected end + } + if (date.isAfter(expectedEndDate, "day")) { + return true; // Hard disable beyond expected end + } + // Intermediate date: leave to UI highlighting (no hard disable) + return null; +} + +const EndDateOnlyStrategy = { + name: CONSTRAINT_MODE_END_DATE_ONLY, + validateStartDateSelection( + dayjsDate, + config, + intervalTree, + selectedItem, + editBookingId, + allItemIds, + selectedDates + ) { + if (!selectedDates || selectedDates.length === 0) { + return validateEndDateOnlyStartDateInternal( + dayjsDate, + config, + intervalTree, + selectedItem, + editBookingId, + allItemIds + ); + } + return false; + }, + handleIntermediateDate(dayjsDate, selectedDates, config) { + // Prefer backend due date when provided and valid; otherwise fall back to maxPeriod + if (config?.calculatedDueDate) { + if (!selectedDates || selectedDates.length !== 1) return null; + const startDate = dayjs(selectedDates[0]).startOf("day"); + const due = config.calculatedDueDate; + if (!due.isBefore(startDate, "day")) { + const expectedEndDate = due.clone(); + if (dayjsDate.isSame(expectedEndDate, "day")) return null; + if (dayjsDate.isAfter(expectedEndDate, "day")) return true; // disable beyond expected end + return null; // intermediate left to UI highlighting + click prevention + } + // Fall through to maxPeriod handling + } + return handleEndDateOnlyIntermediateDatesInternal( + dayjsDate, + selectedDates, + Number(config?.maxPeriod) || 0 + ); + }, + /** + * @param {Date|import('dayjs').Dayjs} startDate + * @param {import('../../types/bookings').CirculationRule|Object} circulationRules + * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}] + * @returns {import('../../types/bookings').ConstraintHighlighting|null} + */ + calculateConstraintHighlighting( + startDate, + circulationRules, + constraintOptions = {} + ) { + const start = dayjs(startDate).startOf("day"); + // Prefer backend-calculated due date when provided (respects closures) + const dueStr = circulationRules?.calculated_due_date; + let targetEnd; + let periodForUi = Number(circulationRules?.calculated_period_days) || 0; + if (dueStr) { + const due = dayjs(dueStr).startOf("day"); + const start = dayjs(startDate).startOf("day"); + if (!due.isBefore(start, "day")) { + targetEnd = due; + } + } + if (!targetEnd) { + let maxPeriod = constraintOptions.maxBookingPeriod; + if (!maxPeriod) { + maxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 30; + } + if (!maxPeriod) return null; + targetEnd = calculateMaxEndDate(start, maxPeriod); + periodForUi = maxPeriod; + } + const diffDays = Math.max(0, targetEnd.diff(start, "day")); + const blockedIntermediateDates = []; + for (let i = 1; i < diffDays; i++) { + blockedIntermediateDates.push(addDays(start, i).toDate()); + } + return { + startDate: start.toDate(), + targetEndDate: targetEnd.toDate(), + blockedIntermediateDates, + constraintMode: CONSTRAINT_MODE_END_DATE_ONLY, + maxPeriod: periodForUi, + }; + }, + enforceEndDateSelection(dayjsStart, dayjsEnd, circulationRules) { + if (!dayjsEnd) return { ok: true }; + const dueStr = circulationRules?.calculated_due_date; + let targetEnd; + if (dueStr) { + const due = dayjs(dueStr).startOf("day"); + if (!due.isBefore(dayjsStart, "day")) { + targetEnd = due; + } + } + if (!targetEnd) { + const numericMaxPeriod = + Number(circulationRules?.maxPeriod) || + Number(circulationRules?.issuelength) || + 0; + targetEnd = addDays(dayjsStart, Math.max(1, numericMaxPeriod) - 1); + } + return { + ok: dayjsEnd.isSame(targetEnd, "day"), + expectedEnd: targetEnd, + }; + }, +}; + +const NormalStrategy = { + name: CONSTRAINT_MODE_NORMAL, + validateStartDateSelection() { + return false; + }, + handleIntermediateDate() { + return null; + }, + /** + * @param {Date|import('dayjs').Dayjs} startDate + * @param {any} _rules + * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}] + * @returns {import('../../types/bookings').ConstraintHighlighting|null} + */ + calculateConstraintHighlighting(startDate, _rules, constraintOptions = {}) { + const start = dayjs(startDate).startOf("day"); + const maxPeriod = constraintOptions.maxBookingPeriod; + if (!maxPeriod) return null; + const targetEndDate = calculateMaxEndDate(start, maxPeriod).toDate(); + return { + startDate: start.toDate(), + targetEndDate, + blockedIntermediateDates: [], + constraintMode: CONSTRAINT_MODE_NORMAL, + maxPeriod, + }; + }, + enforceEndDateSelection() { + return { ok: true }; + }, +}; + +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..994b465dcb3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js @@ -0,0 +1,69 @@ +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 + ), + 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..c892db601c2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs @@ -0,0 +1,110 @@ +/** + * Pure functions for booking validation logic + * Extracted from BookingValidationService to eliminate store coupling + */ + +import { handleBookingDateChange } from "./manager.mjs"; + +/** + * 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; + + // Step 1: Patron validation (if required) + if (showPatronSelect && !bookingPatron) { + return false; + } + + // Step 2: Item details validation + if (showItemDetailsSelects || showPickupLocationSelect) { + if (showPickupLocationSelect && !pickupLibraryId) { + return false; + } + if (showItemDetailsSelects) { + if (!bookingItemtypeId && itemtypeOptions.length > 0) { + return false; + } + if (!bookingItemId && bookableItems.length > 0) { + return false; + } + } + } + + // Additional validation: Check if there are any bookable items available + 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 (!dateRange || dateRange.length === 0) return false; + + // For range mode, need both start and end dates + if (Array.isArray(dateRange) && dateRange.length < 2) { + return false; + } + + return true; +} + +/** + * Validate date selection and return detailed result + * @param {Array} selectedDates - Selected dates from calendar + * @param {Array} circulationRules - Circulation rules for validation + * @param {Array} bookings - Existing bookings data + * @param {Array} checkouts - Existing checkouts data + * @param {Array} bookableItems - Available bookable items + * @param {string} bookingItemId - Selected item ID + * @param {string} bookingId - Current booking ID (for updates) + * @returns {Object} Validation result with dates and conflicts + */ +export function validateDateSelection( + selectedDates, + circulationRules, + bookings, + checkouts, + bookableItems, + bookingItemId, + bookingId +) { + return handleBookingDateChange( + selectedDates, + circulationRules, + bookings, + checkouts, + bookableItems, + bookingItemId, + bookingId + ); +} 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..14d39b859b2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs @@ -0,0 +1,11 @@ +import { $__ } from "../../../../i18n/index.js"; + +export function getMarkerTypeLabel(type) { + const labels = { + booked: $__("Booked"), + "checked-out": $__("Checked out"), + lead: $__("Lead period"), + trail: $__("Trail period"), + }; + 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..c331ef409e4 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs @@ -0,0 +1,34 @@ +import { idsEqual } from "../booking/id-utils.mjs"; +import { $__ } from "../../../../i18n/index.js"; + +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..1f63f57ef5e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs @@ -0,0 +1,58 @@ +/** + * 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; +} + +/** + * Determine if additional fields section should be shown + * @param {boolean} showAdditionalFields - Configuration setting for additional fields + * @param {boolean} hasAdditionalFields - Whether additional fields exist + * @returns {boolean} Whether to show additional fields section + */ +export function shouldShowAdditionalFields( + showAdditionalFields, + hasAdditionalFields +) { + return showAdditionalFields && hasAdditionalFields; +} 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..169533af340 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ES2020", + "moduleResolution": "Node", + "checkJs": true, + "skipLibCheck": true, + "allowJs": true, + "noEmit": true, + "strict": false, + "baseUrl": ".", + "paths": { + "@bookingApi": [ + "./lib/adapters/api/staff-interface.js", + "./lib/adapters/api/opac.js" + ] + }, + "types": [] + }, + "include": [ + "./**/*.js", + "./**/*.mjs", + "./**/*.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..de9a630df0c --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts @@ -0,0 +1,286 @@ +/** + * 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; +}; + +/** Resulting highlighting metadata for calendar UI. */ +export type ConstraintHighlighting = { + startDate: Date; + targetEndDate: Date; + blockedIntermediateDates: Date[]; + constraintMode: string; + maxPeriod: number; +}; + +/** 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; +}; + +/** Store actions used by composables to interact with backend. */ +export type BookingStoreActions = { + fetchPickupLocations: ( + biblionumber: Id, + patronId: Id + ) => Promise; + invalidateCalculatedDue: () => void; + fetchCirculationRules: ( + params: Record + ) => 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; +}; + +/** Options object for `useDerivedItemType` composable. */ +export type DerivedItemTypeOptions = { + bookingItemtypeId: import('vue').Ref; + bookingItemId: import('vue').Ref; + constrainedItemTypes: import('vue').Ref>; + bookableItems: import('vue').Ref>; +}; + +/** Options object for `useDefaultPickup` composable. */ +export type DefaultPickupOptions = { + bookingPickupLibraryId: import('vue').Ref; + bookingPatron: import('vue').Ref; + pickupLocations: import('vue').Ref>; + bookableItems: import('vue').Ref>; + opacDefaultBookingLibraryEnabled?: boolean | string | number; + opacDefaultBookingLibrary?: string; +}; + +/** Input shape for `useErrorState`. */ +export type ErrorStateInit = { message?: string; code?: string | null }; +/** Return shape for `useErrorState`. */ +export type ErrorStateResult = { + error: { message: string; code: string | null }; + setError: (message: string, code?: string) => void; + clear: () => void; + hasError: import('vue').ComputedRef; +}; + +/** Options for calendar `createOnChange` handler. */ +export type OnChangeOptions = { + setError?: (msg: string) => void; + tooltipVisibleRef?: { value: boolean }; + constraintOptions?: ConstraintOptions; +}; + +/** Minimal parameter set for circulation rules fetching. */ +export type RulesParams = { + patron_category_id?: string | number; + item_type_id?: Id; + library_id?: string; +}; + +/** Flatpickr instance augmented with a cache for constraint highlighting. */ +export type FlatpickrInstanceWithHighlighting = import('flatpickr/dist/types/instance').Instance & { + _constraintHighlighting?: ConstraintHighlighting | null; +}; + +/** 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; + 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/flatpickr-augmentations.d.ts b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/flatpickr-augmentations.d.ts new file mode 100644 index 00000000000..fff8fd4e91e --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/flatpickr-augmentations.d.ts @@ -0,0 +1,17 @@ +// Augment flatpickr Instance to carry cached highlighting data +declare module "flatpickr" { + interface Instance { + /** Koha Bookings: cached constraint highlighting for re-application after navigation */ + _constraintHighlighting?: import('./bookings').ConstraintHighlighting | null; + /** Koha Bookings: cached loan boundary timestamps for bold styling */ + _loanBoundaryTimes?: Set; + } +} + +// Augment DOM Element to include flatpickr's custom property used in our UI code +declare global { + interface Element { + /** set by flatpickr on day cells */ + dateObj?: Date; + } +} 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..7cd0e8cd900 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue @@ -0,0 +1,40 @@ + + + + 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..3c2638b95a2 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js @@ -0,0 +1,239 @@ +// 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"; + +/** + * 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: {}, + + // 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, + submit: false, + }, + error: { + bookableItems: null, + bookings: null, + checkouts: null, + patrons: null, + bookingPatron: null, + pickupLocations: null, + circulationRules: null, + submit: null, + }, + }), + + 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; + }); + }, + 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"), + /** + * 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/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 e05273fb7b5..85ad5f10cc1 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" @@ -95,6 +99,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: { @@ -166,6 +174,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": -- 2.51.2