Bugzilla – Attachment 188546 Details for
Bug 41129
Migrate place_booking.js to a Vue island.
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 41129: [DO NOT PUSH] Add BookingModal.vue, remove place_booking.{inc,js}
Bug-41129-DO-NOT-PUSH-Add-BookingModalvue-remove-p.patch (text/plain), 404.09 KB, created by
Paul Derscheid
on 2025-10-29 11:13:33 UTC
(
hide
)
Description:
Bug 41129: [DO NOT PUSH] Add BookingModal.vue, remove place_booking.{inc,js}
Filename:
MIME Type:
Creator:
Paul Derscheid
Created:
2025-10-29 11:13:33 UTC
Size:
404.09 KB
patch
obsolete
>From e15b6d5b5d7df757d7ef8e5e5e6cb63fe1d7a983 Mon Sep 17 00:00:00 2001 >From: Paul Derscheid <paul.derscheid@lmscloud.de> >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 ) %] >- <div class="btn-group" >- ><button id="placbooking" class="btn btn-default" data-bs-toggle="modal" data-bs-target="#placeBookingModal" data-biblionumber="[% biblionumber | html %]"><i class="fa fa-calendar"></i> Place booking</button></div >- > >+ [% 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 @@ > </div> > </div> > </div> >- >-[% 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 @@ >+<div class="btn-group"> >+ <button id="booking-modal-btn" type="button" class="btn btn-default btn-xs edit-action"><i class="fa fa-pencil" aria-hidden="true"></i> Edit</button> >+</div> >\ 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 @@ >+<div class="btn-group"> >+ <button class="btn btn-default" data-booking-modal type="button"><i class="fa fa-calendar"></i> Place Booking </button> >+</div> >\ 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 %] >+ >+<div id="booking-modal-mount"> >+ <booking-modal-island >+ biblionumber="[% biblionumber | html %]" >+ show-patron-select >+ show-item-details-selects >+ show-pickup-location-select >+ date-range-constraint="[% (Koha.Preference('BookingDateRangeConstraint') || 'issuelength_with_renewals') | html %]" [%# FIXME: issuelength_with_renewals needs to be the db default, don't constrain needs a value %] >+ ></booking-modal-island> >+</div> >+[% SET islands = Asset.js("js/vue/dist/islands.esm.js").match('(src="([^"]+)")').1 %] <script src="[% islands %]" type="module"></script> >+<script type="module"> >+ import { hydrate } from "[% islands %]"; >+ hydrate(); >+ >+ const island = document.querySelector("booking-modal-island"); >+ >+ const parseJson = value => { >+ if (!value) return []; >+ try { >+ return JSON.parse(value); >+ } catch (error) { >+ console.warn("Failed to parse booking modal payload", error); >+ return []; >+ } >+ }; >+ >+ const normalizeProps = source => { >+ if (!island || !source) return; >+ >+ const bookingId = source.booking ?? source.bookingId ?? null; >+ const itemId = source.itemnumber ?? source.itemId ?? null; >+ const patronId = source.patron ?? source.patronId ?? null; >+ const pickupLibraryId = >+ source.pickup_library ?? source.pickupLibraryId ?? null; >+ const startDate = source.start_date ?? source.startDate ?? null; >+ const endDate = source.end_date ?? source.endDate ?? null; >+ const itemtypeId = >+ source.item_type_id ?? source.itemtypeId ?? source.itemTypeId ?? null; >+ const biblionumber = >+ source.biblionumber ?? source.biblio_id ?? source.biblioId; >+ >+ island.bookingId = bookingId; >+ island.itemId = itemId; >+ island.patronId = patronId; >+ island.pickupLibraryId = pickupLibraryId; >+ island.startDate = startDate; >+ island.endDate = endDate; >+ island.itemtypeId = itemtypeId; >+ island.selectedDateRange = startDate >+ ? [startDate, endDate || startDate] >+ : []; >+ >+ const extendedAttributes = >+ source.extendedAttributes ?? source.extended_attributes ?? []; >+ island.extendedAttributes = Array.isArray(extendedAttributes) >+ ? extendedAttributes >+ : parseJson(extendedAttributes); >+ >+ if (biblionumber) { >+ island.biblionumber = biblionumber; >+ } >+ }; >+ >+ const openModal = props => { >+ if (!island) return; >+ normalizeProps(props || {}); >+ island.open = true; >+ }; >+ >+ if (island) { >+ island.addEventListener("close", () => { >+ island.open = false; >+ }); >+ /* This might need to be optimised if we ever >+ * run into noticeable lag on click events. */ >+ document.addEventListener( >+ "click", >+ e => { >+ const trigger = e.target.closest("[data-booking-modal]"); >+ if (!trigger) return; >+ openModal(trigger.dataset); >+ }, >+ { passive: true } >+ ); >+ >+ if (typeof window.openBookingModal !== "function") { >+ window.openBookingModal = openModal; >+ } >+ } >+</script> >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 %] >-<!-- Place booking modal --> >-<div class="modal" id="placeBookingModal" tabindex="-1" role="dialog" aria-labelledby="placeBookingLabel"> >- <form method="get" id="placeBookingForm"> >- <div class="modal-dialog modal-lg"> >- <div class="modal-content"> >- <div class="modal-header"> >- <h1 class="modal-title" id="placeBookingLabel"></h1> >- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> >- </div> >- <div class="modal-body"> >- <div id="booking_result"></div> >- <fieldset class="brief"> >- <input type="hidden" name="biblio_id" id="booking_id" /> >- <input type="hidden" name="biblio_id" id="booking_biblio_id" /> >- <input type="hidden" name="start_date" id="booking_start_date" /> >- <input type="hidden" name="end_date" id="booking_end_date" /> >- <ol> >- <li> >- <label class="required" for="booking_patron_id">Patron: </label> >- <select name="booking_patron_id" id="booking_patron_id" required="required"> >- <option></option> >- [% IF patron %] >- <option value="[% borrowernumber | uri %]" selected="selected">[% patron.firstname | html %] [% patron.surname | html %] ([% patron.cardnumber | html %] )</option> >- [% END %] >- </select> >- <div class="hint">Enter patron card number or partial name</div> >- </li> >- <li> >- <label class="required" for="pickup_library_id">Pickup at:</label> >- <select name="booking_pickup" id="pickup_library_id" required="required" disabled="disabled"></select> >- <span class="required">Required</span> >- </li> >- <li> >- <label for="booking_itemtype">Itemtype: </label> >- <select id="booking_itemtype" name="booking_itemtype" disabled="disabled"> </select> </li >- ><li> >- <label for="booking_item_id">Item: </label> >- <select name="booking_item_id" id="booking_item_id" disabled="disabled"> >- <option value="0">Any item</option> >- </select> >- </li> >- <li> >- <div id="period_fields"> >- <label class="required" for="period">Booking dates: </label> >- <input type="text" id="period" name="period" class="flatpickr" data-flatpickr-futuredate="true" data-flatpickr-disable-shortcuts="true" required="required" disabled="disabled" autocomplete="off" /> >- <span class="required">Required</span> >- </div> >- <div class="hint">Select the booking start and end date</div> >- </li> >- </ol> >- </fieldset> >- </div> >- <!-- /.modal-body --> >- <div class="modal-footer"> >- <button type="submit" class="btn btn-primary">Submit</button> >- <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button> >- </div> >- <!-- /.modal-footer --> >- </div> >- <!-- /.modal-content --> >- </div> >- <!-- /.modal-dialog --> >- </form> >-</div> >-<!-- /#placeBookingModal --> >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 @@ > <script> > dayjs.extend(window.dayjs_plugin_isSameOrBefore); > </script> >- [% 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 %] > <script> >- var cancel_success = 0; >- var update_success = 0; >+ var cancel_success = 0; > var bookings_table; > var timeline; > let biblionumber = "[% biblionumber | uri %]"; >@@ -166,20 +164,25 @@ > > // action events > onMove: function (data, callback) { >- let startDate = dayjs(data.start); >+ const startDate = dayjs(data.start).toISOString(); >+ const endDate = dayjs(data.end) >+ .endOf('day') >+ .toISOString(); > >- // set end datetime hours and minutes to the end of the day >- let endDate = dayjs(data.end).endOf('day'); >+ if (typeof window.openBookingModal === 'function') { >+ window.openBookingModal({ >+ booking: data.id, >+ biblionumber: "[% biblionumber | html %]", >+ itemnumber: data.group, >+ patron: data.patron, >+ pickup_library: data.pickup_library, >+ start_date: startDate, >+ end_date: endDate, >+ item_type_id: data.item_type_id, >+ }); >+ } > >- $('#placeBookingModal').modal('show', $('<button data-booking="'+data.id+'" data-biblionumber="[% biblionumber | uri %]" data-itemnumber="'+data.group+'" data-patron="'+data.patron+'" data-pickup_library="'+data.pickup_library+'" data-start_date="'+startDate.toISOString()+'" data-end_date="'+endDate.toISOString()+'">')); >- $('#placeBookingModal').on('hide.bs.modal', function(e) { >- if (update_success) { >- update_success = 0; >- callback(data); >- } else { >- callback(null); >- } >- }); >+ callback(null); > }, > onRemove: function (item, callback) { > const button = document.createElement('button'); >@@ -368,8 +371,7 @@ > if (!is_readonly) { > result += ` > <button type="button" class="btn btn-default btn-xs edit-action" >- data-bs-toggle="modal" >- data-bs-target="#placeBookingModal" >+ data-booking-modal > data-booking="%s" > data-biblionumber="%s" > data-itemnumber="%s" >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/ISBDdetail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/ISBDdetail.tt >index 85c511aa8a3..6e826aaedce 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/ISBDdetail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/ISBDdetail.tt >@@ -87,7 +87,6 @@ > [% IF Koha.Preference('EnableBooking') %] > [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'select2.inc' %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > > [% Asset.js("js/browser.js") | $raw %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/MARCdetail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/MARCdetail.tt >index 66a5774709d..9fb7ce5c30b 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/MARCdetail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/MARCdetail.tt >@@ -211,7 +211,6 @@ > [% IF Koha.Preference('EnableBooking') %] > [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'select2.inc' %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > > [% Asset.js("js/browser.js") | $raw %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >index 2fd1d4cff86..27851f2fa52 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >@@ -1723,7 +1723,6 @@ > [% Asset.js("js/browser.js") | $raw %] > > [% IF Koha.Preference('EnableBooking') %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > <script> > var browser; >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/imageviewer.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/imageviewer.tt >index ef094d22889..d4570e0a2ab 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/imageviewer.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/imageviewer.tt >@@ -141,7 +141,6 @@ > [% IF Koha.Preference('EnableBooking') %] > [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'select2.inc' %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > > [% IF ( Koha.Preference('CatalogConcerns') ) %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/labeledMARCdetail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/labeledMARCdetail.tt >index aff14ecbb70..817c9e8ecad 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/labeledMARCdetail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/labeledMARCdetail.tt >@@ -101,7 +101,6 @@ > [% IF Koha.Preference('EnableBooking') %] > [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'select2.inc' %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > > [% IF ( Koha.Preference('CatalogConcerns') ) %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/moredetail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/moredetail.tt >index 93faa13bec9..85403a54dbc 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/moredetail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/moredetail.tt >@@ -593,7 +593,6 @@ > [% IF Koha.Preference('EnableBooking') %] > [% INCLUDE 'calendar.inc' %] > [% INCLUDE 'select2.inc' %] >- [% Asset.js("js/modals/place_booking.js") | $raw %] > [% END %] > > [% IF ( Koha.Preference('CatalogConcerns') ) %] >diff --git a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js b/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js >deleted file mode 100644 >index 634cce0c36e..00000000000 >--- a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js >+++ /dev/null >@@ -1,1312 +0,0 @@ >-let dataFetched = false; >-let bookable_items, >- bookings, >- checkouts, >- booking_id, >- booking_item_id, >- booking_patron, >- booking_itemtype_id; >- >-function containsAny(integers1, integers2) { >- // Create a hash set to store integers from the second array >- let integerSet = {}; >- for (let i = 0; i < integers2.length; i++) { >- integerSet[integers2[i]] = true; >- } >- >- // Check if any integer from the first array exists in the hash set >- for (let i = 0; i < integers1.length; i++) { >- if (integerSet[integers1[i]]) { >- return true; // Found a match, return true >- } >- } >- >- return false; // No match found >-} >- >-$("#placeBookingModal").on("show.bs.modal", function (e) { >- // Get context >- let button = $(e.relatedTarget); >- let biblionumber = button.data("biblionumber"); >- $("#booking_biblio_id").val(biblionumber); >- >- let patron_id = button.data("patron") || 0; >- let pickup_library_id = button.data("pickup_library"); >- booking_item_id = button.data("itemnumber"); >- let start_date = button.data("start_date"); >- let end_date = button.data("end_date"); >- let item_type_id = button.data("item_type_id"); >- >- // Get booking id if this is an edit >- booking_id = button.data("booking"); >- if (booking_id) { >- $("#placeBookingLabel").html(__("Edit booking")); >- $("#booking_id").val(booking_id); >- } else { >- $("#placeBookingLabel").html(__("Place booking")); >- // Ensure we don't accidentally update a booking >- $("#booking_id").val(""); >- } >- >- // Patron select2 >- $("#booking_patron_id").kohaSelect({ >- dropdownParent: $(".modal-content", "#placeBookingModal"), >- width: "50%", >- dropdownAutoWidth: true, >- allowClear: true, >- minimumInputLength: 3, >- ajax: { >- url: "/api/v1/patrons", >- delay: 250, >- dataType: "json", >- headers: { >- "x-koha-embed": "library", >- }, >- data: function (params) { >- let q = buildPatronSearchQuery(params.term); >- let query = { >- q: JSON.stringify(q), >- _page: params.page, >- _order_by: "+me.surname,+me.firstname", >- }; >- return query; >- }, >- processResults: function (data, params) { >- let results = []; >- data.results.forEach(function (patron) { >- patron.id = patron.patron_id; >- results.push(patron); >- }); >- return { >- results: results, >- pagination: { more: data.pagination.more }, >- }; >- }, >- }, >- templateResult: function (patron) { >- if (patron.library_id == loggedInLibrary) { >- loggedInClass = "ac-currentlibrary"; >- } else { >- loggedInClass = ""; >- } >- >- let $patron = $("<span></span>") >- .append( >- "" + >- (patron.surname >- ? escape_str(patron.surname) + ", " >- : "") + >- (patron.firstname >- ? escape_str(patron.firstname) + " " >- : "") + >- (patron.cardnumber >- ? " (" + escape_str(patron.cardnumber) + ")" >- : "") + >- "<small>" + >- (patron.date_of_birth >- ? ' <span class="age_years">' + >- $get_age(patron.date_of_birth) + >- " " + >- __("years") + >- "</span>" >- : "") + >- (patron.library >- ? ' <span class="ac-library">' + >- escape_str(patron.library.name) + >- "</span>" >- : "") + >- "</small>" >- ) >- .addClass(loggedInClass); >- return $patron; >- }, >- templateSelection: function (patron) { >- if (!patron.surname) { >- return patron.text; >- } >- return patron.surname + ", " + patron.firstname; >- }, >- placeholder: __("Search for a patron"), >- }); >- >- // Circulation rules update >- let leadDays = 0; >- let trailDays = 0; >- let boldDates = []; >- let issueLength; >- let renewalLength; >- let renewalsAllowed; >- >- // Note: For now, we apply the pickup library rules for issuelength, renewalsallowed and renewalperiod. >- // This effectively makes these circulation rules hard coded to CircControl: ItemHomeLibrary + HomeOrHolding: holdingbranch >- // Whilst it would be beneficial to make this follow those rules more closely, this would require some significant thinking >- // around how to best display this in the calendar component for the 'Any item' case. >- function getCirculationRules() { >- let rules_url = "/api/v1/circulation_rules"; >- if (booking_patron && pickup_library_id && booking_itemtype_id) { >- $.ajax({ >- url: rules_url, >- type: "GET", >- dataType: "json", >- data: { >- patron_category_id: booking_patron.category_id, >- item_type_id: booking_itemtype_id, >- library_id: pickup_library_id, >- rules: "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod", >- }, >- success: function (response) { >- let rules = response[0]; >- let changed = >- issueLength !== rules.issuelength || >- renewalsAllowed !== rules.renewalsallowed || >- renewalLength !== rules.renewalperiod; >- issueLength = rules.issuelength; >- renewalsAllowed = rules.renewalsallowed; >- renewalLength = rules.renewalperiod; >- leadDays = rules.bookings_lead_period; >- trailDays = rules.bookings_trail_period; >- >- // redraw pariodPicker taking selected item into account >- if (changed) { >- periodPicker.clear(); >- } >- periodPicker.redraw(); >- >- // Enable flatpickr now we have data we need >- if (dataFetched) { >- $("#period_fields :input").prop("disabled", false); >- } >- }, >- error: function (xhr, status, error) { >- console.log("Circulation rules fetch failed: ", error); >- }, >- }); >- } else { >- periodPicker.clear(); >- $("#period_fields :input").prop("disabled", true); >- } >- } >- >- // Pickup location select2 >- let pickup_url = "/api/v1/biblios/" + biblionumber + "/pickup_locations"; >- $("#pickup_library_id").select2({ >- dropdownParent: $(".modal-content", "#placeBookingModal"), >- width: "50%", >- dropdownAutoWidth: true, >- minimumResultsForSearch: 10, >- allowClear: false, >- placeholder: __("Pickup location"), >- }); >- function setLocationsPicker(response) { >- let $pickupSelect = $("#pickup_library_id"); >- let $itemTypeSelect = $("#booking_itemtype"); >- let bookableItemnumbers = bookable_items.map(function (object) { >- return object.item_id; >- }); >- $pickupSelect.empty(); >- >- $.each(response, function (index, pickup_location) { >- if ( >- containsAny(pickup_location.pickup_items, bookableItemnumbers) >- ) { >- let option = $( >- '<option value="' + >- pickup_location.library_id + >- '">' + >- pickup_location.name + >- "</option>" >- ); >- >- option.attr( >- "data-needs_override", >- pickup_location.needs_override >- ); >- option.attr( >- "data-pickup_items", >- pickup_location.pickup_items.join(",") >- ); >- >- $pickupSelect.append(option); >- } >- }); >- >- $pickupSelect.prop("disabled", false); >- >- // If pickup_library already exists, pre-select >- if (pickup_library_id) { >- $pickupSelect.val(pickup_library_id).trigger("change"); >- } else { >- $pickupSelect.val(null).trigger("change"); >- } >- >- // If item_type_id already exists, pre-select >- if (item_type_id) { >- $itemTypeSelect.val(item_type_id).trigger("change"); >- } else { >- $itemTypeSelect.val(null).trigger("change"); >- } >- } >- >- // Itemtype select2 >- $("#booking_itemtype").select2({ >- dropdownParent: $(".modal-content", "#placeBookingModal"), >- width: "50%", >- allowClear: true, >- dropdownAutoWidth: true, >- minimumResultsForSearch: 20, >- placeholder: __("Item type"), >- }); >- >- // Item select2 >- $("#booking_item_id").select2({ >- dropdownParent: $(".modal-content", "#placeBookingModal"), >- width: "50%", >- dropdownAutoWidth: true, >- minimumResultsForSearch: 10, >- allowClear: false, >- }); >- >- // Patron selection triggers >- $("#booking_patron_id").on("select2:select", function (e) { >- booking_patron = e.params.data; >- >- // Fetch pickup locations and enable picker >- $.ajax({ >- url: pickup_url, >- type: "GET", >- dataType: "json", >- data: { >- _order_by: "name", >- _per_page: "-1", >- patron_id: booking_patron.patron_id, >- }, >- success: function (response) { >- if (dataFetched === true) { >- setLocationsPicker(response); >- } else { >- var interval = setInterval(function () { >- if (dataFetched === true) { >- // Data is fetched, execute the callback and stop the interval >- setLocationsPicker(response); >- clearInterval(interval); >- } >- }, 100); >- } >- }, >- error: function (xhr, status, error) { >- console.log("Pickup location fetch failed: ", error); >- }, >- }); >- >- // Enable item selection if item data is also fetched >- let $bookingItemSelect = $("#booking_item_id"); >- $bookingItemSelect.data("patron", true); >- if ($bookingItemSelect.data("loaded")) { >- $bookingItemSelect.prop("disabled", false); >- } >- >- // Enable itemtype selection if item data if also fetched >- let $bookingItemtypeSelect = $("#booking_itemtype"); >- $bookingItemtypeSelect.data("patron", true); >- if ($bookingItemtypeSelect.data("loaded")) { >- $bookingItemtypeSelect.prop("disabled", false); >- } >- >- // Populate circulation rules >- getCirculationRules(); >- }); >- >- // Adopt periodPicker >- let periodPicker = $("#period").get(0)._flatpickr; >- >- if (!dataFetched) { >- // Fetch list of bookable items >- let itemsFetch = $.ajax({ >- url: >- "/api/v1/biblios/" + >- biblionumber + >- "/items?bookable=1" + >- "&_per_page=-1", >- dataType: "json", >- type: "GET", >- headers: { >- "x-koha-embed": "item_type", >- }, >- }); >- >- // Fetch list of existing bookings >- let bookingsFetch = $.ajax({ >- url: >- "/api/v1/bookings?biblio_id=" + >- biblionumber + >- "&_per_page=-1" + >- '&q={"status":{"-in":["new","pending","active"]}}', >- dataType: "json", >- type: "GET", >- }); >- >- // Fetch list of current checkouts >- let checkoutsFetch = $.ajax({ >- url: "/api/v1/biblios/" + biblionumber + "/checkouts?_per_page=-1", >- dataType: "json", >- type: "GET", >- }); >- >- // Update item select2 and period flatpickr >- $.when(itemsFetch, bookingsFetch, checkoutsFetch).then( >- function (itemsFetch, bookingsFetch, checkoutsFetch) { >- // Set variables >- bookable_items = itemsFetch[0]; >- bookings = bookingsFetch[0]; >- checkouts = checkoutsFetch[0]; >- >- // Merge current checkouts into bookings >- for (checkout of checkouts) { >- let already_booked = bookings.some( >- b => b.item_id === checkout.item_id >- ); >- if (!already_booked) { >- let booking = { >- biblio_id: biblionumber, >- booking_id: null, >- end_date: checkout.due_date, >- item_id: checkout.item_id, >- patron_id: checkout.patron_id, >- start_date: new Date().toISOString(), >- }; >- bookings.unshift(booking); >- } >- } >- >- // Update flatpickr mode >- periodPicker.set("mode", "range"); >- >- // Total bookable items >- let bookable = 0; >- for (item of bookable_items) { >- bookable++; >- >- // Populate item select >- if ( >- !$("#booking_item_id").find( >- "option[value='" + item.item_id + "']" >- ).length >- ) { >- // Create a DOM Option and de-select by default >- let newOption = new Option( >- escape_str(item.external_id), >- item.item_id, >- false, >- false >- ); >- newOption.setAttribute("data-available", true); >- newOption.setAttribute( >- "data-itemtype", >- item.effective_item_type_id >- ); >- >- // Append it to the select >- $("#booking_item_id").append(newOption); >- } >- >- // Populate item types select >- if ( >- !$("#booking_itemtype").find( >- "option[value='" + >- item.item_type.item_type_id + >- "']" >- ).length >- ) { >- // Create a DOM Option and de-select by default >- let newTypeOption = new Option( >- escape_str(item.item_type.description), >- item.item_type.item_type_id, >- false, >- false >- ); >- $("#booking_itemtype").append(newTypeOption); >- } >- } >- $("#booking_itemtype").val(null).trigger("change"); >- >- // Set disable function for periodPicker >- let disableExists = periodPicker.config.disable.filter( >- f => f.name === "dateDisable" >- ); >- if (disableExists.length === 0) { >- periodPicker.config.disable.push( >- function dateDisable(date) { >- // set local copy of selectedDates >- let selectedDates = periodPicker.selectedDates; >- >- // set booked counter >- let booked = 0; >- >- // reset the unavailable items array >- let unavailable_items = []; >- >- // reset the biblio level bookings array >- let biblio_bookings = []; >- >- // disable dates before selected date >- if ( >- !selectedDates[1] && >- selectedDates[0] && >- selectedDates[0] > date >- ) { >- return true; >- } >- >- // iterate existing bookings >- for (booking of bookings) { >- // Skip if we're editing this booking >- if ( >- booking_id && >- booking_id == booking.booking_id >- ) { >- continue; >- } >- >- let start_date = flatpickr.parseDate( >- booking.start_date >- ); >- let end_date = flatpickr.parseDate( >- booking.end_date >- ); >- >- // patron has selected a start date (end date checks) >- if (selectedDates[0]) { >- // new booking start date is between existing booking start and end dates >- if ( >- selectedDates[0] >= start_date && >- selectedDates[0] <= end_date >- ) { >- if (booking.item_id) { >- if ( >- unavailable_items.indexOf( >- booking.item_id >- ) === -1 >- ) { >- unavailable_items.push( >- booking.item_id >- ); >- } >- } else { >- if ( >- biblio_bookings.indexOf( >- booking.booking_id >- ) === -1 >- ) { >- biblio_bookings.push( >- booking.booking_id >- ); >- } >- } >- } >- >- // new booking end date would be between existing booking start and end dates >- else if ( >- date >= start_date && >- date <= end_date >- ) { >- if (booking.item_id) { >- if ( >- unavailable_items.indexOf( >- booking.item_id >- ) === -1 >- ) { >- unavailable_items.push( >- booking.item_id >- ); >- } >- } else { >- if ( >- biblio_bookings.indexOf( >- booking.booking_id >- ) === -1 >- ) { >- biblio_bookings.push( >- booking.booking_id >- ); >- } >- } >- } >- >- // new booking would span existing booking >- else if ( >- selectedDates[0] <= start_date && >- date >= end_date >- ) { >- if (booking.item_id) { >- if ( >- unavailable_items.indexOf( >- booking.item_id >- ) === -1 >- ) { >- unavailable_items.push( >- booking.item_id >- ); >- } >- } else { >- if ( >- biblio_bookings.indexOf( >- booking.booking_id >- ) === -1 >- ) { >- biblio_bookings.push( >- booking.booking_id >- ); >- } >- } >- } >- >- // new booking would not conflict >- else { >- continue; >- } >- >- // check that there are available items >- // available = all bookable items - booked items - booked biblios >- let total_available = >- bookable_items.length - >- unavailable_items.length - >- biblio_bookings.length; >- if (total_available === 0) { >- return true; >- } >- } >- >- // patron has not yet selected a start date (start date checks) >- else if ( >- date <= end_date && >- date >= start_date >- ) { >- // same item, disable date >- if ( >- booking.item_id && >- booking.item_id == booking_item_id >- ) { >- return true; >- } >- >- // count all clashes, both item and biblio level >- booked++; >- if (booked == bookable) { >- return true; >- } >- >- // FIXME: The above is not intelligent enough to spot >- // cases where an item must be used for a biblio level booking >- // due to all other items being booking within the biblio level >- // booking period... we end up with a clash >- // To reproduce: >- // * One bib with two bookable items. >- // * Add item level booking >- // * Add biblio level booking that extends one day beyond the item level booking >- // * Try to book the item without an item level booking from the day before the biblio level >- // booking is to be returned. Note this is a clash, the only item available for the biblio >- // level booking is the item you just booked out overlapping the end date. >- } >- } >- } >- ); >- } >- >- // Setup listener for itemtype select2 >- $("#booking_itemtype").on("change", function (e) { >- let selectedValue = $(this).val(); // Get selected value (null if cleared) >- booking_itemtype_id = selectedValue ? selectedValue : null; >- >- // Handle item selectionue >- if (!booking_itemtype_id) { >- // Enable all items for selection >- $("#booking_item_id > option").prop("disabled", false); >- } else { >- // Disable items not of this itemtype >- $("#booking_item_id > option").each(function () { >- let option = $(this); >- if (option.val() != 0) { >- let item_itemtype = option.data("itemtype"); >- if (item_itemtype == booking_itemtype_id) { >- if ( >- option.data("available") && >- option.data("pickup") >- ) { >- option.prop("disabled", false); >- } >- } else { >- option.prop("disabled", true); >- } >- } >- }); >- } >- $("#booking_item_id").trigger("change.select2"); >- >- // Update circulation rules >- getCirculationRules(); >- }); >- >- // Setup listener for item select2 >- $("#booking_item_id").on("select2:select", function (e) { >- booking_item_id = e.params.data.id >- ? e.params.data.id >- : null; >- >- // Disable invalid pickup locations >- $("#pickup_library_id > option").each(function () { >- let option = $(this); >- if (booking_item_id == 0) { >- option.prop("disabled", false); >- } else { >- let valid_items = String( >- option.data("pickup_items") >- ) >- .split(",") >- .map(Number); >- if ( >- valid_items.includes(parseInt(booking_item_id)) >- ) { >- option.prop("disabled", false); >- } else { >- option.prop("disabled", true); >- } >- } >- }); >- $("#pickup_library_id").trigger("change.select2"); >- >- // Disable patron selection change >- $("#booking_patron_id").prop("disabled", true); >- >- // handle itemtype picker >- if (booking_item_id != 0) { >- let itemtype = e.params.data.element.dataset.itemtype; >- booking_itemtype_id = itemtype; >- >- $("#booking_itemtype").val(itemtype); >- $("#booking_itemtype").trigger("change.select2"); >- $("#booking_itemtype").prop("disabled", true); >- } else { >- $("#booking_itemtype").prop("disabled", false); >- } >- >- // Update circulation rules >- getCirculationRules(); >- }); >- >- // Setup listener for pickup location select2 >- $("#pickup_library_id").on("select2:select", function (e) { >- let valid_items = >- e.params.data.element.dataset.pickup_items.split(","); >- valid_items.push("0"); >- >- // Disable items not available at the pickup location >- $("#booking_item_id > option").each(function () { >- let option = $(this); >- let item_id = option.val(); >- if (valid_items.includes(item_id)) { >- option.attr("data-pickup", true); >- if (option.data("available")) { >- option.prop("disabled", false); >- } >- } else { >- option.prop("disabled", true); >- option.attr("data-pickup", false); >- } >- }); >- $("#booking_item_id").trigger("change.select2"); >- >- // Disable patron selection change >- $("#booking_patron_id").prop("disabled", true); >- >- pickup_library_id = $("#pickup_library_id").val(); >- >- // Populate circulation rules >- getCirculationRules(); >- }); >- >- // Set onChange for flatpickr >- let changeExists = periodPicker.config.onChange.filter( >- f => f.name === "periodChange" >- ); >- if (changeExists.length === 0) { >- periodPicker.config.onChange.push( >- function periodChange( >- selectedDates, >- dateStr, >- instance >- ) { >- // Start date selected >- if (selectedDates[0] && !selectedDates[1]) { >- const startDate = new Date(selectedDates[0]); >- >- // Custom format function to make specific dates bold >- boldDates = [new Date(startDate)]; >- // Add issueLength days after the startDate >- const nextDate = new Date(startDate); >- nextDate.setDate( >- nextDate.getDate() + parseInt(issueLength) >- ); >- boldDates.push(new Date(nextDate)); >- >- // Add subsequent dates based on renewalsAllowed and renewalLength >- for (let i = 0; i < renewalsAllowed; i++) { >- nextDate.setDate( >- nextDate.getDate() + >- parseInt(renewalLength) >- ); >- boldDates.push(new Date(nextDate)); >- } >- >- // Calculate the maximum date based on the selected start date >- let totalRenewalLength = >- parseInt(renewalsAllowed) * >- parseInt(renewalLength); >- let totalIssueLength = >- parseInt(issueLength) + >- parseInt(totalRenewalLength); >- >- const maxDate = new Date(startDate.getTime()); >- maxDate.setDate( >- maxDate.getDate() + totalIssueLength >- ); >- >- // Update the maxDate option of the flatpickr instance >- instance.set("maxDate", maxDate); >- } >- // Range set, update hidden fields and set available items >- else if (selectedDates[0] && selectedDates[1]) { >- // set form fields from picker >- let picker_start = dayjs(selectedDates[0]); >- let picker_end = dayjs(selectedDates[1]).endOf( >- "day" >- ); >- $("#booking_start_date").val( >- picker_start.toISOString() >- ); >- $("#booking_end_date").val( >- picker_end.toISOString() >- ); >- >- // set available items in select2 >- let booked_items = bookings.filter( >- function (booking) { >- let start_date = flatpickr.parseDate( >- booking.start_date >- ); >- let end_date = flatpickr.parseDate( >- booking.end_date >- ); >- // This booking ends before the start of the new booking >- if (end_date <= selectedDates[0]) { >- return false; >- } >- // This booking starts after then end of the new booking >- if (start_date >= selectedDates[1]) { >- return false; >- } >- // This booking overlaps >- return true; >- } >- ); >- $("#booking_item_id > option").each( >- function () { >- let option = $(this); >- if ( >- booking_item_id && >- booking_item_id == option.val() >- ) { >- option.prop("disabled", false); >- } else if ( >- booked_items.some( >- function (booked_item) { >- return ( >- option.val() == >- booked_item.item_id >- ); >- } >- ) >- ) { >- option.attr( >- "data-available", >- false >- ); >- option.prop("disabled", true); >- } else { >- option.attr("data-available", true); >- if (option.data("pickup")) { >- option.prop("disabled", false); >- } >- } >- } >- ); >- $("#booking_item_id").trigger("change.select2"); >- } >- // Range not set, reset field options and flatPickr state >- else { >- boldDates = []; >- instance.set("maxDate", null); >- $("#booking_item_id > option").each( >- function () { >- let option = $(this); >- if (option.data("pickup")) { >- option.prop("disabled", false); >- } >- } >- ); >- $("#booking_item_id").trigger("change.select2"); >- } >- } >- ); >- } >- >- // Create a bookings store keyed on date >- let bookingsByDate = {}; >- // Iterate through the bookings array >- bookings.forEach(booking => { >- const start_date = flatpickr.parseDate(booking.start_date); >- const end_date = flatpickr.parseDate(booking.end_date); >- const item_id = booking.item_id; >- >- // Iterate through each date within the range of start_date and end_date >- let currentDate = new Date(start_date); >- while (currentDate <= end_date) { >- const currentDateStr = currentDate >- .toISOString() >- .split("T")[0]; >- >- // If the date key doesn't exist in the hash, create an empty array for it >- if (!bookingsByDate[currentDateStr]) { >- bookingsByDate[currentDateStr] = []; >- } >- >- // Push the booking ID to the array corresponding to the date key >- bookingsByDate[currentDateStr].push(item_id); >- >- // Move to the next day >- currentDate.setDate(currentDate.getDate() + 1); >- } >- }); >- >- // Set onDayCreate for flatpickr >- let dayCreateExists = periodPicker.config.onDayCreate.filter( >- f => f.name === "dayCreate" >- ); >- if (dayCreateExists.length === 0) { >- periodPicker.config.onDayCreate.push( >- function dayCreate(dObj, dStr, instance, dayElem) { >- const currentDate = dayElem.dateObj; >- const dateString = currentDate >- .toISOString() >- .split("T")[0]; >- >- const isBold = boldDates.some( >- boldDate => >- boldDate.getTime() === currentDate.getTime() >- ); >- if (isBold) { >- dayElem.classList.add("title"); >- } >- >- if (bookingsByDate[dateString]) { >- const dots = document.createElement("span"); >- dots.className = "event-dots"; >- dayElem.appendChild(dots); >- bookingsByDate[dateString].forEach(item => { >- const dot = document.createElement("span"); >- dot.className = "event item_" + item; >- dots.appendChild(dot); >- }); >- } >- } >- ); >- } >- >- // Add hints for days before the start range and after the end range >- periodPicker.calendarContainer.addEventListener( >- "mouseover", >- function (event) { >- const target = event.target; >- if (target.classList.contains("flatpickr-day")) { >- const hoverDate = dayjs(target.dateObj).startOf( >- "day" >- ); >- const startDate = periodPicker.selectedDates[0] >- ? dayjs(periodPicker.selectedDates[0]).startOf( >- "day" >- ) >- : null; >- >- const leadStart = startDate >- ? startDate.subtract(leadDays, "day") >- : hoverDate.subtract(leadDays, "day"); >- const leadEnd = startDate ? startDate : hoverDate; >- const trailStart = hoverDate; >- const trailEnd = hoverDate.add(trailDays, "day"); >- >- let leadDisable = false; >- let trailDisable = false; >- periodPicker.calendarContainer >- .querySelectorAll(".flatpickr-day") >- .forEach(function (dayElem) { >- const elemDate = dayjs( >- dayElem.dateObj >- ).startOf("day"); >- >- dayElem.classList.toggle( >- "leadRangeStart", >- elemDate.isSame(leadStart) >- ); >- dayElem.classList.toggle( >- "leadRange", >- elemDate.isSameOrAfter(leadStart) && >- elemDate.isBefore(leadEnd) >- ); >- dayElem.classList.toggle( >- "leadRangeEnd", >- elemDate.isSame(leadEnd) >- ); >- dayElem.classList.toggle( >- "trailRangeStart", >- elemDate.isSame(trailStart) >- ); >- dayElem.classList.toggle( >- "trailRange", >- elemDate.isAfter(trailStart) && >- elemDate.isSameOrBefore(trailEnd) >- ); >- dayElem.classList.toggle( >- "trailRangeEnd", >- elemDate.isSame(trailEnd) >- ); >- // If we're overlapping a disabled date, disable our hoverDate >- if ( >- dayElem.classList.contains( >- "flatpickr-disabled" >- ) >- ) { >- if ( >- !periodPicker.selectedDates[0] && >- elemDate.isSameOrAfter(leadStart) && >- elemDate.isBefore(leadEnd) >- ) { >- leadDisable = true; >- } >- if ( >- elemDate.isAfter(trailStart) && >- elemDate.isSameOrBefore(trailEnd) >- ) { >- trailDisable = true; >- } >- } >- dayElem.classList.remove("leadDisable"); >- dayElem.classList.remove("trailDisable"); >- dayElem.removeEventListener( >- "click", >- disableClick, >- true >- ); >- }); >- >- if (leadDisable) { >- target.classList.add("leadDisable"); >- } >- if (trailDisable) { >- target.classList.add("trailDisable"); >- } >- if (trailDisable || leadDisable) { >- target.addEventListener( >- "click", >- disableClick, >- true >- ); >- } >- } >- } >- ); >- >- function disableClick(e) { >- e.stopImmediatePropagation(); >- } >- >- // Enable flatpickr now we have date function populated >- periodPicker.redraw(); >- >- // Redraw itemtype select with new options and enable >- let $bookingItemtypeSelect = $("#booking_itemtype"); >- $bookingItemtypeSelect.trigger("change"); >- $bookingItemtypeSelect.data("loaded", true); >- if ($bookingItemtypeSelect.data("patron")) { >- $bookingItemtypeSelect.prop("disabled", false); >- } >- >- // Redraw item select with new options and enable >- let $bookingItemSelect = $("#booking_item_id"); >- $bookingItemSelect.trigger("change"); >- $bookingItemSelect.data("loaded", true); >- if ($bookingItemSelect.data("patron")) { >- $bookingItemSelect.prop("disabled", false); >- } >- >- // Set the flag to indicate that data has been fetched >- dataFetched = true; >- >- // Set form values >- setFormValues( >- patron_id, >- booking_item_id, >- start_date, >- end_date, >- periodPicker >- ); >- }, >- function (jqXHR, textStatus, errorThrown) { >- console.log("Fetch failed"); >- } >- ); >- } else { >- setFormValues( >- patron_id, >- booking_item_id, >- start_date, >- end_date, >- periodPicker >- ); >- } >-}); >- >-function setFormValues( >- patron_id, >- booking_item_id, >- start_date, >- end_date, >- periodPicker >-) { >- // If passed patron, pre-select >- if (patron_id) { >- let patronSelect = $("#booking_patron_id"); >- let patron = $.ajax({ >- url: "/api/v1/patrons/" + patron_id, >- dataType: "json", >- type: "GET", >- }); >- >- $.when(patron).done(function (patron) { >- // clone patron_id to id (select2 expects an id field) >- patron.id = patron.patron_id; >- patron.text = >- escape_str(patron.surname) + >- ", " + >- escape_str(patron.firstname); >- >- // Add and select new option >- let newOption = new Option(patron.text, patron.id, true, true); >- patronSelect.append(newOption).trigger("change"); >- >- // manually trigger the `select2:select` event >- patronSelect.trigger({ >- type: "select2:select", >- params: { >- data: patron, >- }, >- }); >- }); >- } >- >- // Set booking start & end if this is an edit >- if (start_date) { >- // Allow invalid pre-load so setDate can set date range >- // periodPicker.set('allowInvalidPreload', true); >- // FIXME: Why is this the case.. we're passing two valid Date objects >- let start = new Date(start_date); >- let end = new Date(end_date); >- >- let dates = [new Date(start_date), new Date(end_date)]; >- periodPicker.setDate(dates, true); >- } >- // Reset periodPicker, biblio_id may have been nulled >- else { >- periodPicker.redraw(); >- } >- >- // If passed an itemnumber, pre-select >- if (booking_item_id) { >- $("#booking_item_id").val(booking_item_id).trigger("change"); >- } >-} >- >-$("#placeBookingForm").on("submit", function (e) { >- e.preventDefault(); >- >- let url = "/api/v1/bookings"; >- >- let start_date = $("#booking_start_date").val(); >- let end_date = $("#booking_end_date").val(); >- let pickup_library_id = $("#pickup_library_id").val(); >- let biblio_id = $("#booking_biblio_id").val(); >- let item_id = $("#booking_item_id").val(); >- >- if (!booking_id) { >- let posting = $.post( >- url, >- JSON.stringify({ >- start_date: start_date, >- end_date: end_date, >- pickup_library_id: pickup_library_id, >- biblio_id: biblio_id, >- item_id: item_id != 0 ? item_id : null, >- patron_id: $("#booking_patron_id").find(":selected").val(), >- }) >- ); >- >- posting.done(function (data) { >- // Update bookings store for subsequent bookings >- bookings.push(data); >- >- // Update bookings page as required >- if ( >- typeof bookings_table !== "undefined" && >- bookings_table !== null >- ) { >- bookings_table.api().ajax.reload(); >- } >- if (typeof timeline !== "undefined" && timeline !== null) { >- timeline.itemsData.add({ >- id: data.booking_id, >- booking: data.booking_id, >- patron: data.patron_id, >- start: dayjs(data.start_date).toDate(), >- end: dayjs(data.end_date).toDate(), >- content: $patron_to_html(booking_patron, { >- display_cardnumber: true, >- url: false, >- }), >- editable: { remove: true, updateTime: true }, >- type: "range", >- group: data.item_id ? data.item_id : 0, >- }); >- timeline.focus(data.booking_id); >- } >- >- // Update bookings counts >- $(".bookings_count").html( >- parseInt($(".bookings_count").html(), 10) + 1 >- ); >- >- // Set feedback >- $("#transient_result").replaceWith( >- '<div id="transient_result" class="alert alert-info">' + >- __("Booking successfully placed") + >- "</div>" >- ); >- >- // Close modal >- $("#placeBookingModal").modal("hide"); >- }); >- >- posting.fail(function (data) { >- $("#booking_result").replaceWith( >- '<div id="booking_result" class="alert alert-danger">' + >- __("Failure") + >- "</div>" >- ); >- }); >- } else { >- url += "/" + booking_id; >- let putting = $.ajax({ >- method: "PUT", >- url: url, >- contentType: "application/json", >- data: JSON.stringify({ >- booking_id: booking_id, >- start_date: start_date, >- end_date: end_date, >- pickup_library_id: pickup_library_id, >- biblio_id: biblio_id, >- item_id: item_id != 0 ? item_id : null, >- patron_id: $("#booking_patron_id").find(":selected").val(), >- }), >- }); >- >- putting.done(function (data) { >- update_success = 1; >- >- // Update bookings store for subsequent bookings >- let target = bookings.find( >- obj => obj.booking_id === data.booking_id >- ); >- Object.assign(target, data); >- >- // Update bookings page as required >- if ( >- typeof bookings_table !== "undefined" && >- bookings_table !== null >- ) { >- bookings_table.api().ajax.reload(); >- } >- if (typeof timeline !== "undefined" && timeline !== null) { >- timeline.itemsData.update({ >- id: data.booking_id, >- booking: data.booking_id, >- patron: data.patron_id, >- start: dayjs(data.start_date).toDate(), >- end: dayjs(data.end_date).toDate(), >- content: $patron_to_html(booking_patron, { >- display_cardnumber: true, >- url: false, >- }), >- editable: { remove: true, updateTime: true }, >- type: "range", >- group: data.item_id ? data.item_id : 0, >- }); >- timeline.focus(data.booking_id); >- } >- >- // Set feedback >- $("#transient_result").replaceWith( >- '<div id="transient_result" class="alert alert-info">' + >- __("Booking successfully updated") + >- "</div>" >- ); >- >- // Close modal >- $("#placeBookingModal").modal("hide"); >- }); >- >- putting.fail(function (data) { >- $("#booking_result").replaceWith( >- '<div id="booking_result" class="alert alert-danger">' + >- __("Failure") + >- "</div>" >- ); >- }); >- } >-}); >- >-$("#placeBookingModal").on("hidden.bs.modal", function (e) { >- // Reset patron select >- $("#booking_patron_id").val(null).trigger("change"); >- $("#booking_patron_id").empty(); >- $("#booking_patron_id").prop("disabled", false); >- booking_patron = undefined; >- >- // Reset item select >- $("#booking_item_id").val(0).trigger("change"); >- $("#booking_item_id").prop("disabled", true); >- >- // Reset itemtype select >- $("#booking_itemtype").val(null).trigger("change"); >- $("#booking_itemtype").prop("disabled", true); >- booking_itemtype_id = undefined; >- >- // Reset pickup library select >- $("#pickup_library_id").val(null).trigger("change"); >- $("#pickup_library_id").empty(); >- $("#pickup_library_id").prop("disabled", true); >- >- // Reset booking period picker >- $("#period").get(0)._flatpickr.clear(); >- $("#period").prop("disabled", true); >- $("#booking_start_date").val(""); >- $("#booking_end_date").val(""); >- $("#booking_id").val(""); >-}); >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/.eslintrc.json b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/.eslintrc.json >new file mode 100644 >index 00000000000..0a26a5914ed >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/.eslintrc.json >@@ -0,0 +1,42 @@ >+{ >+ "env": { >+ "browser": true, >+ "es2021": true >+ }, >+ "extends": [ >+ "eslint:recommended", >+ "plugin:prettier/recommended" >+ ], >+ "parserOptions": { >+ "ecmaVersion": 2021, >+ "sourceType": "module" >+ }, >+ "rules": { >+ "indent": [ >+ "error", >+ 4 >+ ], >+ "linebreak-style": [ >+ "error", >+ "unix" >+ ], >+ "semi": [ >+ "error", >+ "always" >+ ], >+ "no-unused-vars": [ >+ "warn", >+ { >+ "argsIgnorePattern": "^_", >+ "varsIgnorePattern": "^_" >+ } >+ ], >+ "switch-colon-spacing": [ >+ "error", >+ { >+ "after": true, >+ "before": false >+ } >+ ] >+ } >+} >\ No newline at end of file >diff --git a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue >new file mode 100644 >index 00000000000..22c8bd10880 >--- /dev/null >+++ b/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue >@@ -0,0 +1,268 @@ >+<template> >+ <fieldset class="step-block"> >+ <legend class="step-header"> >+ {{ stepNumber }}. >+ {{ >+ showItemDetailsSelects >+ ? $__("Select Pickup Location and Item Type or Item") >+ : showPickupLocationSelect >+ ? $__("Select Pickup Location") >+ : "" >+ }} >+ </legend> >+ >+ <div >+ v-if="showPickupLocationSelect || showItemDetailsSelects" >+ class="form-group" >+ > >+ <label for="pickup_library_id">{{ >+ $__("Pickup location") >+ }}</label> >+ <v-select >+ v-model="selectedPickupLibraryId" >+ :placeholder=" >+ $__('Select a pickup location') >+ " >+ :options="constrainedPickupLocations" >+ label="name" >+ :reduce="l => l.library_id" >+ :loading="loading.pickupLocations" >+ :clearable="true" >+ :disabled="selectsDisabled" >+ :input-id="'pickup_library_id'" >+ > >+ <template #no-options> >+ {{ $__("No pickup locations available.") }} >+ </template> >+ <template #spinner> >+ <span class="sr-only">{{ $__("Loading...") }}</span> >+ </template> >+ </v-select> >+ <span >+ v-if="constrainedFlags.pickupLocations && (showPickupLocationSelect || showItemDetailsSelects)" >+ class="badge badge-warning ml-2" >+ > >+ {{ $__("Options updated") }} >+ <span class="ml-1" >+ >({{ >+ pickupLocationsTotal - pickupLocationsFilteredOut >+ }}/{{ pickupLocationsTotal }})</span >+ > >+ </span> >+ </div> >+ >+ <div v-if="showItemDetailsSelects" class="form-group"> >+ <label for="booking_itemtype">{{ >+ $__("Item type") >+ }}</label> >+ <v-select >+ v-model="selectedItemtypeId" >+ :options="constrainedItemTypes" >+ label="description" >+ :reduce="t => t.item_type_id" >+ :clearable="true" >+ :disabled="selectsDisabled" >+ :input-id="'booking_itemtype'" >+ > >+ <template #no-options> >+ {{ $__("No item types available.") }} >+ </template> >+ </v-select> >+ <span >+ v-if="constrainedFlags.itemTypes" >+ class="badge badge-warning ml-2" >+ >{{ $__("Options updated") }}</span >+ > >+ </div> >+ >+ <div v-if="showItemDetailsSelects" class="form-group"> >+ <label for="booking_item_id">{{ >+ $__("Item") >+ }}</label> >+ <v-select >+ v-model="selectedItemId" >+ :placeholder=" >+ $__('Any item') >+ " >+ :options="constrainedBookableItems" >+ label="external_id" >+ :reduce="i => i.item_id" >+ :clearable="true" >+ :loading="loading.bookableItems" >+ :disabled="selectsDisabled" >+ :input-id="'booking_item_id'" >+ > >+ <template #no-options> >+ {{ $__("No items available.") }} >+ </template> >+ <template #spinner> >+ <span class="sr-only">{{ $__("Loading...") }}</span> >+ </template> >+ </v-select> >+ <span >+ v-if="constrainedFlags.bookableItems" >+ class="badge badge-warning ml-2" >+ > >+ {{ $__("Options updated") }} >+ <span class="ml-1" >+ >({{ >+ bookableItemsTotal - bookableItemsFilteredOut >+ }}/{{ bookableItemsTotal }})</span >+ > >+ </span> >+ </div> >+ </fieldset> >+</template> >+ >+<script> >+import { computed } from "vue"; >+import vSelect from "vue-select"; >+import { $__ } from "../../i18n"; >+import { useBookingStore } from "../../stores/bookings"; >+import { storeToRefs } from "pinia"; >+ >+export default { >+ name: "BookingDetailsStep", >+ components: { >+ vSelect, >+ }, >+ props: { >+ stepNumber: { >+ type: Number, >+ required: true, >+ }, >+ showItemDetailsSelects: { >+ type: Boolean, >+ default: false, >+ }, >+ showPickupLocationSelect: { >+ type: Boolean, >+ default: false, >+ }, >+ selectedPatron: { >+ type: Object, >+ default: null, >+ }, >+ patronRequired: { >+ type: Boolean, >+ default: false, >+ }, >+ // Enable/disable selects based on data readiness from parent >+ detailsEnabled: { type: Boolean, default: true }, >+ // v-model values >+ pickupLibraryId: { >+ type: String, >+ default: null, >+ }, >+ itemtypeId: { >+ type: [Number, String], >+ default: null, >+ }, >+ itemId: { >+ type: [Number, String], >+ default: null, >+ }, >+ // Options and constraints >+ constrainedPickupLocations: { >+ type: Array, >+ default: () => [], >+ }, >+ constrainedItemTypes: { >+ type: Array, >+ default: () => [], >+ }, >+ constrainedBookableItems: { >+ type: Array, >+ default: () => [], >+ }, >+ constrainedFlags: { >+ type: Object, >+ default: () => ({ >+ pickupLocations: false, >+ itemTypes: false, >+ bookableItems: false, >+ }), >+ }, >+ // Statistics for badges >+ pickupLocationsTotal: { >+ type: Number, >+ default: 0, >+ }, >+ pickupLocationsFilteredOut: { >+ type: Number, >+ default: 0, >+ }, >+ bookableItemsTotal: { >+ type: Number, >+ default: 0, >+ }, >+ bookableItemsFilteredOut: { >+ type: Number, >+ default: 0, >+ }, >+ }, >+ emits: [ >+ "update:pickup-library-id", >+ "update:itemtype-id", >+ "update:item-id", >+ ], >+ setup(props, { emit }) { >+ const store = useBookingStore(); >+ const { loading } = storeToRefs(store); >+ // Helper to create v-model proxies with minimal repetition >+ const vModelProxy = (prop, event) => >+ computed({ >+ get: () => props[prop], >+ set: value => emit(event, value), >+ }); >+ >+ const selectedPickupLibraryId = vModelProxy( >+ "pickupLibraryId", >+ "update:pickup-library-id" >+ ); >+ const selectedItemtypeId = vModelProxy( >+ "itemtypeId", >+ "update:itemtype-id" >+ ); >+ const selectedItemId = vModelProxy("itemId", "update:item-id"); >+ >+ const selectsDisabled = computed( >+ () => !props.detailsEnabled || (!props.selectedPatron && props.patronRequired) >+ ); >+ >+ return { >+ selectedPickupLibraryId, >+ selectedItemtypeId, >+ selectedItemId, >+ loading, >+ selectsDisabled, >+ }; >+ }, >+}; >+</script> >+ >+<style scoped> >+.step-block { >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.step-header { >+ font-weight: 600; >+ font-size: var(--booking-text-lg); >+ margin-bottom: calc(var(--booking-space-lg) * 0.75); >+ color: var(--booking-neutral-600); >+} >+ >+.form-group { >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.badge { >+ font-size: var(--booking-text-xs); >+} >+ >+.badge-warning { >+ background-color: var(--booking-warning-bg); >+ color: var(--booking-neutral-600); >+} >+</style> >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 @@ >+<template> >+ <div >+ v-if="modalState.isOpen" >+ class="modal show booking-modal-backdrop" >+ tabindex="-1" >+ role="dialog" >+ > >+ <div >+ class="modal-dialog booking-modal-window booking-modal" >+ role="document" >+ > >+ <div class="modal-content"> >+ <div class="booking-modal-header"> >+ <h5 class="booking-modal-title"> >+ {{ modalTitle }} >+ </h5> >+ <button >+ type="button" >+ class="booking-modal-close" >+ aria-label="Close" >+ @click="handleClose" >+ > >+ <span aria-hidden="true">×</span> >+ </button> >+ </div> >+ <div class="modal-body booking-modal-body"> >+ <form >+ id="form-booking" >+ :action="submitUrl" >+ method="post" >+ @submit.prevent="handleSubmit" >+ > >+ <KohaAlert >+ :show="showCapacityWarning" >+ variant="warning" >+ :message="zeroCapacityMessage" >+ /> >+ <BookingPatronStep >+ v-if="showPatronSelect" >+ v-model="bookingPatron" >+ :step-number="stepNumber.patron" >+ :set-error="setError" >+ /> >+ <hr >+ v-if=" >+ showPatronSelect || >+ showItemDetailsSelects || >+ showPickupLocationSelect >+ " >+ /> >+ <BookingDetailsStep >+ v-if=" >+ showItemDetailsSelects || >+ showPickupLocationSelect >+ " >+ :step-number="stepNumber.details" >+ :details-enabled="readiness.dataReady" >+ :show-item-details-selects="showItemDetailsSelects" >+ :show-pickup-location-select=" >+ showPickupLocationSelect >+ " >+ :selected-patron="bookingPatron" >+ :patron-required="showPatronSelect" >+ v-model:pickup-library-id="pickupLibraryId" >+ v-model:itemtype-id="bookingItemtypeId" >+ v-model:item-id="bookingItemId" >+ :constrained-pickup-locations=" >+ constrainedPickupLocations >+ " >+ :constrained-item-types="constrainedItemTypes" >+ :constrained-bookable-items=" >+ constrainedBookableItems >+ " >+ :constrained-flags="constrainedFlags" >+ :pickup-locations-total="pickupLocationsTotal" >+ :pickup-locations-filtered-out=" >+ pickupLocationsFilteredOut >+ " >+ :bookable-items-total="bookableItemsTotal" >+ :bookable-items-filtered-out=" >+ bookableItemsFilteredOut >+ " >+ /> >+ <hr >+ v-if=" >+ showItemDetailsSelects || >+ showPickupLocationSelect >+ " >+ /> >+ <BookingPeriodStep >+ :step-number="stepNumber.period" >+ :calendar-enabled="readiness.isCalendarReady" >+ :constraint-options="constraintOptions" >+ :date-range-constraint="dateRangeConstraint" >+ :max-booking-period="maxBookingPeriod" >+ :error-message="uiError.message" >+ :set-error="setError" >+ :has-selected-dates="selectedDateRange?.length > 0" >+ @clear-dates="clearDateRange" >+ /> >+ <hr >+ v-if=" >+ showAdditionalFields && >+ modalState.hasAdditionalFields >+ " >+ /> >+ </form> >+ </div> >+ <div class="modal-footer"> >+ <div class="d-flex gap-2"> >+ <button >+ class="btn btn-primary" >+ :disabled="loading.submit || !isSubmitReady" >+ type="submit" >+ form="form-booking" >+ > >+ {{ submitLabel }} >+ </button> >+ <button >+ class="btn btn-secondary ml-2" >+ @click.prevent="handleClose" >+ > >+ {{ $__("Cancel") }} >+ </button> >+ </div> >+ </div> >+ </div> >+ </div> >+ </div> >+</template> >+ >+<script> >+import { toISO } from "./lib/booking/date-utils.mjs"; >+import { >+ computed, >+ reactive, >+ watch, >+ onUnmounted, >+} from "vue"; >+import BookingPatronStep from "./BookingPatronStep.vue"; >+import BookingDetailsStep from "./BookingDetailsStep.vue"; >+import BookingPeriodStep from "./BookingPeriodStep.vue"; >+import { $__ } from "../../i18n"; >+import { processApiError } from "../../utils/apiErrors.js"; >+import { >+ constrainBookableItems, >+ constrainItemTypes, >+ constrainPickupLocations, >+} from "./lib/booking/manager.mjs"; >+import { useBookingStore } from "../../stores/bookings"; >+import { storeToRefs } from "pinia"; >+import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs"; >+import { enableBodyScroll, disableBodyScroll } from "./lib/adapters/modal-scroll.mjs"; >+import { appendHiddenInputs } from "./lib/adapters/form.mjs"; >+import { calculateStepNumbers } from "./lib/ui/steps.mjs"; >+import { useBookingValidation } from "./composables/useBookingValidation.mjs"; >+import { calculateMaxBookingPeriod } from "./lib/booking/manager.mjs"; >+import { useDefaultPickup } from "./composables/useDefaultPickup.mjs"; >+import { buildNoItemsAvailableMessage } from "./lib/ui/selection-message.mjs"; >+import { useRulesFetcher } from "./composables/useRulesFetcher.mjs"; >+import { normalizeIdType } from "./lib/booking/id-utils.mjs"; >+import { useDerivedItemType } from "./composables/useDerivedItemType.mjs"; >+import { useErrorState } from "./composables/useErrorState.mjs"; >+import { useCapacityGuard } from "./composables/useCapacityGuard.mjs"; >+import KohaAlert from "../KohaAlert.vue"; >+ >+export default { >+ name: "BookingModal", >+ components: { >+ BookingPatronStep, >+ BookingDetailsStep, >+ BookingPeriodStep, >+ KohaAlert, >+ }, >+ props: { >+ open: { type: Boolean, default: false }, >+ size: { type: String, default: "lg" }, >+ title: { type: String, default: "" }, >+ biblionumber: { type: [String, Number], required: true }, >+ bookingId: { type: [Number, String], default: null }, >+ itemId: { type: [Number, String], default: null }, >+ patronId: { type: [Number, String], default: null }, >+ pickupLibraryId: { type: String, default: null }, >+ startDate: { type: String, default: null }, >+ endDate: { type: String, default: null }, >+ itemtypeId: { type: [Number, String], default: null }, >+ showPatronSelect: { type: Boolean, default: false }, >+ showItemDetailsSelects: { type: Boolean, default: false }, >+ showPickupLocationSelect: { type: Boolean, default: false }, >+ submitType: { >+ type: String, >+ default: "api", >+ validator: value => ["api", "form-submission"].includes(String(value)), >+ }, >+ submitUrl: { type: String, default: "" }, >+ extendedAttributes: { type: Array, default: () => [] }, >+ extendedAttributeTypes: { type: Object, default: null }, >+ authorizedValues: { type: Object, default: null }, >+ showAdditionalFields: { type: Boolean, default: false }, >+ dateRangeConstraint: { >+ type: String, >+ default: null, >+ validator: value => >+ !value || >+ ["issuelength", "issuelength_with_renewals", "custom"].includes( >+ String(value) >+ ), >+ }, >+ customDateRangeFormula: { >+ type: Function, >+ default: null, >+ }, >+ opacDefaultBookingLibraryEnabled: { type: [Boolean, String], default: null }, >+ opacDefaultBookingLibrary: { type: String, default: null }, >+ }, >+ emits: ["close"], >+ setup(props, { emit }) { >+ const store = useBookingStore(); >+ >+ // Properly destructure reactive store state using storeToRefs >+ const { >+ bookingId, >+ bookingItemId, >+ bookingPatron, >+ bookingItemtypeId, >+ pickupLibraryId, >+ selectedDateRange, >+ bookableItems, >+ bookings, >+ checkouts, >+ pickupLocations, >+ itemTypes, >+ circulationRules, >+ circulationRulesContext, >+ loading, >+ } = storeToRefs(store); >+ >+ // Calculate max booking period from circulation rules and selected constraint >+ >+ // Use validation composable for reactive validation logic >+ const { canSubmit: canSubmitReactive } = useBookingValidation(store); >+ >+ // Grouped reactive state following Vue 3 best practices >+ const modalState = reactive({ >+ isOpen: props.open, >+ step: 1, >+ hasAdditionalFields: false, >+ }); >+ const { error: uiError, setError, clear: clearError } = useErrorState(); >+ >+ const modalTitle = computed( >+ () => >+ props.title || >+ (bookingId.value ? $__("Edit booking") : $__("Place booking")) >+ ); >+ >+ // Determine whether to show pickup location select >+ // In OPAC: show if default library is not enabled >+ // In staff: use the explicit prop >+ const showPickupLocationSelect = computed(() => { >+ if (props.opacDefaultBookingLibraryEnabled !== null) { >+ const enabled = props.opacDefaultBookingLibraryEnabled === true || >+ String(props.opacDefaultBookingLibraryEnabled) === "1"; >+ return !enabled; >+ } >+ return props.showPickupLocationSelect; >+ }); >+ >+ const stepNumber = computed(() => { >+ return calculateStepNumbers( >+ props.showPatronSelect, >+ props.showItemDetailsSelects, >+ showPickupLocationSelect.value, >+ props.showAdditionalFields, >+ modalState.hasAdditionalFields >+ ); >+ }); >+ >+ const submitLabel = computed(() => >+ bookingId.value ? $__("Update booking") : $__("Place booking") >+ ); >+ >+ const isFormSubmission = computed( >+ () => props.submitType === "form-submission" >+ ); >+ >+ // pickupLibraryId is a ref from the store; use directly >+ >+ // Constraint flags computed from pure function results >+ const constrainedFlags = computed(() => ({ >+ pickupLocations: pickupLocationConstraint.value.constraintApplied, >+ bookableItems: bookableItemsConstraint.value.constraintApplied, >+ itemTypes: itemTypeConstraint.value.constraintApplied, >+ })); >+ >+ const pickupLocationConstraint = computed(() => >+ constrainPickupLocations( >+ pickupLocations.value, >+ bookableItems.value, >+ bookingItemtypeId.value, >+ bookingItemId.value >+ ) >+ ); >+ const constrainedPickupLocations = computed( >+ () => pickupLocationConstraint.value.filtered >+ ); >+ const pickupLocationsFilteredOut = computed( >+ () => pickupLocationConstraint.value.filteredOutCount >+ ); >+ const pickupLocationsTotal = computed( >+ () => pickupLocationConstraint.value.total >+ ); >+ >+ const bookableItemsConstraint = computed(() => >+ constrainBookableItems( >+ bookableItems.value, >+ pickupLocations.value, >+ pickupLibraryId.value, >+ bookingItemtypeId.value >+ ) >+ ); >+ const constrainedBookableItems = computed( >+ () => bookableItemsConstraint.value.filtered >+ ); >+ const bookableItemsFilteredOut = computed( >+ () => bookableItemsConstraint.value.filteredOutCount >+ ); >+ const bookableItemsTotal = computed( >+ () => bookableItemsConstraint.value.total >+ ); >+ >+ const itemTypeConstraint = computed(() => >+ constrainItemTypes( >+ itemTypes.value, >+ bookableItems.value, >+ pickupLocations.value, >+ pickupLibraryId.value, >+ bookingItemId.value >+ ) >+ ); >+ const constrainedItemTypes = computed( >+ () => itemTypeConstraint.value.filtered >+ ); >+ >+ const maxBookingPeriod = computed(() => >+ calculateMaxBookingPeriod( >+ circulationRules.value, >+ props.dateRangeConstraint, >+ props.customDateRangeFormula >+ ) >+ ); >+ >+ const constraintOptions = computed(() => ({ >+ dateRangeConstraint: props.dateRangeConstraint, >+ maxBookingPeriod: maxBookingPeriod.value, >+ })); >+ >+ // Centralized capacity guard (extracts UI warning state) >+ const { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning } = >+ useCapacityGuard({ >+ circulationRules, >+ circulationRulesContext, >+ loading, >+ bookableItems, >+ bookingPatron, >+ bookingItemId, >+ bookingItemtypeId, >+ pickupLibraryId, >+ showPatronSelect: props.showPatronSelect, >+ showItemDetailsSelects: props.showItemDetailsSelects, >+ showPickupLocationSelect: showPickupLocationSelect.value, >+ dateRangeConstraint: props.dateRangeConstraint, >+ }); >+ >+ // Readiness flags >+ const dataReady = computed( >+ () => >+ !loading.value.bookableItems && >+ !loading.value.bookings && >+ !loading.value.checkouts && >+ (bookableItems.value?.length ?? 0) > 0 >+ ); >+ const formPrefilterValid = computed(() => { >+ const requireTypeOrItem = !!props.showItemDetailsSelects; >+ const hasTypeOrItem = >+ !!bookingItemId.value || !!bookingItemtypeId.value; >+ const patronOk = !props.showPatronSelect || !!bookingPatron.value; >+ return patronOk && (requireTypeOrItem ? hasTypeOrItem : true); >+ }); >+ const hasAvailableItems = computed( >+ () => constrainedBookableItems.value.length > 0 >+ ); >+ >+ const isCalendarReady = computed(() => { >+ const basicReady = dataReady.value && >+ formPrefilterValid.value && >+ hasAvailableItems.value; >+ if (!basicReady) return false; >+ if (loading.value.circulationRules) return true; >+ >+ return hasPositiveCapacity.value; >+ }); >+ >+ // Separate validation for submit button using reactive composable >+ const isSubmitReady = computed( >+ () => isCalendarReady.value && canSubmitReactive.value >+ ); >+ >+ // Grouped readiness for convenient consumption (optional) >+ const readiness = computed(() => ({ >+ dataReady: dataReady.value, >+ formPrefilterValid: formPrefilterValid.value, >+ hasAvailableItems: hasAvailableItems.value, >+ isCalendarReady: isCalendarReady.value, >+ canSubmit: isSubmitReady.value, >+ })); >+ >+ // Watchers: synchronize open state, orchestrate initial data load, >+ // push availability to store, fetch rules/pickup locations, and keep >+ // UX guidance/errors fresh while inputs change. >+ >+ // Sync internal modal state with the `open` prop and reset inputs when >+ // opening to ensure a clean form each time. >+ watch( >+ () => props.open, >+ val => { >+ modalState.isOpen = val; >+ if (val) { >+ resetModalState(); >+ } >+ } >+ ); >+ >+ // Orchestrates initial data loading on modal open. This needs to >+ // remain isolated because it handles async fetch ordering, DOM body >+ // scroll state and localization preload. >+ watch( >+ () => modalState.isOpen, >+ async open => { >+ if (open) { >+ disableBodyScroll(); >+ } else { >+ enableBodyScroll(); >+ return; >+ } >+ >+ modalState.step = 1; >+ const biblionumber = props.biblionumber; >+ if (!biblionumber) return; >+ >+ bookingId.value = props.bookingId; >+ >+ try { >+ // Fetch core data first >+ await Promise.all([ >+ store.fetchBookableItems(biblionumber), >+ store.fetchBookings(biblionumber), >+ store.fetchCheckouts(biblionumber), >+ ]); >+ >+ modalState.hasAdditionalFields = false; >+ >+ // Derive item types after bookable items are loaded >+ store.deriveItemTypesFromBookableItems(); >+ >+ // If editing with patron, fetch patron-specific data >+ if (props.patronId) { >+ const patron = await store.fetchPatron(props.patronId); >+ await store.fetchPickupLocations( >+ biblionumber, >+ props.patronId >+ ); >+ >+ // Now set patron after data is available >+ bookingPatron.value = patron; >+ } >+ >+ // Set other form values after all dependencies are loaded >+ >+ // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching >+ bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null; >+ if (props.itemtypeId) { >+ bookingItemtypeId.value = props.itemtypeId; >+ } >+ >+ if (props.startDate && props.endDate) { >+ selectedDateRange.value = [ >+ toISO(props.startDate), >+ toISO(props.endDate), >+ ]; >+ } >+ } catch (error) { >+ console.error("Error initializing booking modal:", error); >+ setError(processApiError(error), "api"); >+ } >+ } >+ ); >+ >+ useRulesFetcher({ >+ store, >+ bookingPatron, >+ bookingPickupLibraryId: pickupLibraryId, >+ bookingItemtypeId, >+ constrainedItemTypes, >+ selectedDateRange, >+ biblionumber: String(props.biblionumber), >+ }); >+ >+ useDerivedItemType({ >+ bookingItemtypeId, >+ bookingItemId, >+ constrainedItemTypes, >+ bookableItems, >+ }); >+ >+ watch( >+ () => ({ >+ patron: bookingPatron.value?.patron_id, >+ pickup: pickupLibraryId.value, >+ itemtype: bookingItemtypeId.value, >+ item: bookingItemId.value, >+ d0: selectedDateRange.value?.[0], >+ d1: selectedDateRange.value?.[1], >+ }), >+ (curr, prev) => { >+ const inputsChanged = >+ !prev || >+ curr.patron !== prev.patron || >+ curr.pickup !== prev.pickup || >+ curr.itemtype !== prev.itemtype || >+ curr.item !== prev.item || >+ curr.d0 !== prev.d0 || >+ curr.d1 !== prev.d1; >+ if (inputsChanged) clearErrors(); >+ } >+ ); >+ >+ // Default pickup selection handled by composable >+ useDefaultPickup({ >+ bookingPickupLibraryId: pickupLibraryId, >+ bookingPatron, >+ pickupLocations, >+ bookableItems, >+ opacDefaultBookingLibraryEnabled: props.opacDefaultBookingLibraryEnabled, >+ opacDefaultBookingLibrary: props.opacDefaultBookingLibrary, >+ }); >+ >+ // Show an actionable error when current selection yields no available >+ // items, helping the user adjust filters. >+ watch( >+ [ >+ constrainedBookableItems, >+ () => bookingPatron.value, >+ () => pickupLibraryId.value, >+ () => bookingItemtypeId.value, >+ dataReady, >+ () => loading.value.circulationRules, >+ () => loading.value.pickupLocations, >+ ], >+ ([availableItems, patron, pickupLibrary, itemtypeId, isDataReady]) => { >+ // Only show error if data is loaded and user has made selections that result in no items >+ // Wait for pickup locations and circulation rules to finish loading to avoid false positives >+ const pickupLocationsReady = !pickupLibrary || (!loading.value.pickupLocations && pickupLocations.value.length > 0); >+ const circulationRulesReady = !loading.value.circulationRules; >+ >+ if ( >+ isDataReady && >+ pickupLocationsReady && >+ circulationRulesReady && >+ patron && >+ (pickupLibrary || itemtypeId) && >+ availableItems.length === 0 >+ ) { >+ const msg = buildNoItemsAvailableMessage( >+ pickupLocations.value, >+ itemTypes.value, >+ pickupLibrary, >+ itemtypeId >+ ); >+ setError(msg, "no_items"); >+ } else if (uiError.code === "no_items") { >+ clearErrors(); >+ } >+ }, >+ { immediate: true } >+ ); >+ >+ >+ // Globally clear all error states (modal + store) >+ function clearErrors() { >+ clearError(); >+ store.resetErrors(); >+ } >+ >+ >+ function resetModalState() { >+ bookingPatron.value = null; >+ pickupLibraryId.value = null; >+ bookingItemtypeId.value = null; >+ bookingItemId.value = null; >+ selectedDateRange.value = []; >+ modalState.step = 1; >+ clearErrors(); >+ modalState.hasAdditionalFields = false; >+ } >+ >+ function clearDateRange() { >+ selectedDateRange.value = []; >+ clearErrors(); >+ } >+ >+ function handleClose() { >+ modalState.isOpen = false; >+ enableBodyScroll(); >+ emit("close"); >+ resetModalState(); >+ } >+ >+ async function handleSubmit(event) { >+ // Use selectedDateRange (clean ISO strings maintained by onChange handler) >+ const selectedDates = selectedDateRange.value; >+ >+ if (!selectedDates || selectedDates.length === 0) { >+ setError($__("Please select a valid date range"), "invalid_date_range"); >+ return; >+ } >+ >+ const start = selectedDates[0]; >+ const end = >+ selectedDates.length >= 2 ? selectedDates[1] : selectedDates[0]; >+ const bookingData = { >+ booking_id: props.bookingId ?? undefined, >+ start_date: start, >+ end_date: end, >+ pickup_library_id: pickupLibraryId.value, >+ biblio_id: props.biblionumber, >+ item_id: bookingItemId.value || null, >+ patron_id: bookingPatron.value?.patron_id, >+ }; >+ >+ if (isFormSubmission.value) { >+ const form = /** @type {HTMLFormElement} */ (event.target); >+ const csrfToken = /** @type {HTMLInputElement|null} */ ( >+ document.querySelector('[name="csrf_token"]') >+ ); >+ >+ const dataToSubmit = { ...bookingData }; >+ >+ appendHiddenInputs( >+ form, >+ [ >+ ...Object.entries(dataToSubmit), >+ [csrfToken?.name, csrfToken?.value], >+ ['op', 'cud-add'], >+ ] >+ ); >+ form.submit(); >+ return; >+ } >+ >+ try { >+ const result = await store.saveOrUpdateBooking(bookingData); >+ updateExternalDependents(result, bookingPatron.value, !!props.bookingId); >+ emit("close"); >+ resetModalState(); >+ } catch (errorObj) { >+ setError(processApiError(errorObj), "api"); >+ } >+ } >+ >+ // Cleanup function for proper memory management >+ onUnmounted(() => { >+ enableBodyScroll(); >+ }); >+ >+ return { >+ modalState, >+ modalTitle, >+ submitLabel, >+ isFormSubmission, >+ loading, >+ showPickupLocationSelect, >+ // Store refs from storeToRefs >+ selectedDateRange, >+ bookingId, >+ bookingItemId, >+ bookingPatron, >+ bookingItemtypeId, >+ pickupLibraryId, >+ bookableItems, >+ bookings, >+ checkouts, >+ pickupLocations, >+ itemTypes, >+ uiError, >+ setError, >+ // Computed values >+ constrainedPickupLocations, >+ constrainedItemTypes, >+ constrainedBookableItems, >+ isCalendarReady, >+ isSubmitReady, >+ readiness, >+ constrainedFlags, >+ constraintOptions, >+ handleClose, >+ handleSubmit, >+ clearDateRange, >+ resetModalState, >+ stepNumber, >+ pickupLocationsFilteredOut, >+ pickupLocationsTotal, >+ bookableItemsFilteredOut, >+ bookableItemsTotal, >+ maxBookingPeriod, >+ hasPositiveCapacity, >+ zeroCapacityMessage, >+ showCapacityWarning, >+ }; >+ }, >+}; >+</script> >+ >+<style> >+.booking-modal-backdrop { >+ position: fixed; >+ top: 0; >+ left: 0; >+ width: 100%; >+ height: 100%; >+ background: rgba(0, 0, 0, 0.5); >+ z-index: 1050; >+ display: block; >+ overflow-y: auto; >+} >+ >+/* Global variables for external libraries (flatpickr) and cross-block usage */ >+:root { >+ /* Success colors for constraint highlighting */ >+ --booking-success-hue: 134; >+ --booking-success-bg: hsl(var(--booking-success-hue), 40%, 90%); >+ --booking-success-bg-hover: hsl(var(--booking-success-hue), 35%, 85%); >+ --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%); >+ --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%); >+ --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%); >+ >+ /* Border width used by flatpickr */ >+ --booking-border-width: 1px; >+ >+ /* Variables used by second style block (booking markers, calendar states) */ >+ --booking-marker-size: 0.25em; >+ --booking-marker-grid-gap: 0.25rem; >+ --booking-marker-grid-offset: -0.75rem; >+ >+ /* Color hues used in second style block */ >+ --booking-warning-hue: 45; >+ --booking-danger-hue: 354; >+ --booking-info-hue: 195; >+ --booking-neutral-hue: 210; >+ >+ /* Colors derived from hues (used in second style block) */ >+ --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%); >+ --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%); >+ >+ /* Spacing used in second style block */ >+ --booking-space-xs: 0.125rem; >+ >+ /* Typography used in second style block */ >+ --booking-text-xs: 0.7rem; >+ >+ /* Border radius used in second style block and other components */ >+ --booking-border-radius-sm: 0.25rem; >+ --booking-border-radius-md: 0.5rem; >+ --booking-border-radius-full: 50%; >+} >+ >+/* Design System: CSS Custom Properties (First Style Block Only) */ >+.booking-modal-backdrop { >+ /* Colors not used in second style block */ >+ --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%); >+ --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%); >+ --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%); >+ --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%); >+ >+ /* Spacing Scale (first block only) */ >+ --booking-space-sm: 0.25rem; /* 4px */ >+ --booking-space-md: 0.5rem; /* 8px */ >+ --booking-space-lg: 1rem; /* 16px */ >+ --booking-space-xl: 1.5rem; /* 24px */ >+ --booking-space-2xl: 2rem; /* 32px */ >+ >+ /* Typography Scale (first block only) */ >+ --booking-text-sm: 0.8125rem; >+ --booking-text-base: 1rem; >+ --booking-text-lg: 1.1rem; >+ --booking-text-xl: 1.3rem; >+ --booking-text-2xl: 2rem; >+ >+ /* Layout */ >+ --booking-modal-max-height: calc(100vh - var(--booking-space-2xl)); >+ --booking-input-min-width: 15rem; >+ >+ /* Animation */ >+ --booking-transition-fast: 0.15s ease-in-out; >+} >+ >+/* Constraint Highlighting Component */ >+.flatpickr-calendar .booking-constrained-range-marker { >+ background-color: var(--booking-success-bg) !important; >+ border: var(--booking-border-width) solid var(--booking-success-border) !important; >+ color: var(--booking-success-text) !important; >+} >+ >+.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker { >+ background-color: var(--booking-success-bg) !important; >+ border-color: var(--booking-success-border) !important; >+ color: var(--booking-success-text) !important; >+} >+ >+.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker:hover { >+ background-color: var(--booking-success-bg-hover) !important; >+ border-color: var(--booking-success-border-hover) !important; >+} >+ >+/* End Date Only Mode - Blocked Intermediate Dates */ >+.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked { >+ background-color: hsl(var(--booking-success-hue), 40%, 90%) !important; >+ border-color: hsl(var(--booking-success-hue), 40%, 70%) !important; >+ color: hsl(var(--booking-success-hue), 40%, 50%) !important; >+ cursor: not-allowed !important; >+ opacity: 0.7 !important; >+} >+ >+/* Bold styling for end of loan and renewal period boundaries */ >+.flatpickr-calendar .flatpickr-day.booking-loan-boundary { >+ font-weight: 700 !important; >+} >+.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked:hover { >+ background-color: hsl(var(--booking-success-hue), 40%, 85%) !important; >+ border-color: hsl(var(--booking-success-hue), 40%, 60%) !important; >+} >+ >+/* Modal Layout Components */ >+.booking-modal-window { >+ max-height: var(--booking-modal-max-height); >+ margin: var(--booking-space-lg) auto; >+} >+ >+.modal-content { >+ max-height: var(--booking-modal-max-height); >+ display: flex; >+ flex-direction: column; >+} >+ >+.booking-modal-header { >+ padding: var(--booking-space-lg); >+ border-bottom: var(--booking-border-width) solid var(--booking-neutral-100); >+ display: flex; >+ align-items: center; >+ justify-content: space-between; >+ flex-shrink: 0; >+} >+ >+.booking-modal-title { >+ margin: 0; >+ font-size: var(--booking-text-xl); >+ font-weight: 600; >+} >+ >+.booking-modal-close { >+ background: transparent; >+ border: none; >+ font-size: var(--booking-text-2xl); >+ line-height: 1; >+ cursor: pointer; >+ color: var(--booking-neutral-500); >+ opacity: 0.5; >+ transition: opacity var(--booking-transition-fast); >+ padding: 0; >+ margin: 0; >+ width: var(--booking-space-2xl); >+ height: var(--booking-space-2xl); >+ display: flex; >+ align-items: center; >+ justify-content: center; >+} >+ >+.booking-modal-close:hover { >+ opacity: 0.75; >+} >+ >+.booking-modal-body { >+ padding: var(--booking-space-xl); >+ overflow-y: auto; >+ flex: 1 1 auto; >+} >+ >+/* Form & Layout Components */ >+.booking-extended-attributes { >+ list-style: none; >+ padding: 0; >+ margin: 0; >+} >+ >+.step-block { >+ margin-bottom: var(--booking-space-2xl); >+} >+ >+.step-header { >+ font-weight: bold; >+ font-size: var(--booking-text-lg); >+ margin-bottom: var(--booking-space-md); >+} >+ >+hr { >+ border: none; >+ border-top: var(--booking-border-width) solid var(--booking-neutral-100); >+ margin: var(--booking-space-2xl) 0; >+} >+ >+/* Input Components */ >+.booking-flatpickr-input, >+.flatpickr-input.booking-flatpickr-input { >+ min-width: var(--booking-input-min-width); >+ padding: calc(var(--booking-space-md) - var(--booking-space-xs)) >+ calc(var(--booking-space-md) + var(--booking-space-sm)); >+ border: var(--booking-border-width) solid var(--booking-neutral-300); >+ border-radius: var(--booking-border-radius-sm); >+ font-size: var(--booking-text-base); >+ transition: border-color var(--booking-transition-fast), >+ box-shadow var(--booking-transition-fast); >+} >+ >+/* Calendar Legend Component */ >+.calendar-legend { >+ margin-top: var(--booking-space-lg); >+ margin-bottom: var(--booking-space-lg); >+ font-size: var(--booking-text-sm); >+ display: flex; >+ align-items: center; >+} >+ >+.calendar-legend .booking-marker-dot { >+ /* Make legend dots much larger and more visible */ >+ width: calc(var(--booking-marker-size) * 3) !important; >+ height: calc(var(--booking-marker-size) * 3) !important; >+ margin-right: calc(var(--booking-space-sm) * 1.5); >+ border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15); >+} >+ >+.calendar-legend .ml-3 { >+ margin-left: var(--booking-space-lg); >+} >+ >+/* Legend colors match actual calendar markers exactly */ >+.calendar-legend .booking-marker-dot--booked { >+ background: var(--booking-warning-bg) !important; >+} >+ >+.calendar-legend .booking-marker-dot--checked-out { >+ background: hsl(var(--booking-danger-hue), 60%, 85%) !important; >+} >+ >+.calendar-legend .booking-marker-dot--lead { >+ background: hsl(var(--booking-info-hue), 60%, 85%) !important; >+} >+ >+.calendar-legend .booking-marker-dot--trail { >+ background: var(--booking-warning-bg) !important; >+} >+</style> >+ >+<style> >+.booking-date-picker { >+ display: flex; >+ align-items: stretch; >+ width: 100%; >+} >+ >+.booking-date-picker > .form-control { >+ flex: 1 1 auto; >+ min-width: 0; >+ margin-bottom: 0; >+} >+ >+.booking-date-picker-append { >+ display: flex; >+ margin-left: -1px; >+} >+ >+.booking-date-picker-append .btn { >+ border-top-left-radius: 0; >+ border-bottom-left-radius: 0; >+} >+ >+.booking-date-picker > .form-control:not(:last-child) { >+ border-top-right-radius: 0; >+ border-bottom-right-radius: 0; >+} >+ >+/* External Library Integration */ >+.booking-modal-body .vs__selected { >+ font-size: var(--vs-font-size); >+ line-height: var(--vs-line-height); >+} >+ >+.booking-constraint-info { >+ margin-top: var(--booking-space-lg); >+ margin-bottom: var(--booking-space-lg); >+} >+ >+/* Booking Status Marker System */ >+.booking-marker-grid { >+ position: relative; >+ top: var(--booking-marker-grid-offset); >+ display: flex; >+ flex-wrap: wrap; >+ justify-content: center; >+ gap: var(--booking-marker-grid-gap); >+ width: fit-content; >+ max-width: 90%; >+ margin-left: auto; >+ margin-right: auto; >+ line-height: normal; >+} >+ >+.booking-marker-item { >+ display: inline-flex; >+ align-items: center; >+} >+ >+.booking-marker-dot { >+ display: inline-block; >+ width: var(--booking-marker-size); >+ height: var(--booking-marker-size); >+ border-radius: var(--booking-border-radius-full); >+ vertical-align: middle; >+} >+ >+.booking-marker-count { >+ font-size: var(--booking-text-xs); >+ margin-left: var(--booking-space-xs); >+ line-height: 1; >+ font-weight: normal; >+ color: var(--booking-neutral-600); >+} >+ >+/* Status Indicator Colors */ >+.booking-marker-dot--booked { >+ background: var(--booking-warning-bg); >+} >+ >+.booking-marker-dot--checked-out { >+ background: hsl(var(--booking-danger-hue), 60%, 85%); >+} >+ >+.booking-marker-dot--lead { >+ background: hsl(var(--booking-info-hue), 60%, 85%); >+} >+ >+.booking-marker-dot--trail { >+ background: var(--booking-warning-bg); >+} >+ >+/* Calendar Day States */ >+.booked { >+ background: var(--booking-warning-bg) !important; >+ color: hsl(var(--booking-warning-hue), 80%, 25%) !important; >+ border-radius: var(--booking-border-radius-full) !important; >+} >+ >+.checked-out { >+ background: hsl(var(--booking-danger-hue), 60%, 85%) !important; >+ color: hsl(var(--booking-danger-hue), 80%, 25%) !important; >+ border-radius: var(--booking-border-radius-full) !important; >+} >+ >+/* Hover States with Transparency */ >+.flatpickr-day.booking-day--hover-lead { >+ background-color: hsl(var(--booking-info-hue), 60%, 85%, 0.2) !important; >+} >+ >+.flatpickr-day.booking-day--hover-trail { >+ background-color: hsl( >+ var(--booking-warning-hue), >+ 100%, >+ 70%, >+ 0.2 >+ ) !important; >+} >+</style> >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 @@ >+<template> >+ <fieldset class="step-block"> >+ <legend class="step-header"> >+ {{ stepNumber }}. >+ {{ $__("Select Patron") }} >+ </legend> >+ <PatronSearchSelect >+ v-model="selectedPatron" >+ :label=" >+ $__('Patron') >+ " >+ :placeholder=" >+ $__('Search for a patron') >+ " >+ :set-error="setError" >+ > >+ <template #no-options="{ hasSearched }"> >+ {{ hasSearched ? $__("No patrons found.") : $__("Type to search for patrons.") }} >+ </template> >+ <template #spinner> >+ <span class="sr-only">{{ $__("Searching...") }}</span> >+ </template> >+ </PatronSearchSelect> >+ </fieldset> >+</template> >+ >+<script> >+import { computed } from "vue"; >+import PatronSearchSelect from "./PatronSearchSelect.vue"; >+import { $__ } from "../../i18n"; >+ >+export default { >+ name: "BookingPatronStep", >+ components: { >+ PatronSearchSelect, >+ }, >+ props: { >+ stepNumber: { >+ type: Number, >+ required: true, >+ }, >+ modelValue: { >+ type: Object, >+ default: null, >+ }, >+ setError: { >+ type: Function, >+ default: null, >+ }, >+ }, >+ emits: ["update:modelValue"], >+ setup(props, { emit }) { >+ const selectedPatron = computed({ >+ get: () => props.modelValue, >+ set: (value) => { >+ emit("update:modelValue", value); >+ }, >+ }); >+ >+ return { >+ selectedPatron, >+ }; >+ }, >+}; >+</script> >+ >+<style scoped> >+.step-block { >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.step-header { >+ font-weight: 600; >+ font-size: var(--booking-text-lg); >+ margin-bottom: calc(var(--booking-space-lg) * 0.75); >+ color: var(--booking-neutral-600); >+} >+</style> >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 @@ >+<template> >+ <fieldset class="step-block"> >+ <legend class="step-header"> >+ {{ stepNumber }}. >+ {{ $__("Select Booking Period") }} >+ </legend> >+ >+ <div class="form-group"> >+ <label for="booking_period">{{ $__("Booking period") }}</label> >+ <div class="booking-date-picker"> >+ <input >+ ref="inputEl" >+ id="booking_period" >+ type="text" >+ class="booking-flatpickr-input form-control" >+ :disabled="!calendarEnabled" >+ readonly >+ /> >+ <div class="booking-date-picker-append"> >+ <button >+ type="button" >+ class="btn btn-outline-secondary" >+ :disabled="!calendarEnabled" >+ :title=" >+ $__('Clear selected dates') >+ " >+ @click="clearDateRange" >+ > >+ <i class="fa fa-times" aria-hidden="true"></i> >+ <span class="sr-only">{{ >+ $__("Clear selected dates") >+ }}</span> >+ </button> >+ </div> >+ </div> >+ </div> >+ >+ <KohaAlert >+ v-if=" >+ dateRangeConstraint && >+ (maxBookingPeriod === null || maxBookingPeriod > 0) >+ " >+ variant="info" >+ extra-class="booking-constraint-info" >+ > >+ <small> >+ <strong>{{ $__("Booking constraint active:") }}</strong> >+ {{ constraintHelpText }} >+ </small> >+ </KohaAlert> >+ >+ <div class="calendar-legend"> >+ <span class="booking-marker-dot booking-marker-dot--booked"></span> >+ {{ $__("Booked") }} >+ <span >+ class="booking-marker-dot booking-marker-dot--lead ml-3" >+ ></span> >+ {{ $__("Lead Period") }} >+ <span >+ class="booking-marker-dot booking-marker-dot--trail ml-3" >+ ></span> >+ {{ $__("Trail Period") }} >+ <span >+ class="booking-marker-dot booking-marker-dot--checked-out ml-3" >+ ></span> >+ {{ $__("Checked Out") }} >+ <span >+ v-if="dateRangeConstraint && hasSelectedDates" >+ class="booking-marker-dot ml-3" >+ style="background-color: #28a745" >+ ></span> >+ <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1"> >+ {{ $__("Required end date") }} >+ </span> >+ </div> >+ >+ <div v-if="errorMessage" class="alert alert-danger mt-2"> >+ {{ errorMessage }} >+ </div> >+ </fieldset> >+ <BookingTooltip >+ :markers="tooltipMarkers" >+ :x="tooltipX" >+ :y="tooltipY" >+ :visible="tooltipVisible" >+ /> >+</template> >+ >+<script> >+import { computed, ref, toRef, watch } from "vue"; >+import KohaAlert from "../KohaAlert.vue"; >+import { useFlatpickr } from "./composables/useFlatpickr.mjs"; >+import { useBookingStore } from "../../stores/bookings"; >+import { storeToRefs } from "pinia"; >+import { useAvailability } from "./composables/useAvailability.mjs"; >+import BookingTooltip from "./BookingTooltip.vue"; >+import { $__ } from "../../i18n"; >+ >+export default { >+ name: "BookingPeriodStep", >+ components: { BookingTooltip, KohaAlert }, >+ props: { >+ stepNumber: { >+ type: Number, >+ required: true, >+ }, >+ calendarEnabled: { type: Boolean, default: true }, >+ // disable fn now computed in child >+ constraintOptions: { type: Object, required: true }, >+ dateRangeConstraint: { >+ type: String, >+ default: null, >+ }, >+ maxBookingPeriod: { >+ type: Number, >+ default: null, >+ }, >+ errorMessage: { >+ type: String, >+ default: "", >+ }, >+ setError: { type: Function, required: true }, >+ hasSelectedDates: { >+ type: Boolean, >+ default: false, >+ }, >+ }, >+ emits: ["clear-dates"], >+ setup(props, { emit }) { >+ const store = useBookingStore(); >+ const { >+ bookings, >+ checkouts, >+ bookableItems, >+ bookingItemId, >+ bookingId, >+ selectedDateRange, >+ circulationRules, >+ } = storeToRefs(store); >+ const inputEl = ref(null); >+ >+ const constraintHelpText = computed(() => { >+ if (!props.dateRangeConstraint) return ""; >+ >+ const baseMessages = { >+ issuelength: props.maxBookingPeriod >+ ? $__( >+ "Booking period limited to issue length (%s days)" >+ ).format(props.maxBookingPeriod) >+ : $__("Booking period limited to issue length"), >+ issuelength_with_renewals: props.maxBookingPeriod >+ ? $__( >+ "Booking period limited to issue length with renewals (%s days)" >+ ).format(props.maxBookingPeriod) >+ : $__( >+ "Booking period limited to issue length with renewals" >+ ), >+ default: props.maxBookingPeriod >+ ? $__( >+ "Booking period limited by circulation rules (%s days)" >+ ).format(props.maxBookingPeriod) >+ : $__("Booking period limited by circulation rules"), >+ }; >+ >+ return ( >+ baseMessages[props.dateRangeConstraint] || baseMessages.default >+ ); >+ }); >+ >+ // Visible calendar range for on-demand markers >+ const visibleRangeRef = ref({ >+ visibleStartDate: null, >+ visibleEndDate: null, >+ }); >+ >+ // Merge constraint options with visible range for availability calc >+ const availabilityOptionsRef = computed(() => ({ >+ ...(props.constraintOptions || {}), >+ ...(visibleRangeRef.value || {}), >+ })); >+ >+ const { availability, disableFnRef } = useAvailability( >+ { >+ bookings, >+ checkouts, >+ bookableItems, >+ bookingItemId, >+ bookingId, >+ selectedDateRange, >+ circulationRules, >+ }, >+ availabilityOptionsRef >+ ); >+ >+ // Tooltip refs local to this component, used by the composable and rendered via BookingTooltip >+ const tooltipMarkers = ref([]); >+ const tooltipVisible = ref(false); >+ const tooltipX = ref(0); >+ const tooltipY = ref(0); >+ >+ const setErrorForFlatpickr = msg => props.setError(msg); >+ >+ const { clear } = useFlatpickr(inputEl, { >+ store, >+ disableFnRef, >+ constraintOptionsRef: toRef(props, "constraintOptions"), >+ setError: setErrorForFlatpickr, >+ tooltipMarkersRef: tooltipMarkers, >+ tooltipVisibleRef: tooltipVisible, >+ tooltipXRef: tooltipX, >+ tooltipYRef: tooltipY, >+ visibleRangeRef, >+ }); >+ >+ // Push availability map (on-demand for current view) to store for markers >+ watch( >+ () => availability.value?.unavailableByDate, >+ map => { >+ try { >+ store.setUnavailableByDate(map ?? {}); >+ } catch (e) { >+ // ignore if store shape differs in some contexts >+ } >+ }, >+ { immediate: true } >+ ); >+ >+ const clearDateRange = () => { >+ clear(); >+ emit("clear-dates"); >+ }; >+ >+ return { >+ clearDateRange, >+ inputEl, >+ constraintHelpText, >+ tooltipMarkers, >+ tooltipVisible, >+ tooltipX, >+ tooltipY, >+ }; >+ }, >+}; >+</script> >+ >+<style scoped> >+.step-block { >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.step-header { >+ font-weight: 600; >+ font-size: var(--booking-text-lg); >+ margin-bottom: calc(var(--booking-space-lg) * 0.75); >+ color: var(--booking-neutral-600); >+} >+ >+.form-group { >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.booking-date-picker { >+ display: flex; >+ align-items: center; >+} >+ >+.booking-flatpickr-input { >+ flex: 1; >+ margin-right: var(--booking-space-md); >+} >+ >+.booking-date-picker-append { >+ flex-shrink: 0; >+} >+ >+.booking-constraint-info { >+ margin-top: var(--booking-space-md); >+ margin-bottom: var(--booking-space-lg); >+} >+ >+.calendar-legend { >+ display: flex; >+ flex-wrap: wrap; >+ align-items: center; >+ gap: var(--booking-space-md); >+ font-size: var(--booking-text-sm); >+ margin-top: var(--booking-space-lg); >+} >+ >+.booking-marker-dot { >+ display: inline-block; >+ width: calc(var(--booking-marker-size) * 3); >+ height: calc(var(--booking-marker-size) * 3); >+ border-radius: var(--booking-border-radius-full); >+ margin-right: var(--booking-space-sm); >+ border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15); >+} >+ >+.booking-marker-dot--booked { >+ background-color: var(--booking-warning-bg); >+} >+ >+.booking-marker-dot--lead { >+ background-color: hsl(var(--booking-info-hue), 60%, 85%); >+} >+ >+.booking-marker-dot--trail { >+ background-color: var(--booking-warning-bg); >+} >+ >+.booking-marker-dot--checked-out { >+ background-color: hsl(var(--booking-danger-hue), 60%, 85%); >+} >+ >+.alert { >+ padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg); >+ border: var(--booking-border-width) solid transparent; >+ border-radius: var(--booking-border-radius-sm); >+} >+ >+.alert-info { >+ color: hsl(var(--booking-info-hue), 80%, 20%); >+ background-color: hsl(var(--booking-info-hue), 40%, 90%); >+ border-color: hsl(var(--booking-info-hue), 40%, 70%); >+} >+ >+.alert-danger { >+ color: hsl(var(--booking-danger-hue), 80%, 20%); >+ background-color: hsl(var(--booking-danger-hue), 40%, 90%); >+ border-color: hsl(var(--booking-danger-hue), 40%, 70%); >+} >+</style> >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 @@ >+<template> >+ <Teleport to="body"> >+ <div >+ v-if="visible" >+ class="booking-tooltip" >+ :style="{ >+ position: 'absolute', >+ zIndex: 2147483647, >+ whiteSpace: 'nowrap', >+ top: `${y}px`, >+ left: `${x}px`, >+ }" >+ role="tooltip" >+ > >+ <div >+ v-for="marker in markers" >+ :key="marker.type + ':' + (marker.barcode || marker.item)" >+ > >+ <span >+ :class="[ >+ 'booking-marker-dot', >+ `booking-marker-dot--${marker.type}`, >+ ]" >+ /> >+ {{ getMarkerTypeLabel(marker.type) }} ({{ $__("Barcode") }}: >+ {{ marker.barcode || marker.item || "N/A" }}) >+ </div> >+ </div> >+ </Teleport> >+</template> >+ >+<script setup lang="ts"> >+import { defineProps, withDefaults } from "vue"; >+import { $__ } from "../../i18n"; >+import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs"; >+import type { CalendarMarker } from "./types/bookings"; >+ >+withDefaults( >+ defineProps<{ >+ markers: CalendarMarker[]; >+ x: number; >+ y: number; >+ visible: boolean; >+ }>(), >+ { >+ markers: () => [], >+ x: 0, >+ y: 0, >+ visible: false, >+ } >+); >+ >+// getMarkerTypeLabel provided by shared UI helper >+</script> >+ >+<style scoped> >+.booking-tooltip { >+ background: hsl(var(--booking-warning-hue), 100%, 95%); >+ color: hsl(var(--booking-neutral-hue), 20%, 20%); >+ border: var(--booking-border-width) solid hsl(var(--booking-neutral-hue), 15%, 75%); >+ border-radius: var(--booking-border-radius-md); >+ box-shadow: 0 0.125rem 0.5rem hsla(var(--booking-neutral-hue), 10%, 0%, 0.08); >+ padding: calc(var(--booking-space-xs) * 3) calc(var(--booking-space-xs) * 5); >+ font-size: var(--booking-text-lg); >+ pointer-events: none; >+} >+ >+.booking-marker-dot { >+ display: inline-block; >+ width: calc(var(--booking-marker-size) * 1.25); >+ height: calc(var(--booking-marker-size) * 1.25); >+ border-radius: var(--booking-border-radius-full); >+ margin: 0 var(--booking-space-xs) 0 0; >+ vertical-align: middle; >+} >+ >+.booking-marker-dot--booked { >+ background: var(--booking-warning-bg); >+} >+ >+.booking-marker-dot--checked-out { >+ background: hsl(var(--booking-danger-hue), 60%, 85%); >+} >+ >+.booking-marker-dot--lead { >+ background: hsl(var(--booking-info-hue), 60%, 85%); >+} >+ >+.booking-marker-dot--trail { >+ background: var(--booking-warning-bg); >+} >+</style> >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 @@ >+<template> >+ <div class="form-group"> >+ <label for="booking_patron">{{ label }}</label> >+ <v-select >+ v-model="selectedPatron" >+ :options="patronOptions" >+ :filterable="false" >+ :loading="loading" >+ :placeholder="placeholder" >+ label="label" >+ :clearable="true" >+ :reset-on-blur="false" >+ :reset-on-select="false" >+ :input-id="'booking_patron'" >+ @search="debouncedPatronSearch" >+ > >+ <template #no-options> >+ <slot name="no-options" :has-searched="hasSearched" >+ >Sorry, no matching options.</slot >+ > >+ </template> >+ <template #spinner> >+ <slot name="spinner">Loading...</slot> >+ </template> >+ </v-select> >+ </div> >+</template> >+ >+<script> >+import { computed, ref } from "vue"; >+import vSelect from "vue-select"; >+import "vue-select/dist/vue-select.css"; >+import { processApiError } from "../../utils/apiErrors.js"; >+import { useBookingStore } from "../../stores/bookings"; >+import { storeToRefs } from "pinia"; >+import { debounce } from "./lib/adapters/external-dependents.mjs"; >+ >+export default { >+ name: "PatronSearchSelect", >+ components: { >+ vSelect, >+ }, >+ props: { >+ modelValue: { >+ type: Object, // Assuming patron object structure >+ default: null, >+ }, >+ label: { >+ type: String, >+ required: true, >+ }, >+ placeholder: { >+ type: String, >+ default: "", >+ }, >+ setError: { >+ type: Function, >+ default: null, >+ }, >+ }, >+ emits: ["update:modelValue"], >+ setup(props, { emit }) { >+ const store = useBookingStore(); >+ const { loading } = storeToRefs(store); >+ const patronOptions = ref([]); >+ const hasSearched = ref(false); // Track if user has performed a search >+ >+ const selectedPatron = computed({ >+ get: () => props.modelValue, >+ set: value => emit("update:modelValue", value), >+ }); >+ >+ const onPatronSearch = async search => { >+ if (!search || search.length < 2) { >+ hasSearched.value = false; >+ patronOptions.value = []; >+ return; >+ } >+ >+ hasSearched.value = true; >+ // Store handles loading state through withErrorHandling >+ try { >+ const data = await store.fetchPatrons(search); >+ patronOptions.value = data; >+ } catch (error) { >+ const msg = processApiError(error); >+ console.error("Error searching patrons:", msg); >+ if (typeof props.setError === "function") { >+ try { >+ props.setError(msg, "api"); >+ } catch (e) { >+ // no-op: avoid breaking search on error propagation >+ } >+ } >+ patronOptions.value = []; >+ } >+ }; >+ >+ const debouncedPatronSearch = debounce(onPatronSearch, 100); >+ >+ return { >+ selectedPatron, >+ patronOptions, // Expose internal options >+ loading: computed(() => loading.value.patrons), // Use store loading state >+ hasSearched, // Expose search state >+ debouncedPatronSearch, // Expose internal search handler >+ }; >+ }, >+}; >+</script> >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<import('../types/bookings').Booking[]>, >+ * checkouts: import('../types/bookings').RefLike<import('../types/bookings').Checkout[]>, >+ * bookableItems: import('../types/bookings').RefLike<import('../types/bookings').BookableItem[]>, >+ * bookingItemId: import('../types/bookings').RefLike<string|number|null>, >+ * bookingId: import('../types/bookings').RefLike<string|number|null>, >+ * selectedDateRange: import('../types/bookings').RefLike<string[]>, >+ * circulationRules: import('../types/bookings').RefLike<import('../types/bookings').CirculationRule[]> >+ * }} storeRefs >+ * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} optionsRef >+ * @returns {{ availability: import('vue').ComputedRef<import('../types/bookings').AvailabilityResult>, disableFnRef: import('vue').ComputedRef<import('../types/bookings').DisableFn>, unavailableByDateRef: import('vue').ComputedRef<import('../types/bookings').UnavailableByDate> }} >+ */ >+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<Array<import('../types/bookings').CirculationRule>>} 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<Array<import('../types/bookings').BookableItem>>} options.bookableItems >+ * @param {import('vue').Ref<import('../types/bookings').PatronLike|null>} options.bookingPatron >+ * @param {import('vue').Ref<string|number|null>} options.bookingItemId >+ * @param {import('vue').Ref<string|number|null>} options.bookingItemtypeId >+ * @param {import('vue').Ref<string|null>} 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<import('../types/bookings').ConstraintOptions>|undefined} constraintOptionsRef >+ * @returns {{ >+ * highlightingData: import('vue').ComputedRef<null | import('../types/bookings').ConstraintHighlighting> >+ * }} >+ */ >+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<import('../types/bookings').DisableFn>} options.disableFnRef - ref to disable fn >+ * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} 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<import('../types/bookings').CalendarMarker[]>} [options.tooltipMarkersRef] >+ * @param {import('../types/bookings').RefLike<boolean>} [options.tooltipVisibleRef] >+ * @param {import('../types/bookings').RefLike<number>} [options.tooltipXRef] >+ * @param {import('../types/bookings').RefLike<number>} [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<Function> >+ const constraintOptionsRef = options.constraintOptionsRef; // Ref<{dateRangeConstraint,maxBookingPeriod}> >+ const setError = options.setError; // function(string) >+ const tooltipMarkersRef = options.tooltipMarkersRef; // Ref<Array> >+ const tooltipVisibleRef = options.tooltipVisibleRef; // Ref<boolean> >+ const tooltipXRef = options.tooltipXRef; // Ref<number> >+ const tooltipYRef = options.tooltipYRef; // Ref<number> >+ 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<import('flatpickr/dist/types/options').Options>} */ >+ 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<import('../types/bookings').PatronLike|null>} options.bookingPatron >+ * @param {import('../types/bookings').RefLike<string|null>} options.bookingPickupLibraryId >+ * @param {import('../types/bookings').RefLike<string|number|null>} options.bookingItemtypeId >+ * @param {import('../types/bookings').RefLike<Array<import('../types/bookings').ItemType>>} options.constrainedItemTypes >+ * @param {import('../types/bookings').RefLike<Array<string>>} options.selectedDateRange >+ * @param {string|import('../types/bookings').RefLike<string>} options.biblionumber >+ * @returns {{ lastRulesKey: import('vue').Ref<string|null> }} >+ */ >+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<Object>>} 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<Object>>} 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<Object>>} 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<Object>} 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<Array>} >+ */ >+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<Object>>} 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>} 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<Object>>} 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<Object>>} 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<Object>>} 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<Object>} 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>} 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<Object>>} 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>} 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<Object>} 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<Object>} 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<number> }} */ ( >+ 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>|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<void>} >+ */ >+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<number> }} */ ( >+ 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<number> }} */ ( >+ 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<import('../../types/bookings').CalendarMarker[]>} tooltipMarkers - ref of markers shown in tooltip >+ * @param {import('../../types/bookings').RefLike<boolean>} tooltipVisible - visibility ref for tooltip >+ * @param {import('../../types/bookings').RefLike<number>} tooltipX - x position ref >+ * @param {import('../../types/bookings').RefLike<number>} 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<import('../../types/bookings').CalendarMarker[]>} tooltipMarkers >+ * @param {import('../../types/bookings').RefLike<boolean>} 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<string, { attempted: boolean, success?: boolean, reason?: string }>} 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<Object>} bookings - Array of booking objects >+ * @param {Array<Object>} 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<string>} allItemIds - All bookable item IDs >+ * @returns {Object<string, Object<string, Set<string>>>} 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<string, Record<string, Set<string>>>} */ >+ 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<string>} allItemIds - All bookable item IDs >+ * @returns {Object<string, Object<string, Set<string>>>} 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<import('../../types/bookings').BookableItem>} bookableItems - Array of bookable items >+ * @param {string|null} selectedItem - Selected item ID or null >+ * @param {number|null} editBookingId - Booking ID being edited >+ * @param {Array<Date>} 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<string>, 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<import('../../types/bookings').BookableItem>} 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<import('../../types/bookings').PickupLocation>} pickupLocations >+ * @param {Array<import('../../types/bookings').BookableItem>} bookableItems >+ * @param {string|number|null} bookingItemtypeId >+ * @param {string|number|null} bookingItemId >+ * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').PickupLocation>} >+ */ >+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<import('../../types/bookings').BookableItem>} bookableItems >+ * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations >+ * @param {string|null} pickupLibraryId >+ * @param {string|number|null} bookingItemtypeId >+ * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').BookableItem>} >+ */ >+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<import('../../types/bookings').ItemType>} itemTypes >+ * @param {Array<import('../../types/bookings').BookableItem>} bookableItems >+ * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations >+ * @param {string|null} pickupLibraryId >+ * @param {string|number|null} bookingItemId >+ * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').ItemType>} >+ */ >+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<Id>; >+}; >+ >+/** >+ * 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<string, Record<string, Set<UnavailabilityReason>>>; >+ >+/** 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<unknown>; >+ invalidateCalculatedDue: () => void; >+ fetchCirculationRules: ( >+ params: Record<string, unknown> >+ ) => Promise<unknown>; >+}; >+ >+/** Dependencies used for updating external widgets after booking changes. */ >+export type ExternalDependencies = { >+ timeline: () => any; >+ bookingsTable: () => any; >+ patronRenderer: () => any; >+ domQuery: (selector: string) => NodeListOf<HTMLElement>; >+ 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<T> = import('vue').Ref<T> | { 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<string | null | undefined>; >+ bookingItemId: import('vue').Ref<string | number | null | undefined>; >+ constrainedItemTypes: import('vue').Ref<Array<ItemType>>; >+ bookableItems: import('vue').Ref<Array<BookableItem>>; >+}; >+ >+/** Options object for `useDefaultPickup` composable. */ >+export type DefaultPickupOptions = { >+ bookingPickupLibraryId: import('vue').Ref<string | null | undefined>; >+ bookingPatron: import('vue').Ref<PatronLike | null>; >+ pickupLocations: import('vue').Ref<Array<PickupLocation>>; >+ bookableItems: import('vue').Ref<Array<BookableItem>>; >+ 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<boolean>; >+}; >+ >+/** 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<T> = { >+ 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<string, number>; >+ >+/** >+ * 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<number>; >+ } >+} >+ >+// 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 @@ >+<template> >+ <div v-if="show" :class="computedClass" role="alert"> >+ <slot>{{ message }}</slot> >+ <button >+ v-if="dismissible" >+ type="button" >+ class="close" >+ aria-label="Close" >+ @click="$emit('dismiss')" >+ > >+ <span aria-hidden="true">×</span> >+ </button> >+ </div> >+ <div v-else></div> >+</template> >+ >+<script> >+export default { >+ name: "KohaAlert", >+ props: { >+ show: { type: Boolean, default: true }, >+ variant: { >+ type: String, >+ default: "info", // info | warning | danger | success | secondary >+ }, >+ message: { type: String, default: "" }, >+ dismissible: { type: Boolean, default: false }, >+ extraClass: { type: String, default: "" }, >+ }, >+ computed: { >+ computedClass() { >+ const base = ["alert", `alert-${this.variant}`]; >+ if (this.dismissible) base.push("alert-dismissible"); >+ if (this.extraClass) base.push(this.extraClass); >+ return base.join(" "); >+ }, >+ }, >+}; >+</script> >+ >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<string, WebComponentDynamicImport> = > 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<string>} 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
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 41129
: 188546 |
188551