View | Details | Raw Unified | Return to bug 41129
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/cat-toolbar.inc (-6 / +3 lines)
Lines 281-290 Link Here
281
            [% END %]
281
            [% END %]
282
        [% END %]
282
        [% END %]
283
    [% END %]
283
    [% END %]
284
    [% IF ( Koha.Preference('EnableBooking') &&  CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %]
284
    [% IF ( Koha.Preference('EnableBooking') && CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %]
285
        <div class="btn-group"
285
        [% INCLUDE 'modals/booking/button-place.inc' %]
286
            ><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
286
        [% INCLUDE 'modals/booking/island.inc' %]
287
        >
288
    [% END %]
287
    [% END %]
289
288
290
    [% IF Koha.Preference('ArticleRequests') %]
289
    [% IF Koha.Preference('ArticleRequests') %]
Lines 340-344 Link Here
340
        </div>
339
        </div>
341
    </div>
340
    </div>
342
</div>
341
</div>
343
344
[% INCLUDE modals/place_booking.inc %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-edit.inc (+3 lines)
Line 0 Link Here
1
<div class="btn-group">
2
    <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>
3
</div>
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/button-place.inc (+3 lines)
Line 0 Link Here
1
<div class="btn-group">
2
    <button class="btn btn-default" data-booking-modal type="button"><i class="fa fa-calendar"></i> Place Booking </button>
3
</div>
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/booking/island.inc (+92 lines)
Line 0 Link Here
1
[% USE Koha %]
2
3
<div id="booking-modal-mount">
4
    <booking-modal-island
5
        biblionumber="[% biblionumber | html %]"
6
        show-patron-select
7
        show-item-details-selects
8
        show-pickup-location-select
9
        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 %]
10
    ></booking-modal-island>
11
</div>
12
[% SET islands = Asset.js("js/vue/dist/islands.esm.js").match('(src="([^"]+)")').1 %] <script src="[% islands %]" type="module"></script>
13
<script type="module">
14
    import { hydrate } from "[% islands %]";
15
    hydrate();
16
17
    const island = document.querySelector("booking-modal-island");
18
19
    const parseJson = value => {
20
        if (!value) return [];
21
        try {
22
            return JSON.parse(value);
23
        } catch (error) {
24
            console.warn("Failed to parse booking modal payload", error);
25
            return [];
26
        }
27
    };
28
29
    const normalizeProps = source => {
30
        if (!island || !source) return;
31
32
        const bookingId = source.booking ?? source.bookingId ?? null;
33
        const itemId = source.itemnumber ?? source.itemId ?? null;
34
        const patronId = source.patron ?? source.patronId ?? null;
35
        const pickupLibraryId =
36
            source.pickup_library ?? source.pickupLibraryId ?? null;
37
        const startDate = source.start_date ?? source.startDate ?? null;
38
        const endDate = source.end_date ?? source.endDate ?? null;
39
        const itemtypeId =
40
            source.item_type_id ?? source.itemtypeId ?? source.itemTypeId ?? null;
41
        const biblionumber =
42
            source.biblionumber ?? source.biblio_id ?? source.biblioId;
43
44
        island.bookingId = bookingId;
45
        island.itemId = itemId;
46
        island.patronId = patronId;
47
        island.pickupLibraryId = pickupLibraryId;
48
        island.startDate = startDate;
49
        island.endDate = endDate;
50
        island.itemtypeId = itemtypeId;
51
        island.selectedDateRange = startDate
52
            ? [startDate, endDate || startDate]
53
            : [];
54
55
        const extendedAttributes =
56
            source.extendedAttributes ?? source.extended_attributes ?? [];
57
        island.extendedAttributes = Array.isArray(extendedAttributes)
58
            ? extendedAttributes
59
            : parseJson(extendedAttributes);
60
61
        if (biblionumber) {
62
            island.biblionumber = biblionumber;
63
        }
64
    };
65
66
    const openModal = props => {
67
        if (!island) return;
68
        normalizeProps(props || {});
69
        island.open = true;
70
    };
71
72
    if (island) {
73
        island.addEventListener("close", () => {
74
            island.open = false;
75
        });
76
        /* This might need to be optimised if we ever
77
         * run into noticeable lag on click events. */
78
        document.addEventListener(
79
            "click",
80
            e => {
81
                const trigger = e.target.closest("[data-booking-modal]");
82
                if (!trigger) return;
83
                openModal(trigger.dataset);
84
            },
85
            { passive: true }
86
        );
87
88
        if (typeof window.openBookingModal !== "function") {
89
            window.openBookingModal = openModal;
90
        }
91
    }
92
</script>
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/place_booking.inc (-66 lines)
Lines 1-66 Link Here
1
[% USE ItemTypes %]
2
<!-- Place booking modal -->
3
<div class="modal" id="placeBookingModal" tabindex="-1" role="dialog" aria-labelledby="placeBookingLabel">
4
    <form method="get" id="placeBookingForm">
5
        <div class="modal-dialog modal-lg">
6
            <div class="modal-content">
7
                <div class="modal-header">
8
                    <h1 class="modal-title" id="placeBookingLabel"></h1>
9
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
10
                </div>
11
                <div class="modal-body">
12
                    <div id="booking_result"></div>
13
                    <fieldset class="brief">
14
                        <input type="hidden" name="biblio_id" id="booking_id" />
15
                        <input type="hidden" name="biblio_id" id="booking_biblio_id" />
16
                        <input type="hidden" name="start_date" id="booking_start_date" />
17
                        <input type="hidden" name="end_date" id="booking_end_date" />
18
                        <ol>
19
                            <li>
20
                                <label class="required" for="booking_patron_id">Patron: </label>
21
                                <select name="booking_patron_id" id="booking_patron_id" required="required">
22
                                    <option></option>
23
                                    [% IF patron %]
24
                                        <option value="[% borrowernumber | uri %]" selected="selected">[% patron.firstname | html %] [% patron.surname | html %] ([% patron.cardnumber | html %] )</option>
25
                                    [% END %]
26
                                </select>
27
                                <div class="hint">Enter patron card number or partial name</div>
28
                            </li>
29
                            <li>
30
                                <label class="required" for="pickup_library_id">Pickup at:</label>
31
                                <select name="booking_pickup" id="pickup_library_id" required="required" disabled="disabled"></select>
32
                                <span class="required">Required</span>
33
                            </li>
34
                            <li>
35
                                <label for="booking_itemtype">Itemtype: </label>
36
                                <select id="booking_itemtype" name="booking_itemtype" disabled="disabled"> </select> </li
37
                            ><li>
38
                                <label for="booking_item_id">Item: </label>
39
                                <select name="booking_item_id" id="booking_item_id" disabled="disabled">
40
                                    <option value="0">Any item</option>
41
                                </select>
42
                            </li>
43
                            <li>
44
                                <div id="period_fields">
45
                                    <label class="required" for="period">Booking dates: </label>
46
                                    <input type="text" id="period" name="period" class="flatpickr" data-flatpickr-futuredate="true" data-flatpickr-disable-shortcuts="true" required="required" disabled="disabled" autocomplete="off" />
47
                                    <span class="required">Required</span>
48
                                </div>
49
                                <div class="hint">Select the booking start and end date</div>
50
                            </li>
51
                        </ol>
52
                    </fieldset>
53
                </div>
54
                <!-- /.modal-body -->
55
                <div class="modal-footer">
56
                    <button type="submit" class="btn btn-primary">Submit</button>
57
                    <button type="button" class="btn btn-default" data-bs-dismiss="modal">Cancel</button>
58
                </div>
59
                <!-- /.modal-footer -->
60
            </div>
61
            <!-- /.modal-content -->
62
        </div>
63
        <!-- /.modal-dialog -->
64
    </form>
65
</div>
66
<!-- /#placeBookingModal -->
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/bookings/list.tt (-17 / +19 lines)
Lines 63-75 Link Here
63
    <script>
63
    <script>
64
        dayjs.extend(window.dayjs_plugin_isSameOrBefore);
64
        dayjs.extend(window.dayjs_plugin_isSameOrBefore);
65
    </script>
65
    </script>
66
    [% Asset.js("js/modals/place_booking.js") | $raw %]
67
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
66
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
68
    [% Asset.js("js/combobox.js") | $raw %]
67
    [% Asset.js("js/combobox.js") | $raw %]
69
    [% Asset.js("js/additional-filters.js") | $raw %]
68
    [% Asset.js("js/additional-filters.js") | $raw %]
70
    <script>
69
    <script>
71
        var cancel_success = 0;
70
    var cancel_success = 0;
72
        var update_success = 0;
73
        var bookings_table;
71
        var bookings_table;
74
        var timeline;
72
        var timeline;
75
        let biblionumber = "[% biblionumber | uri %]";
73
        let biblionumber = "[% biblionumber | uri %]";
Lines 166-185 Link Here
166
164
167
                        // action events
165
                        // action events
168
                        onMove: function (data, callback) {
166
                        onMove: function (data, callback) {
169
                            let startDate = dayjs(data.start);
167
                            const startDate = dayjs(data.start).toISOString();
168
                            const endDate = dayjs(data.end)
169
                                .endOf('day')
170
                                .toISOString();
170
171
171
                            // set end datetime hours and minutes to the end of the day
172
                            if (typeof window.openBookingModal === 'function') {
172
                            let endDate = dayjs(data.end).endOf('day');
173
                                window.openBookingModal({
174
                                    booking: data.id,
175
                                    biblionumber: "[% biblionumber | html %]",
176
                                    itemnumber: data.group,
177
                                    patron: data.patron,
178
                                    pickup_library: data.pickup_library,
179
                                    start_date: startDate,
180
                                    end_date: endDate,
181
                                    item_type_id: data.item_type_id,
182
                                });
183
                            }
173
184
174
                            $('#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()+'">'));
185
                            callback(null);
175
                            $('#placeBookingModal').on('hide.bs.modal', function(e) {
176
                                if (update_success) {
177
                                    update_success = 0;
178
                                    callback(data);
179
                                } else {
180
                                    callback(null);
181
                                }
182
                            });
183
                        },
186
                        },
184
                        onRemove: function (item, callback) {
187
                        onRemove: function (item, callback) {
185
                            const button = document.createElement('button');
188
                            const button = document.createElement('button');
Lines 368-375 Link Here
368
                        if (!is_readonly) {
371
                        if (!is_readonly) {
369
                            result += `
372
                            result += `
370
                                <button type="button" class="btn btn-default btn-xs edit-action"
373
                                <button type="button" class="btn btn-default btn-xs edit-action"
371
                                    data-bs-toggle="modal"
374
                                    data-booking-modal
372
                                    data-bs-target="#placeBookingModal"
373
                                    data-booking="%s"
375
                                    data-booking="%s"
374
                                    data-biblionumber="%s"
376
                                    data-biblionumber="%s"
375
                                    data-itemnumber="%s"
377
                                    data-itemnumber="%s"
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/ISBDdetail.tt (-1 lines)
Lines 87-93 Link Here
87
    [% IF Koha.Preference('EnableBooking') %]
87
    [% IF Koha.Preference('EnableBooking') %]
88
        [% INCLUDE 'calendar.inc' %]
88
        [% INCLUDE 'calendar.inc' %]
89
        [% INCLUDE 'select2.inc' %]
89
        [% INCLUDE 'select2.inc' %]
90
        [% Asset.js("js/modals/place_booking.js") | $raw %]
91
    [% END %]
90
    [% END %]
92
91
93
    [% Asset.js("js/browser.js") | $raw %]
92
    [% Asset.js("js/browser.js") | $raw %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/MARCdetail.tt (-1 lines)
Lines 211-217 Link Here
211
    [% IF Koha.Preference('EnableBooking') %]
211
    [% IF Koha.Preference('EnableBooking') %]
212
        [% INCLUDE 'calendar.inc' %]
212
        [% INCLUDE 'calendar.inc' %]
213
        [% INCLUDE 'select2.inc' %]
213
        [% INCLUDE 'select2.inc' %]
214
        [% Asset.js("js/modals/place_booking.js") | $raw %]
215
    [% END %]
214
    [% END %]
216
215
217
    [% Asset.js("js/browser.js") | $raw %]
216
    [% Asset.js("js/browser.js") | $raw %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt (-1 lines)
Lines 1723-1729 Link Here
1723
    [% Asset.js("js/browser.js") | $raw %]
1723
    [% Asset.js("js/browser.js") | $raw %]
1724
1724
1725
    [% IF Koha.Preference('EnableBooking') %]
1725
    [% IF Koha.Preference('EnableBooking') %]
1726
        [% Asset.js("js/modals/place_booking.js") | $raw %]
1727
    [% END %]
1726
    [% END %]
1728
    <script>
1727
    <script>
1729
        var browser;
1728
        var browser;
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/imageviewer.tt (-1 lines)
Lines 141-147 Link Here
141
    [% IF Koha.Preference('EnableBooking') %]
141
    [% IF Koha.Preference('EnableBooking') %]
142
        [% INCLUDE 'calendar.inc' %]
142
        [% INCLUDE 'calendar.inc' %]
143
        [% INCLUDE 'select2.inc' %]
143
        [% INCLUDE 'select2.inc' %]
144
        [% Asset.js("js/modals/place_booking.js") | $raw %]
145
    [% END %]
144
    [% END %]
146
145
147
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
146
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/labeledMARCdetail.tt (-1 lines)
Lines 101-107 Link Here
101
    [% IF Koha.Preference('EnableBooking') %]
101
    [% IF Koha.Preference('EnableBooking') %]
102
        [% INCLUDE 'calendar.inc' %]
102
        [% INCLUDE 'calendar.inc' %]
103
        [% INCLUDE 'select2.inc' %]
103
        [% INCLUDE 'select2.inc' %]
104
        [% Asset.js("js/modals/place_booking.js") | $raw %]
105
    [% END %]
104
    [% END %]
106
105
107
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
106
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/moredetail.tt (-1 lines)
Lines 593-599 Link Here
593
    [% IF Koha.Preference('EnableBooking') %]
593
    [% IF Koha.Preference('EnableBooking') %]
594
        [% INCLUDE 'calendar.inc' %]
594
        [% INCLUDE 'calendar.inc' %]
595
        [% INCLUDE 'select2.inc' %]
595
        [% INCLUDE 'select2.inc' %]
596
        [% Asset.js("js/modals/place_booking.js") | $raw %]
597
    [% END %]
596
    [% END %]
598
597
599
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
598
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
(-)a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js (-1312 lines)
Lines 1-1312 Link Here
1
let dataFetched = false;
2
let bookable_items,
3
    bookings,
4
    checkouts,
5
    booking_id,
6
    booking_item_id,
7
    booking_patron,
8
    booking_itemtype_id;
9
10
function containsAny(integers1, integers2) {
11
    // Create a hash set to store integers from the second array
12
    let integerSet = {};
13
    for (let i = 0; i < integers2.length; i++) {
14
        integerSet[integers2[i]] = true;
15
    }
16
17
    // Check if any integer from the first array exists in the hash set
18
    for (let i = 0; i < integers1.length; i++) {
19
        if (integerSet[integers1[i]]) {
20
            return true; // Found a match, return true
21
        }
22
    }
23
24
    return false; // No match found
25
}
26
27
$("#placeBookingModal").on("show.bs.modal", function (e) {
28
    // Get context
29
    let button = $(e.relatedTarget);
30
    let biblionumber = button.data("biblionumber");
31
    $("#booking_biblio_id").val(biblionumber);
32
33
    let patron_id = button.data("patron") || 0;
34
    let pickup_library_id = button.data("pickup_library");
35
    booking_item_id = button.data("itemnumber");
36
    let start_date = button.data("start_date");
37
    let end_date = button.data("end_date");
38
    let item_type_id = button.data("item_type_id");
39
40
    // Get booking id if this is an edit
41
    booking_id = button.data("booking");
42
    if (booking_id) {
43
        $("#placeBookingLabel").html(__("Edit booking"));
44
        $("#booking_id").val(booking_id);
45
    } else {
46
        $("#placeBookingLabel").html(__("Place booking"));
47
        // Ensure we don't accidentally update a booking
48
        $("#booking_id").val("");
49
    }
50
51
    // Patron select2
52
    $("#booking_patron_id").kohaSelect({
53
        dropdownParent: $(".modal-content", "#placeBookingModal"),
54
        width: "50%",
55
        dropdownAutoWidth: true,
56
        allowClear: true,
57
        minimumInputLength: 3,
58
        ajax: {
59
            url: "/api/v1/patrons",
60
            delay: 250,
61
            dataType: "json",
62
            headers: {
63
                "x-koha-embed": "library",
64
            },
65
            data: function (params) {
66
                let q = buildPatronSearchQuery(params.term);
67
                let query = {
68
                    q: JSON.stringify(q),
69
                    _page: params.page,
70
                    _order_by: "+me.surname,+me.firstname",
71
                };
72
                return query;
73
            },
74
            processResults: function (data, params) {
75
                let results = [];
76
                data.results.forEach(function (patron) {
77
                    patron.id = patron.patron_id;
78
                    results.push(patron);
79
                });
80
                return {
81
                    results: results,
82
                    pagination: { more: data.pagination.more },
83
                };
84
            },
85
        },
86
        templateResult: function (patron) {
87
            if (patron.library_id == loggedInLibrary) {
88
                loggedInClass = "ac-currentlibrary";
89
            } else {
90
                loggedInClass = "";
91
            }
92
93
            let $patron = $("<span></span>")
94
                .append(
95
                    "" +
96
                        (patron.surname
97
                            ? escape_str(patron.surname) + ", "
98
                            : "") +
99
                        (patron.firstname
100
                            ? escape_str(patron.firstname) + " "
101
                            : "") +
102
                        (patron.cardnumber
103
                            ? " (" + escape_str(patron.cardnumber) + ")"
104
                            : "") +
105
                        "<small>" +
106
                        (patron.date_of_birth
107
                            ? ' <span class="age_years">' +
108
                              $get_age(patron.date_of_birth) +
109
                              " " +
110
                              __("years") +
111
                              "</span>"
112
                            : "") +
113
                        (patron.library
114
                            ? ' <span class="ac-library">' +
115
                              escape_str(patron.library.name) +
116
                              "</span>"
117
                            : "") +
118
                        "</small>"
119
                )
120
                .addClass(loggedInClass);
121
            return $patron;
122
        },
123
        templateSelection: function (patron) {
124
            if (!patron.surname) {
125
                return patron.text;
126
            }
127
            return patron.surname + ", " + patron.firstname;
128
        },
129
        placeholder: __("Search for a patron"),
130
    });
131
132
    // Circulation rules update
133
    let leadDays = 0;
134
    let trailDays = 0;
135
    let boldDates = [];
136
    let issueLength;
137
    let renewalLength;
138
    let renewalsAllowed;
139
140
    // Note: For now, we apply the pickup library rules for issuelength, renewalsallowed and renewalperiod.
141
    // This effectively makes these circulation rules hard coded to CircControl: ItemHomeLibrary + HomeOrHolding: holdingbranch
142
    // Whilst it would be beneficial to make this follow those rules more closely, this would require some significant thinking
143
    // around how to best display this in the calendar component for the 'Any item' case.
144
    function getCirculationRules() {
145
        let rules_url = "/api/v1/circulation_rules";
146
        if (booking_patron && pickup_library_id && booking_itemtype_id) {
147
            $.ajax({
148
                url: rules_url,
149
                type: "GET",
150
                dataType: "json",
151
                data: {
152
                    patron_category_id: booking_patron.category_id,
153
                    item_type_id: booking_itemtype_id,
154
                    library_id: pickup_library_id,
155
                    rules: "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod",
156
                },
157
                success: function (response) {
158
                    let rules = response[0];
159
                    let changed =
160
                        issueLength !== rules.issuelength ||
161
                        renewalsAllowed !== rules.renewalsallowed ||
162
                        renewalLength !== rules.renewalperiod;
163
                    issueLength = rules.issuelength;
164
                    renewalsAllowed = rules.renewalsallowed;
165
                    renewalLength = rules.renewalperiod;
166
                    leadDays = rules.bookings_lead_period;
167
                    trailDays = rules.bookings_trail_period;
168
169
                    // redraw pariodPicker taking selected item into account
170
                    if (changed) {
171
                        periodPicker.clear();
172
                    }
173
                    periodPicker.redraw();
174
175
                    // Enable flatpickr now we have data we need
176
                    if (dataFetched) {
177
                        $("#period_fields :input").prop("disabled", false);
178
                    }
179
                },
180
                error: function (xhr, status, error) {
181
                    console.log("Circulation rules fetch failed: ", error);
182
                },
183
            });
184
        } else {
185
            periodPicker.clear();
186
            $("#period_fields :input").prop("disabled", true);
187
        }
188
    }
189
190
    // Pickup location select2
191
    let pickup_url = "/api/v1/biblios/" + biblionumber + "/pickup_locations";
192
    $("#pickup_library_id").select2({
193
        dropdownParent: $(".modal-content", "#placeBookingModal"),
194
        width: "50%",
195
        dropdownAutoWidth: true,
196
        minimumResultsForSearch: 10,
197
        allowClear: false,
198
        placeholder: __("Pickup location"),
199
    });
200
    function setLocationsPicker(response) {
201
        let $pickupSelect = $("#pickup_library_id");
202
        let $itemTypeSelect = $("#booking_itemtype");
203
        let bookableItemnumbers = bookable_items.map(function (object) {
204
            return object.item_id;
205
        });
206
        $pickupSelect.empty();
207
208
        $.each(response, function (index, pickup_location) {
209
            if (
210
                containsAny(pickup_location.pickup_items, bookableItemnumbers)
211
            ) {
212
                let option = $(
213
                    '<option value="' +
214
                        pickup_location.library_id +
215
                        '">' +
216
                        pickup_location.name +
217
                        "</option>"
218
                );
219
220
                option.attr(
221
                    "data-needs_override",
222
                    pickup_location.needs_override
223
                );
224
                option.attr(
225
                    "data-pickup_items",
226
                    pickup_location.pickup_items.join(",")
227
                );
228
229
                $pickupSelect.append(option);
230
            }
231
        });
232
233
        $pickupSelect.prop("disabled", false);
234
235
        // If pickup_library already exists, pre-select
236
        if (pickup_library_id) {
237
            $pickupSelect.val(pickup_library_id).trigger("change");
238
        } else {
239
            $pickupSelect.val(null).trigger("change");
240
        }
241
242
        // If item_type_id already exists, pre-select
243
        if (item_type_id) {
244
            $itemTypeSelect.val(item_type_id).trigger("change");
245
        } else {
246
            $itemTypeSelect.val(null).trigger("change");
247
        }
248
    }
249
250
    // Itemtype select2
251
    $("#booking_itemtype").select2({
252
        dropdownParent: $(".modal-content", "#placeBookingModal"),
253
        width: "50%",
254
        allowClear: true,
255
        dropdownAutoWidth: true,
256
        minimumResultsForSearch: 20,
257
        placeholder: __("Item type"),
258
    });
259
260
    // Item select2
261
    $("#booking_item_id").select2({
262
        dropdownParent: $(".modal-content", "#placeBookingModal"),
263
        width: "50%",
264
        dropdownAutoWidth: true,
265
        minimumResultsForSearch: 10,
266
        allowClear: false,
267
    });
268
269
    // Patron selection triggers
270
    $("#booking_patron_id").on("select2:select", function (e) {
271
        booking_patron = e.params.data;
272
273
        // Fetch pickup locations and enable picker
274
        $.ajax({
275
            url: pickup_url,
276
            type: "GET",
277
            dataType: "json",
278
            data: {
279
                _order_by: "name",
280
                _per_page: "-1",
281
                patron_id: booking_patron.patron_id,
282
            },
283
            success: function (response) {
284
                if (dataFetched === true) {
285
                    setLocationsPicker(response);
286
                } else {
287
                    var interval = setInterval(function () {
288
                        if (dataFetched === true) {
289
                            // Data is fetched, execute the callback and stop the interval
290
                            setLocationsPicker(response);
291
                            clearInterval(interval);
292
                        }
293
                    }, 100);
294
                }
295
            },
296
            error: function (xhr, status, error) {
297
                console.log("Pickup location fetch failed: ", error);
298
            },
299
        });
300
301
        // Enable item selection if item data is also fetched
302
        let $bookingItemSelect = $("#booking_item_id");
303
        $bookingItemSelect.data("patron", true);
304
        if ($bookingItemSelect.data("loaded")) {
305
            $bookingItemSelect.prop("disabled", false);
306
        }
307
308
        // Enable itemtype selection if item data if also fetched
309
        let $bookingItemtypeSelect = $("#booking_itemtype");
310
        $bookingItemtypeSelect.data("patron", true);
311
        if ($bookingItemtypeSelect.data("loaded")) {
312
            $bookingItemtypeSelect.prop("disabled", false);
313
        }
314
315
        // Populate circulation rules
316
        getCirculationRules();
317
    });
318
319
    // Adopt periodPicker
320
    let periodPicker = $("#period").get(0)._flatpickr;
321
322
    if (!dataFetched) {
323
        // Fetch list of bookable items
324
        let itemsFetch = $.ajax({
325
            url:
326
                "/api/v1/biblios/" +
327
                biblionumber +
328
                "/items?bookable=1" +
329
                "&_per_page=-1",
330
            dataType: "json",
331
            type: "GET",
332
            headers: {
333
                "x-koha-embed": "item_type",
334
            },
335
        });
336
337
        // Fetch list of existing bookings
338
        let bookingsFetch = $.ajax({
339
            url:
340
                "/api/v1/bookings?biblio_id=" +
341
                biblionumber +
342
                "&_per_page=-1" +
343
                '&q={"status":{"-in":["new","pending","active"]}}',
344
            dataType: "json",
345
            type: "GET",
346
        });
347
348
        // Fetch list of current checkouts
349
        let checkoutsFetch = $.ajax({
350
            url: "/api/v1/biblios/" + biblionumber + "/checkouts?_per_page=-1",
351
            dataType: "json",
352
            type: "GET",
353
        });
354
355
        // Update item select2 and period flatpickr
356
        $.when(itemsFetch, bookingsFetch, checkoutsFetch).then(
357
            function (itemsFetch, bookingsFetch, checkoutsFetch) {
358
                // Set variables
359
                bookable_items = itemsFetch[0];
360
                bookings = bookingsFetch[0];
361
                checkouts = checkoutsFetch[0];
362
363
                // Merge current checkouts into bookings
364
                for (checkout of checkouts) {
365
                    let already_booked = bookings.some(
366
                        b => b.item_id === checkout.item_id
367
                    );
368
                    if (!already_booked) {
369
                        let booking = {
370
                            biblio_id: biblionumber,
371
                            booking_id: null,
372
                            end_date: checkout.due_date,
373
                            item_id: checkout.item_id,
374
                            patron_id: checkout.patron_id,
375
                            start_date: new Date().toISOString(),
376
                        };
377
                        bookings.unshift(booking);
378
                    }
379
                }
380
381
                // Update flatpickr mode
382
                periodPicker.set("mode", "range");
383
384
                // Total bookable items
385
                let bookable = 0;
386
                for (item of bookable_items) {
387
                    bookable++;
388
389
                    // Populate item select
390
                    if (
391
                        !$("#booking_item_id").find(
392
                            "option[value='" + item.item_id + "']"
393
                        ).length
394
                    ) {
395
                        // Create a DOM Option and de-select by default
396
                        let newOption = new Option(
397
                            escape_str(item.external_id),
398
                            item.item_id,
399
                            false,
400
                            false
401
                        );
402
                        newOption.setAttribute("data-available", true);
403
                        newOption.setAttribute(
404
                            "data-itemtype",
405
                            item.effective_item_type_id
406
                        );
407
408
                        // Append it to the select
409
                        $("#booking_item_id").append(newOption);
410
                    }
411
412
                    // Populate item types select
413
                    if (
414
                        !$("#booking_itemtype").find(
415
                            "option[value='" +
416
                                item.item_type.item_type_id +
417
                                "']"
418
                        ).length
419
                    ) {
420
                        // Create a DOM Option and de-select by default
421
                        let newTypeOption = new Option(
422
                            escape_str(item.item_type.description),
423
                            item.item_type.item_type_id,
424
                            false,
425
                            false
426
                        );
427
                        $("#booking_itemtype").append(newTypeOption);
428
                    }
429
                }
430
                $("#booking_itemtype").val(null).trigger("change");
431
432
                // Set disable function for periodPicker
433
                let disableExists = periodPicker.config.disable.filter(
434
                    f => f.name === "dateDisable"
435
                );
436
                if (disableExists.length === 0) {
437
                    periodPicker.config.disable.push(
438
                        function dateDisable(date) {
439
                            // set local copy of selectedDates
440
                            let selectedDates = periodPicker.selectedDates;
441
442
                            // set booked counter
443
                            let booked = 0;
444
445
                            // reset the unavailable items array
446
                            let unavailable_items = [];
447
448
                            // reset the biblio level bookings array
449
                            let biblio_bookings = [];
450
451
                            // disable dates before selected date
452
                            if (
453
                                !selectedDates[1] &&
454
                                selectedDates[0] &&
455
                                selectedDates[0] > date
456
                            ) {
457
                                return true;
458
                            }
459
460
                            // iterate existing bookings
461
                            for (booking of bookings) {
462
                                // Skip if we're editing this booking
463
                                if (
464
                                    booking_id &&
465
                                    booking_id == booking.booking_id
466
                                ) {
467
                                    continue;
468
                                }
469
470
                                let start_date = flatpickr.parseDate(
471
                                    booking.start_date
472
                                );
473
                                let end_date = flatpickr.parseDate(
474
                                    booking.end_date
475
                                );
476
477
                                // patron has selected a start date (end date checks)
478
                                if (selectedDates[0]) {
479
                                    // new booking start date is between existing booking start and end dates
480
                                    if (
481
                                        selectedDates[0] >= start_date &&
482
                                        selectedDates[0] <= end_date
483
                                    ) {
484
                                        if (booking.item_id) {
485
                                            if (
486
                                                unavailable_items.indexOf(
487
                                                    booking.item_id
488
                                                ) === -1
489
                                            ) {
490
                                                unavailable_items.push(
491
                                                    booking.item_id
492
                                                );
493
                                            }
494
                                        } else {
495
                                            if (
496
                                                biblio_bookings.indexOf(
497
                                                    booking.booking_id
498
                                                ) === -1
499
                                            ) {
500
                                                biblio_bookings.push(
501
                                                    booking.booking_id
502
                                                );
503
                                            }
504
                                        }
505
                                    }
506
507
                                    // new booking end date would be between existing booking start and end dates
508
                                    else if (
509
                                        date >= start_date &&
510
                                        date <= end_date
511
                                    ) {
512
                                        if (booking.item_id) {
513
                                            if (
514
                                                unavailable_items.indexOf(
515
                                                    booking.item_id
516
                                                ) === -1
517
                                            ) {
518
                                                unavailable_items.push(
519
                                                    booking.item_id
520
                                                );
521
                                            }
522
                                        } else {
523
                                            if (
524
                                                biblio_bookings.indexOf(
525
                                                    booking.booking_id
526
                                                ) === -1
527
                                            ) {
528
                                                biblio_bookings.push(
529
                                                    booking.booking_id
530
                                                );
531
                                            }
532
                                        }
533
                                    }
534
535
                                    // new booking would span existing booking
536
                                    else if (
537
                                        selectedDates[0] <= start_date &&
538
                                        date >= end_date
539
                                    ) {
540
                                        if (booking.item_id) {
541
                                            if (
542
                                                unavailable_items.indexOf(
543
                                                    booking.item_id
544
                                                ) === -1
545
                                            ) {
546
                                                unavailable_items.push(
547
                                                    booking.item_id
548
                                                );
549
                                            }
550
                                        } else {
551
                                            if (
552
                                                biblio_bookings.indexOf(
553
                                                    booking.booking_id
554
                                                ) === -1
555
                                            ) {
556
                                                biblio_bookings.push(
557
                                                    booking.booking_id
558
                                                );
559
                                            }
560
                                        }
561
                                    }
562
563
                                    // new booking would not conflict
564
                                    else {
565
                                        continue;
566
                                    }
567
568
                                    // check that there are available items
569
                                    // available = all bookable items - booked items - booked biblios
570
                                    let total_available =
571
                                        bookable_items.length -
572
                                        unavailable_items.length -
573
                                        biblio_bookings.length;
574
                                    if (total_available === 0) {
575
                                        return true;
576
                                    }
577
                                }
578
579
                                // patron has not yet selected a start date (start date checks)
580
                                else if (
581
                                    date <= end_date &&
582
                                    date >= start_date
583
                                ) {
584
                                    // same item, disable date
585
                                    if (
586
                                        booking.item_id &&
587
                                        booking.item_id == booking_item_id
588
                                    ) {
589
                                        return true;
590
                                    }
591
592
                                    // count all clashes, both item and biblio level
593
                                    booked++;
594
                                    if (booked == bookable) {
595
                                        return true;
596
                                    }
597
598
                                    // FIXME: The above is not intelligent enough to spot
599
                                    // cases where an item must be used for a biblio level booking
600
                                    // due to all other items being booking within the biblio level
601
                                    // booking period... we end up with a clash
602
                                    // To reproduce:
603
                                    // * One bib with two bookable items.
604
                                    // * Add item level booking
605
                                    // * Add biblio level booking that extends one day beyond the item level booking
606
                                    // * Try to book the item without an item level booking from the day before the biblio level
607
                                    //   booking is to be returned. Note this is a clash, the only item available for the biblio
608
                                    //   level booking is the item you just booked out overlapping the end date.
609
                                }
610
                            }
611
                        }
612
                    );
613
                }
614
615
                // Setup listener for itemtype select2
616
                $("#booking_itemtype").on("change", function (e) {
617
                    let selectedValue = $(this).val(); // Get selected value (null if cleared)
618
                    booking_itemtype_id = selectedValue ? selectedValue : null;
619
620
                    // Handle item selectionue
621
                    if (!booking_itemtype_id) {
622
                        // Enable all items for selection
623
                        $("#booking_item_id > option").prop("disabled", false);
624
                    } else {
625
                        // Disable items not of this itemtype
626
                        $("#booking_item_id > option").each(function () {
627
                            let option = $(this);
628
                            if (option.val() != 0) {
629
                                let item_itemtype = option.data("itemtype");
630
                                if (item_itemtype == booking_itemtype_id) {
631
                                    if (
632
                                        option.data("available") &&
633
                                        option.data("pickup")
634
                                    ) {
635
                                        option.prop("disabled", false);
636
                                    }
637
                                } else {
638
                                    option.prop("disabled", true);
639
                                }
640
                            }
641
                        });
642
                    }
643
                    $("#booking_item_id").trigger("change.select2");
644
645
                    // Update circulation rules
646
                    getCirculationRules();
647
                });
648
649
                // Setup listener for item select2
650
                $("#booking_item_id").on("select2:select", function (e) {
651
                    booking_item_id = e.params.data.id
652
                        ? e.params.data.id
653
                        : null;
654
655
                    // Disable invalid pickup locations
656
                    $("#pickup_library_id > option").each(function () {
657
                        let option = $(this);
658
                        if (booking_item_id == 0) {
659
                            option.prop("disabled", false);
660
                        } else {
661
                            let valid_items = String(
662
                                option.data("pickup_items")
663
                            )
664
                                .split(",")
665
                                .map(Number);
666
                            if (
667
                                valid_items.includes(parseInt(booking_item_id))
668
                            ) {
669
                                option.prop("disabled", false);
670
                            } else {
671
                                option.prop("disabled", true);
672
                            }
673
                        }
674
                    });
675
                    $("#pickup_library_id").trigger("change.select2");
676
677
                    // Disable patron selection change
678
                    $("#booking_patron_id").prop("disabled", true);
679
680
                    // handle itemtype picker
681
                    if (booking_item_id != 0) {
682
                        let itemtype = e.params.data.element.dataset.itemtype;
683
                        booking_itemtype_id = itemtype;
684
685
                        $("#booking_itemtype").val(itemtype);
686
                        $("#booking_itemtype").trigger("change.select2");
687
                        $("#booking_itemtype").prop("disabled", true);
688
                    } else {
689
                        $("#booking_itemtype").prop("disabled", false);
690
                    }
691
692
                    // Update circulation rules
693
                    getCirculationRules();
694
                });
695
696
                // Setup listener for pickup location select2
697
                $("#pickup_library_id").on("select2:select", function (e) {
698
                    let valid_items =
699
                        e.params.data.element.dataset.pickup_items.split(",");
700
                    valid_items.push("0");
701
702
                    // Disable items not available at the pickup location
703
                    $("#booking_item_id > option").each(function () {
704
                        let option = $(this);
705
                        let item_id = option.val();
706
                        if (valid_items.includes(item_id)) {
707
                            option.attr("data-pickup", true);
708
                            if (option.data("available")) {
709
                                option.prop("disabled", false);
710
                            }
711
                        } else {
712
                            option.prop("disabled", true);
713
                            option.attr("data-pickup", false);
714
                        }
715
                    });
716
                    $("#booking_item_id").trigger("change.select2");
717
718
                    // Disable patron selection change
719
                    $("#booking_patron_id").prop("disabled", true);
720
721
                    pickup_library_id = $("#pickup_library_id").val();
722
723
                    // Populate circulation rules
724
                    getCirculationRules();
725
                });
726
727
                // Set onChange for flatpickr
728
                let changeExists = periodPicker.config.onChange.filter(
729
                    f => f.name === "periodChange"
730
                );
731
                if (changeExists.length === 0) {
732
                    periodPicker.config.onChange.push(
733
                        function periodChange(
734
                            selectedDates,
735
                            dateStr,
736
                            instance
737
                        ) {
738
                            // Start date selected
739
                            if (selectedDates[0] && !selectedDates[1]) {
740
                                const startDate = new Date(selectedDates[0]);
741
742
                                // Custom format function to make specific dates bold
743
                                boldDates = [new Date(startDate)];
744
                                // Add issueLength days after the startDate
745
                                const nextDate = new Date(startDate);
746
                                nextDate.setDate(
747
                                    nextDate.getDate() + parseInt(issueLength)
748
                                );
749
                                boldDates.push(new Date(nextDate));
750
751
                                // Add subsequent dates based on renewalsAllowed and renewalLength
752
                                for (let i = 0; i < renewalsAllowed; i++) {
753
                                    nextDate.setDate(
754
                                        nextDate.getDate() +
755
                                            parseInt(renewalLength)
756
                                    );
757
                                    boldDates.push(new Date(nextDate));
758
                                }
759
760
                                // Calculate the maximum date based on the selected start date
761
                                let totalRenewalLength =
762
                                    parseInt(renewalsAllowed) *
763
                                    parseInt(renewalLength);
764
                                let totalIssueLength =
765
                                    parseInt(issueLength) +
766
                                    parseInt(totalRenewalLength);
767
768
                                const maxDate = new Date(startDate.getTime());
769
                                maxDate.setDate(
770
                                    maxDate.getDate() + totalIssueLength
771
                                );
772
773
                                // Update the maxDate option of the flatpickr instance
774
                                instance.set("maxDate", maxDate);
775
                            }
776
                            // Range set, update hidden fields and set available items
777
                            else if (selectedDates[0] && selectedDates[1]) {
778
                                // set form fields from picker
779
                                let picker_start = dayjs(selectedDates[0]);
780
                                let picker_end = dayjs(selectedDates[1]).endOf(
781
                                    "day"
782
                                );
783
                                $("#booking_start_date").val(
784
                                    picker_start.toISOString()
785
                                );
786
                                $("#booking_end_date").val(
787
                                    picker_end.toISOString()
788
                                );
789
790
                                // set available items in select2
791
                                let booked_items = bookings.filter(
792
                                    function (booking) {
793
                                        let start_date = flatpickr.parseDate(
794
                                            booking.start_date
795
                                        );
796
                                        let end_date = flatpickr.parseDate(
797
                                            booking.end_date
798
                                        );
799
                                        // This booking ends before the start of the new booking
800
                                        if (end_date <= selectedDates[0]) {
801
                                            return false;
802
                                        }
803
                                        // This booking starts after then end of the new booking
804
                                        if (start_date >= selectedDates[1]) {
805
                                            return false;
806
                                        }
807
                                        // This booking overlaps
808
                                        return true;
809
                                    }
810
                                );
811
                                $("#booking_item_id > option").each(
812
                                    function () {
813
                                        let option = $(this);
814
                                        if (
815
                                            booking_item_id &&
816
                                            booking_item_id == option.val()
817
                                        ) {
818
                                            option.prop("disabled", false);
819
                                        } else if (
820
                                            booked_items.some(
821
                                                function (booked_item) {
822
                                                    return (
823
                                                        option.val() ==
824
                                                        booked_item.item_id
825
                                                    );
826
                                                }
827
                                            )
828
                                        ) {
829
                                            option.attr(
830
                                                "data-available",
831
                                                false
832
                                            );
833
                                            option.prop("disabled", true);
834
                                        } else {
835
                                            option.attr("data-available", true);
836
                                            if (option.data("pickup")) {
837
                                                option.prop("disabled", false);
838
                                            }
839
                                        }
840
                                    }
841
                                );
842
                                $("#booking_item_id").trigger("change.select2");
843
                            }
844
                            // Range not set, reset field options and flatPickr state
845
                            else {
846
                                boldDates = [];
847
                                instance.set("maxDate", null);
848
                                $("#booking_item_id > option").each(
849
                                    function () {
850
                                        let option = $(this);
851
                                        if (option.data("pickup")) {
852
                                            option.prop("disabled", false);
853
                                        }
854
                                    }
855
                                );
856
                                $("#booking_item_id").trigger("change.select2");
857
                            }
858
                        }
859
                    );
860
                }
861
862
                // Create a bookings store keyed on date
863
                let bookingsByDate = {};
864
                // Iterate through the bookings array
865
                bookings.forEach(booking => {
866
                    const start_date = flatpickr.parseDate(booking.start_date);
867
                    const end_date = flatpickr.parseDate(booking.end_date);
868
                    const item_id = booking.item_id;
869
870
                    // Iterate through each date within the range of start_date and end_date
871
                    let currentDate = new Date(start_date);
872
                    while (currentDate <= end_date) {
873
                        const currentDateStr = currentDate
874
                            .toISOString()
875
                            .split("T")[0];
876
877
                        // If the date key doesn't exist in the hash, create an empty array for it
878
                        if (!bookingsByDate[currentDateStr]) {
879
                            bookingsByDate[currentDateStr] = [];
880
                        }
881
882
                        // Push the booking ID to the array corresponding to the date key
883
                        bookingsByDate[currentDateStr].push(item_id);
884
885
                        // Move to the next day
886
                        currentDate.setDate(currentDate.getDate() + 1);
887
                    }
888
                });
889
890
                // Set onDayCreate for flatpickr
891
                let dayCreateExists = periodPicker.config.onDayCreate.filter(
892
                    f => f.name === "dayCreate"
893
                );
894
                if (dayCreateExists.length === 0) {
895
                    periodPicker.config.onDayCreate.push(
896
                        function dayCreate(dObj, dStr, instance, dayElem) {
897
                            const currentDate = dayElem.dateObj;
898
                            const dateString = currentDate
899
                                .toISOString()
900
                                .split("T")[0];
901
902
                            const isBold = boldDates.some(
903
                                boldDate =>
904
                                    boldDate.getTime() === currentDate.getTime()
905
                            );
906
                            if (isBold) {
907
                                dayElem.classList.add("title");
908
                            }
909
910
                            if (bookingsByDate[dateString]) {
911
                                const dots = document.createElement("span");
912
                                dots.className = "event-dots";
913
                                dayElem.appendChild(dots);
914
                                bookingsByDate[dateString].forEach(item => {
915
                                    const dot = document.createElement("span");
916
                                    dot.className = "event item_" + item;
917
                                    dots.appendChild(dot);
918
                                });
919
                            }
920
                        }
921
                    );
922
                }
923
924
                // Add hints for days before the start range and after the end range
925
                periodPicker.calendarContainer.addEventListener(
926
                    "mouseover",
927
                    function (event) {
928
                        const target = event.target;
929
                        if (target.classList.contains("flatpickr-day")) {
930
                            const hoverDate = dayjs(target.dateObj).startOf(
931
                                "day"
932
                            );
933
                            const startDate = periodPicker.selectedDates[0]
934
                                ? dayjs(periodPicker.selectedDates[0]).startOf(
935
                                      "day"
936
                                  )
937
                                : null;
938
939
                            const leadStart = startDate
940
                                ? startDate.subtract(leadDays, "day")
941
                                : hoverDate.subtract(leadDays, "day");
942
                            const leadEnd = startDate ? startDate : hoverDate;
943
                            const trailStart = hoverDate;
944
                            const trailEnd = hoverDate.add(trailDays, "day");
945
946
                            let leadDisable = false;
947
                            let trailDisable = false;
948
                            periodPicker.calendarContainer
949
                                .querySelectorAll(".flatpickr-day")
950
                                .forEach(function (dayElem) {
951
                                    const elemDate = dayjs(
952
                                        dayElem.dateObj
953
                                    ).startOf("day");
954
955
                                    dayElem.classList.toggle(
956
                                        "leadRangeStart",
957
                                        elemDate.isSame(leadStart)
958
                                    );
959
                                    dayElem.classList.toggle(
960
                                        "leadRange",
961
                                        elemDate.isSameOrAfter(leadStart) &&
962
                                            elemDate.isBefore(leadEnd)
963
                                    );
964
                                    dayElem.classList.toggle(
965
                                        "leadRangeEnd",
966
                                        elemDate.isSame(leadEnd)
967
                                    );
968
                                    dayElem.classList.toggle(
969
                                        "trailRangeStart",
970
                                        elemDate.isSame(trailStart)
971
                                    );
972
                                    dayElem.classList.toggle(
973
                                        "trailRange",
974
                                        elemDate.isAfter(trailStart) &&
975
                                            elemDate.isSameOrBefore(trailEnd)
976
                                    );
977
                                    dayElem.classList.toggle(
978
                                        "trailRangeEnd",
979
                                        elemDate.isSame(trailEnd)
980
                                    );
981
                                    // If we're overlapping a disabled date, disable our hoverDate
982
                                    if (
983
                                        dayElem.classList.contains(
984
                                            "flatpickr-disabled"
985
                                        )
986
                                    ) {
987
                                        if (
988
                                            !periodPicker.selectedDates[0] &&
989
                                            elemDate.isSameOrAfter(leadStart) &&
990
                                            elemDate.isBefore(leadEnd)
991
                                        ) {
992
                                            leadDisable = true;
993
                                        }
994
                                        if (
995
                                            elemDate.isAfter(trailStart) &&
996
                                            elemDate.isSameOrBefore(trailEnd)
997
                                        ) {
998
                                            trailDisable = true;
999
                                        }
1000
                                    }
1001
                                    dayElem.classList.remove("leadDisable");
1002
                                    dayElem.classList.remove("trailDisable");
1003
                                    dayElem.removeEventListener(
1004
                                        "click",
1005
                                        disableClick,
1006
                                        true
1007
                                    );
1008
                                });
1009
1010
                            if (leadDisable) {
1011
                                target.classList.add("leadDisable");
1012
                            }
1013
                            if (trailDisable) {
1014
                                target.classList.add("trailDisable");
1015
                            }
1016
                            if (trailDisable || leadDisable) {
1017
                                target.addEventListener(
1018
                                    "click",
1019
                                    disableClick,
1020
                                    true
1021
                                );
1022
                            }
1023
                        }
1024
                    }
1025
                );
1026
1027
                function disableClick(e) {
1028
                    e.stopImmediatePropagation();
1029
                }
1030
1031
                // Enable flatpickr now we have date function populated
1032
                periodPicker.redraw();
1033
1034
                // Redraw itemtype select with new options and enable
1035
                let $bookingItemtypeSelect = $("#booking_itemtype");
1036
                $bookingItemtypeSelect.trigger("change");
1037
                $bookingItemtypeSelect.data("loaded", true);
1038
                if ($bookingItemtypeSelect.data("patron")) {
1039
                    $bookingItemtypeSelect.prop("disabled", false);
1040
                }
1041
1042
                // Redraw item select with new options and enable
1043
                let $bookingItemSelect = $("#booking_item_id");
1044
                $bookingItemSelect.trigger("change");
1045
                $bookingItemSelect.data("loaded", true);
1046
                if ($bookingItemSelect.data("patron")) {
1047
                    $bookingItemSelect.prop("disabled", false);
1048
                }
1049
1050
                // Set the flag to indicate that data has been fetched
1051
                dataFetched = true;
1052
1053
                // Set form values
1054
                setFormValues(
1055
                    patron_id,
1056
                    booking_item_id,
1057
                    start_date,
1058
                    end_date,
1059
                    periodPicker
1060
                );
1061
            },
1062
            function (jqXHR, textStatus, errorThrown) {
1063
                console.log("Fetch failed");
1064
            }
1065
        );
1066
    } else {
1067
        setFormValues(
1068
            patron_id,
1069
            booking_item_id,
1070
            start_date,
1071
            end_date,
1072
            periodPicker
1073
        );
1074
    }
1075
});
1076
1077
function setFormValues(
1078
    patron_id,
1079
    booking_item_id,
1080
    start_date,
1081
    end_date,
1082
    periodPicker
1083
) {
1084
    // If passed patron, pre-select
1085
    if (patron_id) {
1086
        let patronSelect = $("#booking_patron_id");
1087
        let patron = $.ajax({
1088
            url: "/api/v1/patrons/" + patron_id,
1089
            dataType: "json",
1090
            type: "GET",
1091
        });
1092
1093
        $.when(patron).done(function (patron) {
1094
            // clone patron_id to id (select2 expects an id field)
1095
            patron.id = patron.patron_id;
1096
            patron.text =
1097
                escape_str(patron.surname) +
1098
                ", " +
1099
                escape_str(patron.firstname);
1100
1101
            // Add and select new option
1102
            let newOption = new Option(patron.text, patron.id, true, true);
1103
            patronSelect.append(newOption).trigger("change");
1104
1105
            // manually trigger the `select2:select` event
1106
            patronSelect.trigger({
1107
                type: "select2:select",
1108
                params: {
1109
                    data: patron,
1110
                },
1111
            });
1112
        });
1113
    }
1114
1115
    // Set booking start & end if this is an edit
1116
    if (start_date) {
1117
        // Allow invalid pre-load so setDate can set date range
1118
        // periodPicker.set('allowInvalidPreload', true);
1119
        // FIXME: Why is this the case.. we're passing two valid Date objects
1120
        let start = new Date(start_date);
1121
        let end = new Date(end_date);
1122
1123
        let dates = [new Date(start_date), new Date(end_date)];
1124
        periodPicker.setDate(dates, true);
1125
    }
1126
    // Reset periodPicker, biblio_id may have been nulled
1127
    else {
1128
        periodPicker.redraw();
1129
    }
1130
1131
    // If passed an itemnumber, pre-select
1132
    if (booking_item_id) {
1133
        $("#booking_item_id").val(booking_item_id).trigger("change");
1134
    }
1135
}
1136
1137
$("#placeBookingForm").on("submit", function (e) {
1138
    e.preventDefault();
1139
1140
    let url = "/api/v1/bookings";
1141
1142
    let start_date = $("#booking_start_date").val();
1143
    let end_date = $("#booking_end_date").val();
1144
    let pickup_library_id = $("#pickup_library_id").val();
1145
    let biblio_id = $("#booking_biblio_id").val();
1146
    let item_id = $("#booking_item_id").val();
1147
1148
    if (!booking_id) {
1149
        let posting = $.post(
1150
            url,
1151
            JSON.stringify({
1152
                start_date: start_date,
1153
                end_date: end_date,
1154
                pickup_library_id: pickup_library_id,
1155
                biblio_id: biblio_id,
1156
                item_id: item_id != 0 ? item_id : null,
1157
                patron_id: $("#booking_patron_id").find(":selected").val(),
1158
            })
1159
        );
1160
1161
        posting.done(function (data) {
1162
            // Update bookings store for subsequent bookings
1163
            bookings.push(data);
1164
1165
            // Update bookings page as required
1166
            if (
1167
                typeof bookings_table !== "undefined" &&
1168
                bookings_table !== null
1169
            ) {
1170
                bookings_table.api().ajax.reload();
1171
            }
1172
            if (typeof timeline !== "undefined" && timeline !== null) {
1173
                timeline.itemsData.add({
1174
                    id: data.booking_id,
1175
                    booking: data.booking_id,
1176
                    patron: data.patron_id,
1177
                    start: dayjs(data.start_date).toDate(),
1178
                    end: dayjs(data.end_date).toDate(),
1179
                    content: $patron_to_html(booking_patron, {
1180
                        display_cardnumber: true,
1181
                        url: false,
1182
                    }),
1183
                    editable: { remove: true, updateTime: true },
1184
                    type: "range",
1185
                    group: data.item_id ? data.item_id : 0,
1186
                });
1187
                timeline.focus(data.booking_id);
1188
            }
1189
1190
            // Update bookings counts
1191
            $(".bookings_count").html(
1192
                parseInt($(".bookings_count").html(), 10) + 1
1193
            );
1194
1195
            // Set feedback
1196
            $("#transient_result").replaceWith(
1197
                '<div id="transient_result" class="alert alert-info">' +
1198
                    __("Booking successfully placed") +
1199
                    "</div>"
1200
            );
1201
1202
            // Close modal
1203
            $("#placeBookingModal").modal("hide");
1204
        });
1205
1206
        posting.fail(function (data) {
1207
            $("#booking_result").replaceWith(
1208
                '<div id="booking_result" class="alert alert-danger">' +
1209
                    __("Failure") +
1210
                    "</div>"
1211
            );
1212
        });
1213
    } else {
1214
        url += "/" + booking_id;
1215
        let putting = $.ajax({
1216
            method: "PUT",
1217
            url: url,
1218
            contentType: "application/json",
1219
            data: JSON.stringify({
1220
                booking_id: booking_id,
1221
                start_date: start_date,
1222
                end_date: end_date,
1223
                pickup_library_id: pickup_library_id,
1224
                biblio_id: biblio_id,
1225
                item_id: item_id != 0 ? item_id : null,
1226
                patron_id: $("#booking_patron_id").find(":selected").val(),
1227
            }),
1228
        });
1229
1230
        putting.done(function (data) {
1231
            update_success = 1;
1232
1233
            // Update bookings store for subsequent bookings
1234
            let target = bookings.find(
1235
                obj => obj.booking_id === data.booking_id
1236
            );
1237
            Object.assign(target, data);
1238
1239
            // Update bookings page as required
1240
            if (
1241
                typeof bookings_table !== "undefined" &&
1242
                bookings_table !== null
1243
            ) {
1244
                bookings_table.api().ajax.reload();
1245
            }
1246
            if (typeof timeline !== "undefined" && timeline !== null) {
1247
                timeline.itemsData.update({
1248
                    id: data.booking_id,
1249
                    booking: data.booking_id,
1250
                    patron: data.patron_id,
1251
                    start: dayjs(data.start_date).toDate(),
1252
                    end: dayjs(data.end_date).toDate(),
1253
                    content: $patron_to_html(booking_patron, {
1254
                        display_cardnumber: true,
1255
                        url: false,
1256
                    }),
1257
                    editable: { remove: true, updateTime: true },
1258
                    type: "range",
1259
                    group: data.item_id ? data.item_id : 0,
1260
                });
1261
                timeline.focus(data.booking_id);
1262
            }
1263
1264
            // Set feedback
1265
            $("#transient_result").replaceWith(
1266
                '<div id="transient_result" class="alert alert-info">' +
1267
                    __("Booking successfully updated") +
1268
                    "</div>"
1269
            );
1270
1271
            // Close modal
1272
            $("#placeBookingModal").modal("hide");
1273
        });
1274
1275
        putting.fail(function (data) {
1276
            $("#booking_result").replaceWith(
1277
                '<div id="booking_result" class="alert alert-danger">' +
1278
                    __("Failure") +
1279
                    "</div>"
1280
            );
1281
        });
1282
    }
1283
});
1284
1285
$("#placeBookingModal").on("hidden.bs.modal", function (e) {
1286
    // Reset patron select
1287
    $("#booking_patron_id").val(null).trigger("change");
1288
    $("#booking_patron_id").empty();
1289
    $("#booking_patron_id").prop("disabled", false);
1290
    booking_patron = undefined;
1291
1292
    // Reset item select
1293
    $("#booking_item_id").val(0).trigger("change");
1294
    $("#booking_item_id").prop("disabled", true);
1295
1296
    // Reset itemtype select
1297
    $("#booking_itemtype").val(null).trigger("change");
1298
    $("#booking_itemtype").prop("disabled", true);
1299
    booking_itemtype_id = undefined;
1300
1301
    // Reset pickup library select
1302
    $("#pickup_library_id").val(null).trigger("change");
1303
    $("#pickup_library_id").empty();
1304
    $("#pickup_library_id").prop("disabled", true);
1305
1306
    // Reset booking period picker
1307
    $("#period").get(0)._flatpickr.clear();
1308
    $("#period").prop("disabled", true);
1309
    $("#booking_start_date").val("");
1310
    $("#booking_end_date").val("");
1311
    $("#booking_id").val("");
1312
});
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/.eslintrc.json (+42 lines)
Line 0 Link Here
1
{
2
    "env": {
3
        "browser": true,
4
        "es2021": true
5
    },
6
    "extends": [
7
        "eslint:recommended",
8
        "plugin:prettier/recommended"
9
    ],
10
    "parserOptions": {
11
        "ecmaVersion": 2021,
12
        "sourceType": "module"
13
    },
14
    "rules": {
15
        "indent": [
16
            "error",
17
            4
18
        ],
19
        "linebreak-style": [
20
            "error",
21
            "unix"
22
        ],
23
        "semi": [
24
            "error",
25
            "always"
26
        ],
27
        "no-unused-vars": [
28
            "warn",
29
            {
30
                "argsIgnorePattern": "^_",
31
                "varsIgnorePattern": "^_"
32
            }
33
        ],
34
        "switch-colon-spacing": [
35
            "error",
36
            {
37
                "after": true,
38
                "before": false
39
            }
40
        ]
41
    }
42
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue (+268 lines)
Line 0 Link Here
1
<template>
2
    <fieldset class="step-block">
3
        <legend class="step-header">
4
            {{ stepNumber }}.
5
            {{
6
                showItemDetailsSelects
7
                    ? $__("Select Pickup Location and Item Type or Item")
8
                    : showPickupLocationSelect
9
                    ? $__("Select Pickup Location")
10
                    : ""
11
            }}
12
        </legend>
13
14
        <div
15
            v-if="showPickupLocationSelect || showItemDetailsSelects"
16
            class="form-group"
17
        >
18
            <label for="pickup_library_id">{{
19
                $__("Pickup location")
20
            }}</label>
21
            <v-select
22
                v-model="selectedPickupLibraryId"
23
                :placeholder="
24
                    $__('Select a pickup location')
25
                "
26
                :options="constrainedPickupLocations"
27
                label="name"
28
                :reduce="l => l.library_id"
29
                :loading="loading.pickupLocations"
30
                :clearable="true"
31
                :disabled="selectsDisabled"
32
                :input-id="'pickup_library_id'"
33
            >
34
                <template #no-options>
35
                    {{ $__("No pickup locations available.") }}
36
                </template>
37
                <template #spinner>
38
                    <span class="sr-only">{{ $__("Loading...") }}</span>
39
                </template>
40
            </v-select>
41
            <span
42
                v-if="constrainedFlags.pickupLocations && (showPickupLocationSelect || showItemDetailsSelects)"
43
                class="badge badge-warning ml-2"
44
            >
45
                {{ $__("Options updated") }}
46
                <span class="ml-1"
47
                    >({{
48
                        pickupLocationsTotal - pickupLocationsFilteredOut
49
                    }}/{{ pickupLocationsTotal }})</span
50
                >
51
            </span>
52
        </div>
53
54
        <div v-if="showItemDetailsSelects" class="form-group">
55
            <label for="booking_itemtype">{{
56
                $__("Item type")
57
            }}</label>
58
            <v-select
59
                v-model="selectedItemtypeId"
60
                :options="constrainedItemTypes"
61
                label="description"
62
                :reduce="t => t.item_type_id"
63
                :clearable="true"
64
                :disabled="selectsDisabled"
65
                :input-id="'booking_itemtype'"
66
            >
67
                <template #no-options>
68
                    {{ $__("No item types available.") }}
69
                </template>
70
            </v-select>
71
            <span
72
                v-if="constrainedFlags.itemTypes"
73
                class="badge badge-warning ml-2"
74
                >{{ $__("Options updated") }}</span
75
            >
76
        </div>
77
78
        <div v-if="showItemDetailsSelects" class="form-group">
79
            <label for="booking_item_id">{{
80
                $__("Item")
81
            }}</label>
82
            <v-select
83
                v-model="selectedItemId"
84
                :placeholder="
85
                    $__('Any item')
86
                "
87
                :options="constrainedBookableItems"
88
                label="external_id"
89
                :reduce="i => i.item_id"
90
                :clearable="true"
91
                :loading="loading.bookableItems"
92
                :disabled="selectsDisabled"
93
                :input-id="'booking_item_id'"
94
            >
95
                <template #no-options>
96
                    {{ $__("No items available.") }}
97
                </template>
98
                <template #spinner>
99
                    <span class="sr-only">{{ $__("Loading...") }}</span>
100
                </template>
101
            </v-select>
102
            <span
103
                v-if="constrainedFlags.bookableItems"
104
                class="badge badge-warning ml-2"
105
            >
106
                {{ $__("Options updated") }}
107
                <span class="ml-1"
108
                    >({{
109
                        bookableItemsTotal - bookableItemsFilteredOut
110
                    }}/{{ bookableItemsTotal }})</span
111
                >
112
            </span>
113
        </div>
114
    </fieldset>
115
</template>
116
117
<script>
118
import { computed } from "vue";
119
import vSelect from "vue-select";
120
import { $__ } from "../../i18n";
121
import { useBookingStore } from "../../stores/bookings";
122
import { storeToRefs } from "pinia";
123
124
export default {
125
    name: "BookingDetailsStep",
126
    components: {
127
        vSelect,
128
    },
129
    props: {
130
        stepNumber: {
131
            type: Number,
132
            required: true,
133
        },
134
        showItemDetailsSelects: {
135
            type: Boolean,
136
            default: false,
137
        },
138
        showPickupLocationSelect: {
139
            type: Boolean,
140
            default: false,
141
        },
142
        selectedPatron: {
143
            type: Object,
144
            default: null,
145
        },
146
        patronRequired: {
147
            type: Boolean,
148
            default: false,
149
        },
150
        // Enable/disable selects based on data readiness from parent
151
        detailsEnabled: { type: Boolean, default: true },
152
        // v-model values
153
        pickupLibraryId: {
154
            type: String,
155
            default: null,
156
        },
157
        itemtypeId: {
158
            type: [Number, String],
159
            default: null,
160
        },
161
        itemId: {
162
            type: [Number, String],
163
            default: null,
164
        },
165
        // Options and constraints
166
        constrainedPickupLocations: {
167
            type: Array,
168
            default: () => [],
169
        },
170
        constrainedItemTypes: {
171
            type: Array,
172
            default: () => [],
173
        },
174
        constrainedBookableItems: {
175
            type: Array,
176
            default: () => [],
177
        },
178
        constrainedFlags: {
179
            type: Object,
180
            default: () => ({
181
                pickupLocations: false,
182
                itemTypes: false,
183
                bookableItems: false,
184
            }),
185
        },
186
        // Statistics for badges
187
        pickupLocationsTotal: {
188
            type: Number,
189
            default: 0,
190
        },
191
        pickupLocationsFilteredOut: {
192
            type: Number,
193
            default: 0,
194
        },
195
        bookableItemsTotal: {
196
            type: Number,
197
            default: 0,
198
        },
199
        bookableItemsFilteredOut: {
200
            type: Number,
201
            default: 0,
202
        },
203
    },
204
    emits: [
205
        "update:pickup-library-id",
206
        "update:itemtype-id",
207
        "update:item-id",
208
    ],
209
    setup(props, { emit }) {
210
        const store = useBookingStore();
211
        const { loading } = storeToRefs(store);
212
        // Helper to create v-model proxies with minimal repetition
213
        const vModelProxy = (prop, event) =>
214
            computed({
215
                get: () => props[prop],
216
                set: value => emit(event, value),
217
            });
218
219
        const selectedPickupLibraryId = vModelProxy(
220
            "pickupLibraryId",
221
            "update:pickup-library-id"
222
        );
223
        const selectedItemtypeId = vModelProxy(
224
            "itemtypeId",
225
            "update:itemtype-id"
226
        );
227
        const selectedItemId = vModelProxy("itemId", "update:item-id");
228
229
        const selectsDisabled = computed(
230
            () => !props.detailsEnabled || (!props.selectedPatron && props.patronRequired)
231
        );
232
233
        return {
234
            selectedPickupLibraryId,
235
            selectedItemtypeId,
236
            selectedItemId,
237
            loading,
238
            selectsDisabled,
239
        };
240
    },
241
};
242
</script>
243
244
<style scoped>
245
.step-block {
246
    margin-bottom: var(--booking-space-lg);
247
}
248
249
.step-header {
250
    font-weight: 600;
251
    font-size: var(--booking-text-lg);
252
    margin-bottom: calc(var(--booking-space-lg) * 0.75);
253
    color: var(--booking-neutral-600);
254
}
255
256
.form-group {
257
    margin-bottom: var(--booking-space-lg);
258
}
259
260
.badge {
261
    font-size: var(--booking-text-xs);
262
}
263
264
.badge-warning {
265
    background-color: var(--booking-warning-bg);
266
    color: var(--booking-neutral-600);
267
}
268
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue (+1091 lines)
Line 0 Link Here
1
<template>
2
    <div
3
        v-if="modalState.isOpen"
4
        class="modal show booking-modal-backdrop"
5
        tabindex="-1"
6
        role="dialog"
7
    >
8
        <div
9
            class="modal-dialog booking-modal-window booking-modal"
10
            role="document"
11
        >
12
            <div class="modal-content">
13
                <div class="booking-modal-header">
14
                    <h5 class="booking-modal-title">
15
                        {{ modalTitle }}
16
                    </h5>
17
                    <button
18
                        type="button"
19
                        class="booking-modal-close"
20
                        aria-label="Close"
21
                        @click="handleClose"
22
                    >
23
                        <span aria-hidden="true">&times;</span>
24
                    </button>
25
                </div>
26
                <div class="modal-body booking-modal-body">
27
                    <form
28
                        id="form-booking"
29
                        :action="submitUrl"
30
                        method="post"
31
                        @submit.prevent="handleSubmit"
32
                    >
33
                        <KohaAlert
34
                            :show="showCapacityWarning"
35
                            variant="warning"
36
                            :message="zeroCapacityMessage"
37
                        />
38
                        <BookingPatronStep
39
                            v-if="showPatronSelect"
40
                            v-model="bookingPatron"
41
                            :step-number="stepNumber.patron"
42
                            :set-error="setError"
43
                        />
44
                        <hr
45
                            v-if="
46
                                showPatronSelect ||
47
                                showItemDetailsSelects ||
48
                                showPickupLocationSelect
49
                            "
50
                        />
51
                        <BookingDetailsStep
52
                            v-if="
53
                                showItemDetailsSelects ||
54
                                showPickupLocationSelect
55
                            "
56
                            :step-number="stepNumber.details"
57
                            :details-enabled="readiness.dataReady"
58
                            :show-item-details-selects="showItemDetailsSelects"
59
                            :show-pickup-location-select="
60
                                showPickupLocationSelect
61
                            "
62
                            :selected-patron="bookingPatron"
63
                            :patron-required="showPatronSelect"
64
                            v-model:pickup-library-id="pickupLibraryId"
65
                            v-model:itemtype-id="bookingItemtypeId"
66
                            v-model:item-id="bookingItemId"
67
                            :constrained-pickup-locations="
68
                                constrainedPickupLocations
69
                            "
70
                            :constrained-item-types="constrainedItemTypes"
71
                            :constrained-bookable-items="
72
                                constrainedBookableItems
73
                            "
74
                            :constrained-flags="constrainedFlags"
75
                            :pickup-locations-total="pickupLocationsTotal"
76
                            :pickup-locations-filtered-out="
77
                                pickupLocationsFilteredOut
78
                            "
79
                            :bookable-items-total="bookableItemsTotal"
80
                            :bookable-items-filtered-out="
81
                                bookableItemsFilteredOut
82
                            "
83
                        />
84
                        <hr
85
                            v-if="
86
                                showItemDetailsSelects ||
87
                                showPickupLocationSelect
88
                            "
89
                        />
90
                        <BookingPeriodStep
91
                            :step-number="stepNumber.period"
92
                            :calendar-enabled="readiness.isCalendarReady"
93
                            :constraint-options="constraintOptions"
94
                            :date-range-constraint="dateRangeConstraint"
95
                            :max-booking-period="maxBookingPeriod"
96
                            :error-message="uiError.message"
97
                            :set-error="setError"
98
                            :has-selected-dates="selectedDateRange?.length > 0"
99
                            @clear-dates="clearDateRange"
100
                        />
101
                        <hr
102
                            v-if="
103
                                showAdditionalFields &&
104
                                modalState.hasAdditionalFields
105
                            "
106
                        />
107
                    </form>
108
                </div>
109
                <div class="modal-footer">
110
                    <div class="d-flex gap-2">
111
                        <button
112
                            class="btn btn-primary"
113
                            :disabled="loading.submit || !isSubmitReady"
114
                            type="submit"
115
                            form="form-booking"
116
                        >
117
                            {{ submitLabel }}
118
                        </button>
119
                        <button
120
                            class="btn btn-secondary ml-2"
121
                            @click.prevent="handleClose"
122
                        >
123
                            {{ $__("Cancel") }}
124
                        </button>
125
                    </div>
126
                </div>
127
            </div>
128
        </div>
129
    </div>
130
</template>
131
132
<script>
133
import { toISO } from "./lib/booking/date-utils.mjs";
134
import {
135
    computed,
136
    reactive,
137
    watch,
138
    onUnmounted,
139
} from "vue";
140
import BookingPatronStep from "./BookingPatronStep.vue";
141
import BookingDetailsStep from "./BookingDetailsStep.vue";
142
import BookingPeriodStep from "./BookingPeriodStep.vue";
143
import { $__ } from "../../i18n";
144
import { processApiError } from "../../utils/apiErrors.js";
145
import {
146
    constrainBookableItems,
147
    constrainItemTypes,
148
    constrainPickupLocations,
149
} from "./lib/booking/manager.mjs";
150
import { useBookingStore } from "../../stores/bookings";
151
import { storeToRefs } from "pinia";
152
import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs";
153
import { enableBodyScroll, disableBodyScroll } from "./lib/adapters/modal-scroll.mjs";
154
import { appendHiddenInputs } from "./lib/adapters/form.mjs";
155
import { calculateStepNumbers } from "./lib/ui/steps.mjs";
156
import { useBookingValidation } from "./composables/useBookingValidation.mjs";
157
import { calculateMaxBookingPeriod } from "./lib/booking/manager.mjs";
158
import { useDefaultPickup } from "./composables/useDefaultPickup.mjs";
159
import { buildNoItemsAvailableMessage } from "./lib/ui/selection-message.mjs";
160
import { useRulesFetcher } from "./composables/useRulesFetcher.mjs";
161
import { normalizeIdType } from "./lib/booking/id-utils.mjs";
162
import { useDerivedItemType } from "./composables/useDerivedItemType.mjs";
163
import { useErrorState } from "./composables/useErrorState.mjs";
164
import { useCapacityGuard } from "./composables/useCapacityGuard.mjs";
165
import KohaAlert from "../KohaAlert.vue";
166
167
export default {
168
    name: "BookingModal",
169
    components: {
170
        BookingPatronStep,
171
        BookingDetailsStep,
172
        BookingPeriodStep,
173
        KohaAlert,
174
    },
175
    props: {
176
        open: { type: Boolean, default: false },
177
        size: { type: String, default: "lg" },
178
        title: { type: String, default: "" },
179
        biblionumber: { type: [String, Number], required: true },
180
        bookingId: { type: [Number, String], default: null },
181
        itemId: { type: [Number, String], default: null },
182
        patronId: { type: [Number, String], default: null },
183
        pickupLibraryId: { type: String, default: null },
184
        startDate: { type: String, default: null },
185
        endDate: { type: String, default: null },
186
        itemtypeId: { type: [Number, String], default: null },
187
        showPatronSelect: { type: Boolean, default: false },
188
        showItemDetailsSelects: { type: Boolean, default: false },
189
        showPickupLocationSelect: { type: Boolean, default: false },
190
        submitType: {
191
            type: String,
192
            default: "api",
193
            validator: value => ["api", "form-submission"].includes(String(value)),
194
        },
195
        submitUrl: { type: String, default: "" },
196
        extendedAttributes: { type: Array, default: () => [] },
197
        extendedAttributeTypes: { type: Object, default: null },
198
        authorizedValues: { type: Object, default: null },
199
        showAdditionalFields: { type: Boolean, default: false },
200
        dateRangeConstraint: {
201
            type: String,
202
            default: null,
203
            validator: value =>
204
                !value ||
205
                ["issuelength", "issuelength_with_renewals", "custom"].includes(
206
                    String(value)
207
                ),
208
        },
209
        customDateRangeFormula: {
210
            type: Function,
211
            default: null,
212
        },
213
        opacDefaultBookingLibraryEnabled: { type: [Boolean, String], default: null },
214
        opacDefaultBookingLibrary: { type: String, default: null },
215
    },
216
    emits: ["close"],
217
    setup(props, { emit }) {
218
        const store = useBookingStore();
219
220
        // Properly destructure reactive store state using storeToRefs
221
        const {
222
            bookingId,
223
            bookingItemId,
224
            bookingPatron,
225
            bookingItemtypeId,
226
            pickupLibraryId,
227
            selectedDateRange,
228
            bookableItems,
229
            bookings,
230
            checkouts,
231
            pickupLocations,
232
            itemTypes,
233
            circulationRules,
234
            circulationRulesContext,
235
            loading,
236
        } = storeToRefs(store);
237
238
        // Calculate max booking period from circulation rules and selected constraint
239
240
        // Use validation composable for reactive validation logic
241
        const { canSubmit: canSubmitReactive } = useBookingValidation(store);
242
243
        // Grouped reactive state following Vue 3 best practices
244
        const modalState = reactive({
245
            isOpen: props.open,
246
            step: 1,
247
            hasAdditionalFields: false,
248
        });
249
        const { error: uiError, setError, clear: clearError } = useErrorState();
250
251
        const modalTitle = computed(
252
            () =>
253
                props.title ||
254
                (bookingId.value ? $__("Edit booking") : $__("Place booking"))
255
        );
256
257
        // Determine whether to show pickup location select
258
        // In OPAC: show if default library is not enabled
259
        // In staff: use the explicit prop
260
        const showPickupLocationSelect = computed(() => {
261
            if (props.opacDefaultBookingLibraryEnabled !== null) {
262
                const enabled = props.opacDefaultBookingLibraryEnabled === true ||
263
                    String(props.opacDefaultBookingLibraryEnabled) === "1";
264
                return !enabled;
265
            }
266
            return props.showPickupLocationSelect;
267
        });
268
269
        const stepNumber = computed(() => {
270
            return calculateStepNumbers(
271
                props.showPatronSelect,
272
                props.showItemDetailsSelects,
273
                showPickupLocationSelect.value,
274
                props.showAdditionalFields,
275
                modalState.hasAdditionalFields
276
            );
277
        });
278
279
        const submitLabel = computed(() =>
280
            bookingId.value ? $__("Update booking") : $__("Place booking")
281
        );
282
283
        const isFormSubmission = computed(
284
            () => props.submitType === "form-submission"
285
        );
286
287
        // pickupLibraryId is a ref from the store; use directly
288
289
        // Constraint flags computed from pure function results
290
        const constrainedFlags = computed(() => ({
291
            pickupLocations: pickupLocationConstraint.value.constraintApplied,
292
            bookableItems: bookableItemsConstraint.value.constraintApplied,
293
            itemTypes: itemTypeConstraint.value.constraintApplied,
294
        }));
295
296
        const pickupLocationConstraint = computed(() =>
297
            constrainPickupLocations(
298
                pickupLocations.value,
299
                bookableItems.value,
300
                bookingItemtypeId.value,
301
                bookingItemId.value
302
            )
303
        );
304
        const constrainedPickupLocations = computed(
305
            () => pickupLocationConstraint.value.filtered
306
        );
307
        const pickupLocationsFilteredOut = computed(
308
            () => pickupLocationConstraint.value.filteredOutCount
309
        );
310
        const pickupLocationsTotal = computed(
311
            () => pickupLocationConstraint.value.total
312
        );
313
314
        const bookableItemsConstraint = computed(() =>
315
            constrainBookableItems(
316
                bookableItems.value,
317
                pickupLocations.value,
318
                pickupLibraryId.value,
319
                bookingItemtypeId.value
320
            )
321
        );
322
        const constrainedBookableItems = computed(
323
            () => bookableItemsConstraint.value.filtered
324
        );
325
        const bookableItemsFilteredOut = computed(
326
            () => bookableItemsConstraint.value.filteredOutCount
327
        );
328
        const bookableItemsTotal = computed(
329
            () => bookableItemsConstraint.value.total
330
        );
331
332
        const itemTypeConstraint = computed(() =>
333
            constrainItemTypes(
334
                itemTypes.value,
335
                bookableItems.value,
336
                pickupLocations.value,
337
                pickupLibraryId.value,
338
                bookingItemId.value
339
            )
340
        );
341
        const constrainedItemTypes = computed(
342
            () => itemTypeConstraint.value.filtered
343
        );
344
345
        const maxBookingPeriod = computed(() =>
346
            calculateMaxBookingPeriod(
347
                circulationRules.value,
348
                props.dateRangeConstraint,
349
                props.customDateRangeFormula
350
            )
351
        );
352
353
        const constraintOptions = computed(() => ({
354
            dateRangeConstraint: props.dateRangeConstraint,
355
            maxBookingPeriod: maxBookingPeriod.value,
356
        }));
357
358
        // Centralized capacity guard (extracts UI warning state)
359
        const { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning } =
360
            useCapacityGuard({
361
                circulationRules,
362
                circulationRulesContext,
363
                loading,
364
                bookableItems,
365
                bookingPatron,
366
                bookingItemId,
367
                bookingItemtypeId,
368
                pickupLibraryId,
369
                showPatronSelect: props.showPatronSelect,
370
                showItemDetailsSelects: props.showItemDetailsSelects,
371
                showPickupLocationSelect: showPickupLocationSelect.value,
372
                dateRangeConstraint: props.dateRangeConstraint,
373
            });
374
375
        // Readiness flags
376
        const dataReady = computed(
377
            () =>
378
                !loading.value.bookableItems &&
379
                !loading.value.bookings &&
380
                !loading.value.checkouts &&
381
                (bookableItems.value?.length ?? 0) > 0
382
        );
383
        const formPrefilterValid = computed(() => {
384
            const requireTypeOrItem = !!props.showItemDetailsSelects;
385
            const hasTypeOrItem =
386
                !!bookingItemId.value || !!bookingItemtypeId.value;
387
            const patronOk = !props.showPatronSelect || !!bookingPatron.value;
388
            return patronOk && (requireTypeOrItem ? hasTypeOrItem : true);
389
        });
390
        const hasAvailableItems = computed(
391
            () => constrainedBookableItems.value.length > 0
392
        );
393
394
        const isCalendarReady = computed(() => {
395
            const basicReady = dataReady.value &&
396
                formPrefilterValid.value &&
397
                hasAvailableItems.value;
398
            if (!basicReady) return false;
399
            if (loading.value.circulationRules) return true;
400
401
            return hasPositiveCapacity.value;
402
        });
403
404
        // Separate validation for submit button using reactive composable
405
        const isSubmitReady = computed(
406
            () => isCalendarReady.value && canSubmitReactive.value
407
        );
408
409
        // Grouped readiness for convenient consumption (optional)
410
        const readiness = computed(() => ({
411
            dataReady: dataReady.value,
412
            formPrefilterValid: formPrefilterValid.value,
413
            hasAvailableItems: hasAvailableItems.value,
414
            isCalendarReady: isCalendarReady.value,
415
            canSubmit: isSubmitReady.value,
416
        }));
417
418
        // Watchers: synchronize open state, orchestrate initial data load,
419
        // push availability to store, fetch rules/pickup locations, and keep
420
        // UX guidance/errors fresh while inputs change.
421
422
        // Sync internal modal state with the `open` prop and reset inputs when
423
        // opening to ensure a clean form each time.
424
        watch(
425
            () => props.open,
426
            val => {
427
                modalState.isOpen = val;
428
                if (val) {
429
                    resetModalState();
430
                }
431
            }
432
        );
433
434
        // Orchestrates initial data loading on modal open. This needs to
435
        // remain isolated because it handles async fetch ordering, DOM body
436
        // scroll state and localization preload.
437
        watch(
438
            () => modalState.isOpen,
439
            async open => {
440
                if (open) {
441
                    disableBodyScroll();
442
                } else {
443
                    enableBodyScroll();
444
                    return;
445
                }
446
447
                modalState.step = 1;
448
                const biblionumber = props.biblionumber;
449
                if (!biblionumber) return;
450
451
                bookingId.value = props.bookingId;
452
453
                try {
454
                    // Fetch core data first
455
                    await Promise.all([
456
                        store.fetchBookableItems(biblionumber),
457
                        store.fetchBookings(biblionumber),
458
                        store.fetchCheckouts(biblionumber),
459
                    ]);
460
461
                    modalState.hasAdditionalFields = false;
462
463
                    // Derive item types after bookable items are loaded
464
                    store.deriveItemTypesFromBookableItems();
465
466
                    // If editing with patron, fetch patron-specific data
467
                    if (props.patronId) {
468
                        const patron = await store.fetchPatron(props.patronId);
469
                        await store.fetchPickupLocations(
470
                            biblionumber,
471
                            props.patronId
472
                        );
473
474
                        // Now set patron after data is available
475
                        bookingPatron.value = patron;
476
                    }
477
478
                    // Set other form values after all dependencies are loaded
479
480
                    // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching
481
                    bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null;
482
                    if (props.itemtypeId) {
483
                        bookingItemtypeId.value = props.itemtypeId;
484
                    }
485
486
                    if (props.startDate && props.endDate) {
487
                        selectedDateRange.value = [
488
                            toISO(props.startDate),
489
                            toISO(props.endDate),
490
                        ];
491
                    }
492
                } catch (error) {
493
                    console.error("Error initializing booking modal:", error);
494
                    setError(processApiError(error), "api");
495
                }
496
            }
497
        );
498
499
        useRulesFetcher({
500
            store,
501
            bookingPatron,
502
            bookingPickupLibraryId: pickupLibraryId,
503
            bookingItemtypeId,
504
            constrainedItemTypes,
505
            selectedDateRange,
506
            biblionumber: String(props.biblionumber),
507
        });
508
509
        useDerivedItemType({
510
            bookingItemtypeId,
511
            bookingItemId,
512
            constrainedItemTypes,
513
            bookableItems,
514
        });
515
516
        watch(
517
            () => ({
518
                patron: bookingPatron.value?.patron_id,
519
                pickup: pickupLibraryId.value,
520
                itemtype: bookingItemtypeId.value,
521
                item: bookingItemId.value,
522
                d0: selectedDateRange.value?.[0],
523
                d1: selectedDateRange.value?.[1],
524
            }),
525
            (curr, prev) => {
526
                const inputsChanged =
527
                    !prev ||
528
                    curr.patron !== prev.patron ||
529
                    curr.pickup !== prev.pickup ||
530
                    curr.itemtype !== prev.itemtype ||
531
                    curr.item !== prev.item ||
532
                    curr.d0 !== prev.d0 ||
533
                    curr.d1 !== prev.d1;
534
                if (inputsChanged) clearErrors();
535
            }
536
        );
537
538
        // Default pickup selection handled by composable
539
        useDefaultPickup({
540
            bookingPickupLibraryId: pickupLibraryId,
541
            bookingPatron,
542
            pickupLocations,
543
            bookableItems,
544
            opacDefaultBookingLibraryEnabled: props.opacDefaultBookingLibraryEnabled,
545
            opacDefaultBookingLibrary: props.opacDefaultBookingLibrary,
546
        });
547
548
        // Show an actionable error when current selection yields no available
549
        // items, helping the user adjust filters.
550
        watch(
551
            [
552
                constrainedBookableItems,
553
                () => bookingPatron.value,
554
                () => pickupLibraryId.value,
555
                () => bookingItemtypeId.value,
556
                dataReady,
557
                () => loading.value.circulationRules,
558
                () => loading.value.pickupLocations,
559
            ],
560
            ([availableItems, patron, pickupLibrary, itemtypeId, isDataReady]) => {
561
                // Only show error if data is loaded and user has made selections that result in no items
562
                // Wait for pickup locations and circulation rules to finish loading to avoid false positives
563
                const pickupLocationsReady = !pickupLibrary || (!loading.value.pickupLocations && pickupLocations.value.length > 0);
564
                const circulationRulesReady = !loading.value.circulationRules;
565
566
                if (
567
                    isDataReady &&
568
                    pickupLocationsReady &&
569
                    circulationRulesReady &&
570
                    patron &&
571
                    (pickupLibrary || itemtypeId) &&
572
                    availableItems.length === 0
573
                ) {
574
                    const msg = buildNoItemsAvailableMessage(
575
                        pickupLocations.value,
576
                        itemTypes.value,
577
                        pickupLibrary,
578
                        itemtypeId
579
                    );
580
                    setError(msg, "no_items");
581
                } else if (uiError.code === "no_items") {
582
                    clearErrors();
583
                }
584
            },
585
            { immediate: true }
586
        );
587
588
589
        // Globally clear all error states (modal + store)
590
        function clearErrors() {
591
            clearError();
592
            store.resetErrors();
593
        }
594
595
596
        function resetModalState() {
597
            bookingPatron.value = null;
598
            pickupLibraryId.value = null;
599
            bookingItemtypeId.value = null;
600
            bookingItemId.value = null;
601
            selectedDateRange.value = [];
602
            modalState.step = 1;
603
            clearErrors();
604
            modalState.hasAdditionalFields = false;
605
        }
606
607
        function clearDateRange() {
608
            selectedDateRange.value = [];
609
            clearErrors();
610
        }
611
612
        function handleClose() {
613
            modalState.isOpen = false;
614
            enableBodyScroll();
615
            emit("close");
616
            resetModalState();
617
        }
618
619
        async function handleSubmit(event) {
620
            // Use selectedDateRange (clean ISO strings maintained by onChange handler)
621
            const selectedDates = selectedDateRange.value;
622
623
            if (!selectedDates || selectedDates.length === 0) {
624
                setError($__("Please select a valid date range"), "invalid_date_range");
625
                return;
626
            }
627
628
            const start = selectedDates[0];
629
            const end =
630
                selectedDates.length >= 2 ? selectedDates[1] : selectedDates[0];
631
            const bookingData = {
632
                booking_id: props.bookingId ?? undefined,
633
                start_date: start,
634
                end_date: end,
635
                pickup_library_id: pickupLibraryId.value,
636
                biblio_id: props.biblionumber,
637
                item_id: bookingItemId.value || null,
638
                patron_id: bookingPatron.value?.patron_id,
639
            };
640
641
            if (isFormSubmission.value) {
642
                const form = /** @type {HTMLFormElement} */ (event.target);
643
                const csrfToken = /** @type {HTMLInputElement|null} */ (
644
                    document.querySelector('[name="csrf_token"]')
645
                );
646
647
                const dataToSubmit = { ...bookingData };
648
649
                appendHiddenInputs(
650
                    form,
651
                    [
652
                        ...Object.entries(dataToSubmit),
653
                        [csrfToken?.name, csrfToken?.value],
654
                        ['op', 'cud-add'],
655
                    ]
656
                );
657
                form.submit();
658
                return;
659
            }
660
661
            try {
662
                const result = await store.saveOrUpdateBooking(bookingData);
663
                updateExternalDependents(result, bookingPatron.value, !!props.bookingId);
664
                emit("close");
665
                resetModalState();
666
            } catch (errorObj) {
667
                setError(processApiError(errorObj), "api");
668
            }
669
        }
670
671
        // Cleanup function for proper memory management
672
        onUnmounted(() => {
673
            enableBodyScroll();
674
        });
675
676
        return {
677
            modalState,
678
            modalTitle,
679
            submitLabel,
680
            isFormSubmission,
681
            loading,
682
            showPickupLocationSelect,
683
            // Store refs from storeToRefs
684
            selectedDateRange,
685
            bookingId,
686
            bookingItemId,
687
            bookingPatron,
688
            bookingItemtypeId,
689
            pickupLibraryId,
690
            bookableItems,
691
            bookings,
692
            checkouts,
693
            pickupLocations,
694
            itemTypes,
695
            uiError,
696
            setError,
697
            // Computed values
698
            constrainedPickupLocations,
699
            constrainedItemTypes,
700
            constrainedBookableItems,
701
            isCalendarReady,
702
            isSubmitReady,
703
            readiness,
704
            constrainedFlags,
705
            constraintOptions,
706
            handleClose,
707
            handleSubmit,
708
            clearDateRange,
709
            resetModalState,
710
            stepNumber,
711
            pickupLocationsFilteredOut,
712
            pickupLocationsTotal,
713
            bookableItemsFilteredOut,
714
            bookableItemsTotal,
715
            maxBookingPeriod,
716
            hasPositiveCapacity,
717
            zeroCapacityMessage,
718
            showCapacityWarning,
719
        };
720
    },
721
};
722
</script>
723
724
<style>
725
.booking-modal-backdrop {
726
    position: fixed;
727
    top: 0;
728
    left: 0;
729
    width: 100%;
730
    height: 100%;
731
    background: rgba(0, 0, 0, 0.5);
732
    z-index: 1050;
733
    display: block;
734
    overflow-y: auto;
735
}
736
737
/* Global variables for external libraries (flatpickr) and cross-block usage */
738
:root {
739
    /* Success colors for constraint highlighting */
740
    --booking-success-hue: 134;
741
    --booking-success-bg: hsl(var(--booking-success-hue), 40%, 90%);
742
    --booking-success-bg-hover: hsl(var(--booking-success-hue), 35%, 85%);
743
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
744
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
745
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
746
747
    /* Border width used by flatpickr */
748
    --booking-border-width: 1px;
749
750
    /* Variables used by second style block (booking markers, calendar states) */
751
    --booking-marker-size: 0.25em;
752
    --booking-marker-grid-gap: 0.25rem;
753
    --booking-marker-grid-offset: -0.75rem;
754
755
    /* Color hues used in second style block */
756
    --booking-warning-hue: 45;
757
    --booking-danger-hue: 354;
758
    --booking-info-hue: 195;
759
    --booking-neutral-hue: 210;
760
761
    /* Colors derived from hues (used in second style block) */
762
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
763
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
764
765
    /* Spacing used in second style block */
766
    --booking-space-xs: 0.125rem;
767
768
    /* Typography used in second style block */
769
    --booking-text-xs: 0.7rem;
770
771
    /* Border radius used in second style block and other components */
772
    --booking-border-radius-sm: 0.25rem;
773
    --booking-border-radius-md: 0.5rem;
774
    --booking-border-radius-full: 50%;
775
}
776
777
/* Design System: CSS Custom Properties (First Style Block Only) */
778
.booking-modal-backdrop {
779
    /* Colors not used in second style block */
780
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
781
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
782
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
783
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
784
785
    /* Spacing Scale (first block only) */
786
    --booking-space-sm: 0.25rem; /* 4px */
787
    --booking-space-md: 0.5rem; /* 8px */
788
    --booking-space-lg: 1rem; /* 16px */
789
    --booking-space-xl: 1.5rem; /* 24px */
790
    --booking-space-2xl: 2rem; /* 32px */
791
792
    /* Typography Scale (first block only) */
793
    --booking-text-sm: 0.8125rem;
794
    --booking-text-base: 1rem;
795
    --booking-text-lg: 1.1rem;
796
    --booking-text-xl: 1.3rem;
797
    --booking-text-2xl: 2rem;
798
799
    /* Layout */
800
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
801
    --booking-input-min-width: 15rem;
802
803
    /* Animation */
804
    --booking-transition-fast: 0.15s ease-in-out;
805
}
806
807
/* Constraint Highlighting Component */
808
.flatpickr-calendar .booking-constrained-range-marker {
809
    background-color: var(--booking-success-bg) !important;
810
    border: var(--booking-border-width) solid var(--booking-success-border) !important;
811
    color: var(--booking-success-text) !important;
812
}
813
814
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker {
815
    background-color: var(--booking-success-bg) !important;
816
    border-color: var(--booking-success-border) !important;
817
    color: var(--booking-success-text) !important;
818
}
819
820
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker:hover {
821
    background-color: var(--booking-success-bg-hover) !important;
822
    border-color: var(--booking-success-border-hover) !important;
823
}
824
825
/* End Date Only Mode - Blocked Intermediate Dates */
826
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked {
827
    background-color: hsl(var(--booking-success-hue), 40%, 90%) !important;
828
    border-color: hsl(var(--booking-success-hue), 40%, 70%) !important;
829
    color: hsl(var(--booking-success-hue), 40%, 50%) !important;
830
    cursor: not-allowed !important;
831
    opacity: 0.7 !important;
832
}
833
834
/* Bold styling for end of loan and renewal period boundaries */
835
.flatpickr-calendar .flatpickr-day.booking-loan-boundary {
836
    font-weight: 700 !important;
837
}
838
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked:hover {
839
    background-color: hsl(var(--booking-success-hue), 40%, 85%) !important;
840
    border-color: hsl(var(--booking-success-hue), 40%, 60%) !important;
841
}
842
843
/* Modal Layout Components */
844
.booking-modal-window {
845
    max-height: var(--booking-modal-max-height);
846
    margin: var(--booking-space-lg) auto;
847
}
848
849
.modal-content {
850
    max-height: var(--booking-modal-max-height);
851
    display: flex;
852
    flex-direction: column;
853
}
854
855
.booking-modal-header {
856
    padding: var(--booking-space-lg);
857
    border-bottom: var(--booking-border-width) solid var(--booking-neutral-100);
858
    display: flex;
859
    align-items: center;
860
    justify-content: space-between;
861
    flex-shrink: 0;
862
}
863
864
.booking-modal-title {
865
    margin: 0;
866
    font-size: var(--booking-text-xl);
867
    font-weight: 600;
868
}
869
870
.booking-modal-close {
871
    background: transparent;
872
    border: none;
873
    font-size: var(--booking-text-2xl);
874
    line-height: 1;
875
    cursor: pointer;
876
    color: var(--booking-neutral-500);
877
    opacity: 0.5;
878
    transition: opacity var(--booking-transition-fast);
879
    padding: 0;
880
    margin: 0;
881
    width: var(--booking-space-2xl);
882
    height: var(--booking-space-2xl);
883
    display: flex;
884
    align-items: center;
885
    justify-content: center;
886
}
887
888
.booking-modal-close:hover {
889
    opacity: 0.75;
890
}
891
892
.booking-modal-body {
893
    padding: var(--booking-space-xl);
894
    overflow-y: auto;
895
    flex: 1 1 auto;
896
}
897
898
/* Form & Layout Components */
899
.booking-extended-attributes {
900
    list-style: none;
901
    padding: 0;
902
    margin: 0;
903
}
904
905
.step-block {
906
    margin-bottom: var(--booking-space-2xl);
907
}
908
909
.step-header {
910
    font-weight: bold;
911
    font-size: var(--booking-text-lg);
912
    margin-bottom: var(--booking-space-md);
913
}
914
915
hr {
916
    border: none;
917
    border-top: var(--booking-border-width) solid var(--booking-neutral-100);
918
    margin: var(--booking-space-2xl) 0;
919
}
920
921
/* Input Components */
922
.booking-flatpickr-input,
923
.flatpickr-input.booking-flatpickr-input {
924
    min-width: var(--booking-input-min-width);
925
    padding: calc(var(--booking-space-md) - var(--booking-space-xs))
926
        calc(var(--booking-space-md) + var(--booking-space-sm));
927
    border: var(--booking-border-width) solid var(--booking-neutral-300);
928
    border-radius: var(--booking-border-radius-sm);
929
    font-size: var(--booking-text-base);
930
    transition: border-color var(--booking-transition-fast),
931
        box-shadow var(--booking-transition-fast);
932
}
933
934
/* Calendar Legend Component */
935
.calendar-legend {
936
    margin-top: var(--booking-space-lg);
937
    margin-bottom: var(--booking-space-lg);
938
    font-size: var(--booking-text-sm);
939
    display: flex;
940
    align-items: center;
941
}
942
943
.calendar-legend .booking-marker-dot {
944
    /* Make legend dots much larger and more visible */
945
    width: calc(var(--booking-marker-size) * 3) !important;
946
    height: calc(var(--booking-marker-size) * 3) !important;
947
    margin-right: calc(var(--booking-space-sm) * 1.5);
948
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
949
}
950
951
.calendar-legend .ml-3 {
952
    margin-left: var(--booking-space-lg);
953
}
954
955
/* Legend colors match actual calendar markers exactly */
956
.calendar-legend .booking-marker-dot--booked {
957
    background: var(--booking-warning-bg) !important;
958
}
959
960
.calendar-legend .booking-marker-dot--checked-out {
961
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
962
}
963
964
.calendar-legend .booking-marker-dot--lead {
965
    background: hsl(var(--booking-info-hue), 60%, 85%) !important;
966
}
967
968
.calendar-legend .booking-marker-dot--trail {
969
    background: var(--booking-warning-bg) !important;
970
}
971
</style>
972
973
<style>
974
.booking-date-picker {
975
    display: flex;
976
    align-items: stretch;
977
    width: 100%;
978
}
979
980
.booking-date-picker > .form-control {
981
    flex: 1 1 auto;
982
    min-width: 0;
983
    margin-bottom: 0;
984
}
985
986
.booking-date-picker-append {
987
    display: flex;
988
    margin-left: -1px;
989
}
990
991
.booking-date-picker-append .btn {
992
    border-top-left-radius: 0;
993
    border-bottom-left-radius: 0;
994
}
995
996
.booking-date-picker > .form-control:not(:last-child) {
997
    border-top-right-radius: 0;
998
    border-bottom-right-radius: 0;
999
}
1000
1001
/* External Library Integration */
1002
.booking-modal-body .vs__selected {
1003
    font-size: var(--vs-font-size);
1004
    line-height: var(--vs-line-height);
1005
}
1006
1007
.booking-constraint-info {
1008
    margin-top: var(--booking-space-lg);
1009
    margin-bottom: var(--booking-space-lg);
1010
}
1011
1012
/* Booking Status Marker System */
1013
.booking-marker-grid {
1014
    position: relative;
1015
    top: var(--booking-marker-grid-offset);
1016
    display: flex;
1017
    flex-wrap: wrap;
1018
    justify-content: center;
1019
    gap: var(--booking-marker-grid-gap);
1020
    width: fit-content;
1021
    max-width: 90%;
1022
    margin-left: auto;
1023
    margin-right: auto;
1024
    line-height: normal;
1025
}
1026
1027
.booking-marker-item {
1028
    display: inline-flex;
1029
    align-items: center;
1030
}
1031
1032
.booking-marker-dot {
1033
    display: inline-block;
1034
    width: var(--booking-marker-size);
1035
    height: var(--booking-marker-size);
1036
    border-radius: var(--booking-border-radius-full);
1037
    vertical-align: middle;
1038
}
1039
1040
.booking-marker-count {
1041
    font-size: var(--booking-text-xs);
1042
    margin-left: var(--booking-space-xs);
1043
    line-height: 1;
1044
    font-weight: normal;
1045
    color: var(--booking-neutral-600);
1046
}
1047
1048
/* Status Indicator Colors */
1049
.booking-marker-dot--booked {
1050
    background: var(--booking-warning-bg);
1051
}
1052
1053
.booking-marker-dot--checked-out {
1054
    background: hsl(var(--booking-danger-hue), 60%, 85%);
1055
}
1056
1057
.booking-marker-dot--lead {
1058
    background: hsl(var(--booking-info-hue), 60%, 85%);
1059
}
1060
1061
.booking-marker-dot--trail {
1062
    background: var(--booking-warning-bg);
1063
}
1064
1065
/* Calendar Day States */
1066
.booked {
1067
    background: var(--booking-warning-bg) !important;
1068
    color: hsl(var(--booking-warning-hue), 80%, 25%) !important;
1069
    border-radius: var(--booking-border-radius-full) !important;
1070
}
1071
1072
.checked-out {
1073
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
1074
    color: hsl(var(--booking-danger-hue), 80%, 25%) !important;
1075
    border-radius: var(--booking-border-radius-full) !important;
1076
}
1077
1078
/* Hover States with Transparency */
1079
.flatpickr-day.booking-day--hover-lead {
1080
    background-color: hsl(var(--booking-info-hue), 60%, 85%, 0.2) !important;
1081
}
1082
1083
.flatpickr-day.booking-day--hover-trail {
1084
    background-color: hsl(
1085
        var(--booking-warning-hue),
1086
        100%,
1087
        70%,
1088
        0.2
1089
    ) !important;
1090
}
1091
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue (+78 lines)
Line 0 Link Here
1
<template>
2
    <fieldset class="step-block">
3
        <legend class="step-header">
4
            {{ stepNumber }}.
5
            {{ $__("Select Patron") }}
6
        </legend>
7
        <PatronSearchSelect
8
            v-model="selectedPatron"
9
            :label="
10
                $__('Patron')
11
            "
12
            :placeholder="
13
                $__('Search for a patron')
14
            "
15
            :set-error="setError"
16
        >
17
            <template #no-options="{ hasSearched }">
18
                {{ hasSearched ? $__("No patrons found.") : $__("Type to search for patrons.") }}
19
            </template>
20
            <template #spinner>
21
                <span class="sr-only">{{ $__("Searching...") }}</span>
22
            </template>
23
        </PatronSearchSelect>
24
    </fieldset>
25
</template>
26
27
<script>
28
import { computed } from "vue";
29
import PatronSearchSelect from "./PatronSearchSelect.vue";
30
import { $__ } from "../../i18n";
31
32
export default {
33
    name: "BookingPatronStep",
34
    components: {
35
        PatronSearchSelect,
36
    },
37
    props: {
38
        stepNumber: {
39
            type: Number,
40
            required: true,
41
        },
42
        modelValue: {
43
            type: Object,
44
            default: null,
45
        },
46
        setError: {
47
            type: Function,
48
            default: null,
49
        },
50
    },
51
    emits: ["update:modelValue"],
52
    setup(props, { emit }) {
53
        const selectedPatron = computed({
54
            get: () => props.modelValue,
55
            set: (value) => {
56
                emit("update:modelValue", value);
57
            },
58
        });
59
60
        return {
61
            selectedPatron,
62
        };
63
    },
64
};
65
</script>
66
67
<style scoped>
68
.step-block {
69
    margin-bottom: var(--booking-space-lg);
70
}
71
72
.step-header {
73
    font-weight: 600;
74
    font-size: var(--booking-text-lg);
75
    margin-bottom: calc(var(--booking-space-lg) * 0.75);
76
    color: var(--booking-neutral-600);
77
}
78
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue (+332 lines)
Line 0 Link Here
1
<template>
2
    <fieldset class="step-block">
3
        <legend class="step-header">
4
            {{ stepNumber }}.
5
            {{ $__("Select Booking Period") }}
6
        </legend>
7
8
        <div class="form-group">
9
            <label for="booking_period">{{ $__("Booking period") }}</label>
10
            <div class="booking-date-picker">
11
                <input
12
                    ref="inputEl"
13
                    id="booking_period"
14
                    type="text"
15
                    class="booking-flatpickr-input form-control"
16
                    :disabled="!calendarEnabled"
17
                    readonly
18
                />
19
                <div class="booking-date-picker-append">
20
                    <button
21
                        type="button"
22
                        class="btn btn-outline-secondary"
23
                        :disabled="!calendarEnabled"
24
                        :title="
25
                            $__('Clear selected dates')
26
                        "
27
                        @click="clearDateRange"
28
                    >
29
                        <i class="fa fa-times" aria-hidden="true"></i>
30
                        <span class="sr-only">{{
31
                            $__("Clear selected dates")
32
                        }}</span>
33
                    </button>
34
                </div>
35
            </div>
36
        </div>
37
38
        <KohaAlert
39
            v-if="
40
                dateRangeConstraint &&
41
                (maxBookingPeriod === null || maxBookingPeriod > 0)
42
            "
43
            variant="info"
44
            extra-class="booking-constraint-info"
45
        >
46
            <small>
47
                <strong>{{ $__("Booking constraint active:") }}</strong>
48
                {{ constraintHelpText }}
49
            </small>
50
        </KohaAlert>
51
52
        <div class="calendar-legend">
53
            <span class="booking-marker-dot booking-marker-dot--booked"></span>
54
            {{ $__("Booked") }}
55
            <span
56
                class="booking-marker-dot booking-marker-dot--lead ml-3"
57
            ></span>
58
            {{ $__("Lead Period") }}
59
            <span
60
                class="booking-marker-dot booking-marker-dot--trail ml-3"
61
            ></span>
62
            {{ $__("Trail Period") }}
63
            <span
64
                class="booking-marker-dot booking-marker-dot--checked-out ml-3"
65
            ></span>
66
            {{ $__("Checked Out") }}
67
            <span
68
                v-if="dateRangeConstraint && hasSelectedDates"
69
                class="booking-marker-dot ml-3"
70
                style="background-color: #28a745"
71
            ></span>
72
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
73
                {{ $__("Required end date") }}
74
            </span>
75
        </div>
76
77
        <div v-if="errorMessage" class="alert alert-danger mt-2">
78
            {{ errorMessage }}
79
        </div>
80
    </fieldset>
81
    <BookingTooltip
82
        :markers="tooltipMarkers"
83
        :x="tooltipX"
84
        :y="tooltipY"
85
        :visible="tooltipVisible"
86
    />
87
</template>
88
89
<script>
90
import { computed, ref, toRef, watch } from "vue";
91
import KohaAlert from "../KohaAlert.vue";
92
import { useFlatpickr } from "./composables/useFlatpickr.mjs";
93
import { useBookingStore } from "../../stores/bookings";
94
import { storeToRefs } from "pinia";
95
import { useAvailability } from "./composables/useAvailability.mjs";
96
import BookingTooltip from "./BookingTooltip.vue";
97
import { $__ } from "../../i18n";
98
99
export default {
100
    name: "BookingPeriodStep",
101
    components: { BookingTooltip, KohaAlert },
102
    props: {
103
        stepNumber: {
104
            type: Number,
105
            required: true,
106
        },
107
        calendarEnabled: { type: Boolean, default: true },
108
        // disable fn now computed in child
109
        constraintOptions: { type: Object, required: true },
110
        dateRangeConstraint: {
111
            type: String,
112
            default: null,
113
        },
114
        maxBookingPeriod: {
115
            type: Number,
116
            default: null,
117
        },
118
        errorMessage: {
119
            type: String,
120
            default: "",
121
        },
122
        setError: { type: Function, required: true },
123
        hasSelectedDates: {
124
            type: Boolean,
125
            default: false,
126
        },
127
    },
128
    emits: ["clear-dates"],
129
    setup(props, { emit }) {
130
        const store = useBookingStore();
131
        const {
132
            bookings,
133
            checkouts,
134
            bookableItems,
135
            bookingItemId,
136
            bookingId,
137
            selectedDateRange,
138
            circulationRules,
139
        } = storeToRefs(store);
140
        const inputEl = ref(null);
141
142
        const constraintHelpText = computed(() => {
143
            if (!props.dateRangeConstraint) return "";
144
145
            const baseMessages = {
146
                issuelength: props.maxBookingPeriod
147
                    ? $__(
148
                          "Booking period limited to issue length (%s days)"
149
                      ).format(props.maxBookingPeriod)
150
                    : $__("Booking period limited to issue length"),
151
                issuelength_with_renewals: props.maxBookingPeriod
152
                    ? $__(
153
                          "Booking period limited to issue length with renewals (%s days)"
154
                      ).format(props.maxBookingPeriod)
155
                    : $__(
156
                          "Booking period limited to issue length with renewals"
157
                      ),
158
                default: props.maxBookingPeriod
159
                    ? $__(
160
                          "Booking period limited by circulation rules (%s days)"
161
                      ).format(props.maxBookingPeriod)
162
                    : $__("Booking period limited by circulation rules"),
163
            };
164
165
            return (
166
                baseMessages[props.dateRangeConstraint] || baseMessages.default
167
            );
168
        });
169
170
        // Visible calendar range for on-demand markers
171
        const visibleRangeRef = ref({
172
            visibleStartDate: null,
173
            visibleEndDate: null,
174
        });
175
176
        // Merge constraint options with visible range for availability calc
177
        const availabilityOptionsRef = computed(() => ({
178
            ...(props.constraintOptions || {}),
179
            ...(visibleRangeRef.value || {}),
180
        }));
181
182
        const { availability, disableFnRef } = useAvailability(
183
            {
184
                bookings,
185
                checkouts,
186
                bookableItems,
187
                bookingItemId,
188
                bookingId,
189
                selectedDateRange,
190
                circulationRules,
191
            },
192
            availabilityOptionsRef
193
        );
194
195
        // Tooltip refs local to this component, used by the composable and rendered via BookingTooltip
196
        const tooltipMarkers = ref([]);
197
        const tooltipVisible = ref(false);
198
        const tooltipX = ref(0);
199
        const tooltipY = ref(0);
200
201
        const setErrorForFlatpickr = msg => props.setError(msg);
202
203
        const { clear } = useFlatpickr(inputEl, {
204
            store,
205
            disableFnRef,
206
            constraintOptionsRef: toRef(props, "constraintOptions"),
207
            setError: setErrorForFlatpickr,
208
            tooltipMarkersRef: tooltipMarkers,
209
            tooltipVisibleRef: tooltipVisible,
210
            tooltipXRef: tooltipX,
211
            tooltipYRef: tooltipY,
212
            visibleRangeRef,
213
        });
214
215
        // Push availability map (on-demand for current view) to store for markers
216
        watch(
217
            () => availability.value?.unavailableByDate,
218
            map => {
219
                try {
220
                    store.setUnavailableByDate(map ?? {});
221
                } catch (e) {
222
                    // ignore if store shape differs in some contexts
223
                }
224
            },
225
            { immediate: true }
226
        );
227
228
        const clearDateRange = () => {
229
            clear();
230
            emit("clear-dates");
231
        };
232
233
        return {
234
            clearDateRange,
235
            inputEl,
236
            constraintHelpText,
237
            tooltipMarkers,
238
            tooltipVisible,
239
            tooltipX,
240
            tooltipY,
241
        };
242
    },
243
};
244
</script>
245
246
<style scoped>
247
.step-block {
248
    margin-bottom: var(--booking-space-lg);
249
}
250
251
.step-header {
252
    font-weight: 600;
253
    font-size: var(--booking-text-lg);
254
    margin-bottom: calc(var(--booking-space-lg) * 0.75);
255
    color: var(--booking-neutral-600);
256
}
257
258
.form-group {
259
    margin-bottom: var(--booking-space-lg);
260
}
261
262
.booking-date-picker {
263
    display: flex;
264
    align-items: center;
265
}
266
267
.booking-flatpickr-input {
268
    flex: 1;
269
    margin-right: var(--booking-space-md);
270
}
271
272
.booking-date-picker-append {
273
    flex-shrink: 0;
274
}
275
276
.booking-constraint-info {
277
    margin-top: var(--booking-space-md);
278
    margin-bottom: var(--booking-space-lg);
279
}
280
281
.calendar-legend {
282
    display: flex;
283
    flex-wrap: wrap;
284
    align-items: center;
285
    gap: var(--booking-space-md);
286
    font-size: var(--booking-text-sm);
287
    margin-top: var(--booking-space-lg);
288
}
289
290
.booking-marker-dot {
291
    display: inline-block;
292
    width: calc(var(--booking-marker-size) * 3);
293
    height: calc(var(--booking-marker-size) * 3);
294
    border-radius: var(--booking-border-radius-full);
295
    margin-right: var(--booking-space-sm);
296
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
297
}
298
299
.booking-marker-dot--booked {
300
    background-color: var(--booking-warning-bg);
301
}
302
303
.booking-marker-dot--lead {
304
    background-color: hsl(var(--booking-info-hue), 60%, 85%);
305
}
306
307
.booking-marker-dot--trail {
308
    background-color: var(--booking-warning-bg);
309
}
310
311
.booking-marker-dot--checked-out {
312
    background-color: hsl(var(--booking-danger-hue), 60%, 85%);
313
}
314
315
.alert {
316
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
317
    border: var(--booking-border-width) solid transparent;
318
    border-radius: var(--booking-border-radius-sm);
319
}
320
321
.alert-info {
322
    color: hsl(var(--booking-info-hue), 80%, 20%);
323
    background-color: hsl(var(--booking-info-hue), 40%, 90%);
324
    border-color: hsl(var(--booking-info-hue), 40%, 70%);
325
}
326
327
.alert-danger {
328
    color: hsl(var(--booking-danger-hue), 80%, 20%);
329
    background-color: hsl(var(--booking-danger-hue), 40%, 90%);
330
    border-color: hsl(var(--booking-danger-hue), 40%, 70%);
331
}
332
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue (+92 lines)
Line 0 Link Here
1
<template>
2
    <Teleport to="body">
3
        <div
4
            v-if="visible"
5
            class="booking-tooltip"
6
            :style="{
7
                position: 'absolute',
8
                zIndex: 2147483647,
9
                whiteSpace: 'nowrap',
10
                top: `${y}px`,
11
                left: `${x}px`,
12
            }"
13
            role="tooltip"
14
        >
15
            <div
16
                v-for="marker in markers"
17
                :key="marker.type + ':' + (marker.barcode || marker.item)"
18
            >
19
                <span
20
                    :class="[
21
                        'booking-marker-dot',
22
                        `booking-marker-dot--${marker.type}`,
23
                    ]"
24
                />
25
                {{ getMarkerTypeLabel(marker.type) }} ({{ $__("Barcode") }}:
26
                {{ marker.barcode || marker.item || "N/A" }})
27
            </div>
28
        </div>
29
    </Teleport>
30
</template>
31
32
<script setup lang="ts">
33
import { defineProps, withDefaults } from "vue";
34
import { $__ } from "../../i18n";
35
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
36
import type { CalendarMarker } from "./types/bookings";
37
38
withDefaults(
39
    defineProps<{
40
        markers: CalendarMarker[];
41
        x: number;
42
        y: number;
43
        visible: boolean;
44
    }>(),
45
    {
46
        markers: () => [],
47
        x: 0,
48
        y: 0,
49
        visible: false,
50
    }
51
);
52
53
// getMarkerTypeLabel provided by shared UI helper
54
</script>
55
56
<style scoped>
57
.booking-tooltip {
58
    background: hsl(var(--booking-warning-hue), 100%, 95%);
59
    color: hsl(var(--booking-neutral-hue), 20%, 20%);
60
    border: var(--booking-border-width) solid hsl(var(--booking-neutral-hue), 15%, 75%);
61
    border-radius: var(--booking-border-radius-md);
62
    box-shadow: 0 0.125rem 0.5rem hsla(var(--booking-neutral-hue), 10%, 0%, 0.08);
63
    padding: calc(var(--booking-space-xs) * 3) calc(var(--booking-space-xs) * 5);
64
    font-size: var(--booking-text-lg);
65
    pointer-events: none;
66
}
67
68
.booking-marker-dot {
69
    display: inline-block;
70
    width: calc(var(--booking-marker-size) * 1.25);
71
    height: calc(var(--booking-marker-size) * 1.25);
72
    border-radius: var(--booking-border-radius-full);
73
    margin: 0 var(--booking-space-xs) 0 0;
74
    vertical-align: middle;
75
}
76
77
.booking-marker-dot--booked {
78
    background: var(--booking-warning-bg);
79
}
80
81
.booking-marker-dot--checked-out {
82
    background: hsl(var(--booking-danger-hue), 60%, 85%);
83
}
84
85
.booking-marker-dot--lead {
86
    background: hsl(var(--booking-info-hue), 60%, 85%);
87
}
88
89
.booking-marker-dot--trail {
90
    background: var(--booking-warning-bg);
91
}
92
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue (+110 lines)
Line 0 Link Here
1
<template>
2
    <div class="form-group">
3
        <label for="booking_patron">{{ label }}</label>
4
        <v-select
5
            v-model="selectedPatron"
6
            :options="patronOptions"
7
            :filterable="false"
8
            :loading="loading"
9
            :placeholder="placeholder"
10
            label="label"
11
            :clearable="true"
12
            :reset-on-blur="false"
13
            :reset-on-select="false"
14
            :input-id="'booking_patron'"
15
            @search="debouncedPatronSearch"
16
        >
17
            <template #no-options>
18
                <slot name="no-options" :has-searched="hasSearched"
19
                    >Sorry, no matching options.</slot
20
                >
21
            </template>
22
            <template #spinner>
23
                <slot name="spinner">Loading...</slot>
24
            </template>
25
        </v-select>
26
    </div>
27
</template>
28
29
<script>
30
import { computed, ref } from "vue";
31
import vSelect from "vue-select";
32
import "vue-select/dist/vue-select.css";
33
import { processApiError } from "../../utils/apiErrors.js";
34
import { useBookingStore } from "../../stores/bookings";
35
import { storeToRefs } from "pinia";
36
import { debounce } from "./lib/adapters/external-dependents.mjs";
37
38
export default {
39
    name: "PatronSearchSelect",
40
    components: {
41
        vSelect,
42
    },
43
    props: {
44
        modelValue: {
45
            type: Object, // Assuming patron object structure
46
            default: null,
47
        },
48
        label: {
49
            type: String,
50
            required: true,
51
        },
52
        placeholder: {
53
            type: String,
54
            default: "",
55
        },
56
        setError: {
57
            type: Function,
58
            default: null,
59
        },
60
    },
61
    emits: ["update:modelValue"],
62
    setup(props, { emit }) {
63
        const store = useBookingStore();
64
        const { loading } = storeToRefs(store);
65
        const patronOptions = ref([]);
66
        const hasSearched = ref(false); // Track if user has performed a search
67
68
        const selectedPatron = computed({
69
            get: () => props.modelValue,
70
            set: value => emit("update:modelValue", value),
71
        });
72
73
        const onPatronSearch = async search => {
74
            if (!search || search.length < 2) {
75
                hasSearched.value = false;
76
                patronOptions.value = [];
77
                return;
78
            }
79
80
            hasSearched.value = true;
81
            // Store handles loading state through withErrorHandling
82
            try {
83
                const data = await store.fetchPatrons(search);
84
                patronOptions.value = data;
85
            } catch (error) {
86
                const msg = processApiError(error);
87
                console.error("Error searching patrons:", msg);
88
                if (typeof props.setError === "function") {
89
                    try {
90
                        props.setError(msg, "api");
91
                    } catch (e) {
92
                        // no-op: avoid breaking search on error propagation
93
                    }
94
                }
95
                patronOptions.value = [];
96
            }
97
        };
98
99
        const debouncedPatronSearch = debounce(onPatronSearch, 100);
100
101
        return {
102
            selectedPatron,
103
            patronOptions, // Expose internal options
104
            loading: computed(() => loading.value.patrons), // Use store loading state
105
            hasSearched, // Expose search state
106
            debouncedPatronSearch, // Expose internal search handler
107
        };
108
    },
109
};
110
</script>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs (+93 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
import { isoArrayToDates } from "../lib/booking/date-utils.mjs";
3
import {
4
    calculateDisabledDates,
5
    toEffectiveRules,
6
} from "../lib/booking/manager.mjs";
7
8
/**
9
 * Central availability computation.
10
 *
11
 * Date type policy:
12
 * - Input: storeRefs.selectedDateRange is ISO[]; this composable converts to Date[]
13
 * - Output: `disableFnRef` for Flatpickr, `unavailableByDateRef` for calendar markers
14
 *
15
 * @param {{
16
 *  bookings: import('../types/bookings').RefLike<import('../types/bookings').Booking[]>,
17
 *  checkouts: import('../types/bookings').RefLike<import('../types/bookings').Checkout[]>,
18
 *  bookableItems: import('../types/bookings').RefLike<import('../types/bookings').BookableItem[]>,
19
 *  bookingItemId: import('../types/bookings').RefLike<string|number|null>,
20
 *  bookingId: import('../types/bookings').RefLike<string|number|null>,
21
 *  selectedDateRange: import('../types/bookings').RefLike<string[]>,
22
 *  circulationRules: import('../types/bookings').RefLike<import('../types/bookings').CirculationRule[]>
23
 * }} storeRefs
24
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} optionsRef
25
 * @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> }}
26
 */
27
export function useAvailability(storeRefs, optionsRef) {
28
    const {
29
        bookings,
30
        checkouts,
31
        bookableItems,
32
        bookingItemId,
33
        bookingId,
34
        selectedDateRange,
35
        circulationRules,
36
    } = storeRefs;
37
38
    const inputsReady = computed(
39
        () =>
40
            Array.isArray(bookings.value) &&
41
            Array.isArray(checkouts.value) &&
42
            Array.isArray(bookableItems.value) &&
43
            (bookableItems.value?.length ?? 0) > 0
44
    );
45
46
    const availability = computed(() => {
47
        if (!inputsReady.value)
48
            return { disable: () => true, unavailableByDate: {} };
49
50
        const effectiveRules = toEffectiveRules(
51
            circulationRules.value,
52
            optionsRef.value || {}
53
        );
54
55
        const selectedDatesArray = isoArrayToDates(
56
            selectedDateRange.value || []
57
        );
58
59
        // Support on-demand unavailable map for current calendar view
60
        let calcOptions = {};
61
        if (optionsRef && optionsRef.value) {
62
            const { visibleStartDate, visibleEndDate } = optionsRef.value;
63
            if (visibleStartDate && visibleEndDate) {
64
                calcOptions = {
65
                    onDemand: true,
66
                    visibleStartDate,
67
                    visibleEndDate,
68
                };
69
            }
70
        }
71
72
        return calculateDisabledDates(
73
            bookings.value,
74
            checkouts.value,
75
            bookableItems.value,
76
            bookingItemId.value,
77
            bookingId.value,
78
            selectedDatesArray,
79
            effectiveRules,
80
            undefined,
81
            calcOptions
82
        );
83
    });
84
85
    const disableFnRef = computed(
86
        () => availability.value.disable || (() => false)
87
    );
88
    const unavailableByDateRef = computed(
89
        () => availability.value.unavailableByDate || {}
90
    );
91
92
    return { availability, disableFnRef, unavailableByDateRef };
93
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs (+84 lines)
Line 0 Link Here
1
/**
2
 * Vue composable for reactive booking validation
3
 * Provides reactive computed properties that automatically update when store changes
4
 */
5
6
import { computed } from "vue";
7
import { storeToRefs } from "pinia";
8
import {
9
    canProceedToStep3,
10
    canSubmitBooking,
11
    validateDateSelection,
12
} from "../lib/booking/validation.mjs";
13
14
/**
15
 * Composable for booking validation with reactive state
16
 * @param {Object} store - Pinia booking store instance
17
 * @returns {Object} Reactive validation properties and methods
18
 */
19
export function useBookingValidation(store) {
20
    // Extract reactive refs from store
21
    const {
22
        bookingPatron,
23
        pickupLibraryId,
24
        bookingItemtypeId,
25
        itemTypes,
26
        bookingItemId,
27
        bookableItems,
28
        selectedDateRange,
29
        bookings,
30
        checkouts,
31
        circulationRules,
32
        bookingId,
33
    } = storeToRefs(store);
34
35
    // Computed property for step 3 validation
36
    const canProceedToStep3Computed = computed(() => {
37
        return canProceedToStep3({
38
            showPatronSelect: store.showPatronSelect,
39
            bookingPatron: bookingPatron.value,
40
            showItemDetailsSelects: store.showItemDetailsSelects,
41
            showPickupLocationSelect: store.showPickupLocationSelect,
42
            pickupLibraryId: pickupLibraryId.value,
43
            bookingItemtypeId: bookingItemtypeId.value,
44
            itemtypeOptions: itemTypes.value,
45
            bookingItemId: bookingItemId.value,
46
            bookableItems: bookableItems.value,
47
        });
48
    });
49
50
    // Computed property for form submission validation
51
    const canSubmitComputed = computed(() => {
52
        const validationData = {
53
            showPatronSelect: store.showPatronSelect,
54
            bookingPatron: bookingPatron.value,
55
            showItemDetailsSelects: store.showItemDetailsSelects,
56
            showPickupLocationSelect: store.showPickupLocationSelect,
57
            pickupLibraryId: pickupLibraryId.value,
58
            bookingItemtypeId: bookingItemtypeId.value,
59
            itemtypeOptions: itemTypes.value,
60
            bookingItemId: bookingItemId.value,
61
            bookableItems: bookableItems.value,
62
        };
63
        return canSubmitBooking(validationData, selectedDateRange.value);
64
    });
65
66
    // Method for validating date selections
67
    const validateDates = selectedDates => {
68
        return validateDateSelection(
69
            selectedDates,
70
            circulationRules.value,
71
            bookings.value,
72
            checkouts.value,
73
            bookableItems.value,
74
            bookingItemId.value,
75
            bookingId.value
76
        );
77
    };
78
79
    return {
80
        canProceedToStep3: canProceedToStep3Computed,
81
        canSubmit: canSubmitComputed,
82
        validateDates,
83
    };
84
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs (+155 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
import { $__ } from "../../../i18n/index.js";
3
4
/**
5
 * Centralized capacity guard for booking period availability.
6
 * Determines whether circulation rules yield a positive booking period,
7
 * derives a context-aware message, and drives a global warning state.
8
 *
9
 * @param {Object} options
10
 * @param {import('vue').Ref<Array<import('../types/bookings').CirculationRule>>} options.circulationRules
11
 * @param {import('vue').Ref<{patron_category_id: string|null, item_type_id: string|null, library_id: string|null}|null>} options.circulationRulesContext
12
 * @param {import('vue').Ref<{ bookings: boolean; checkouts: boolean; bookableItems: boolean; circulationRules: boolean }>} options.loading
13
 * @param {import('vue').Ref<Array<import('../types/bookings').BookableItem>>} options.bookableItems
14
 * @param {import('vue').Ref<import('../types/bookings').PatronLike|null>} options.bookingPatron
15
 * @param {import('vue').Ref<string|number|null>} options.bookingItemId
16
 * @param {import('vue').Ref<string|number|null>} options.bookingItemtypeId
17
 * @param {import('vue').Ref<string|null>} options.pickupLibraryId
18
 * @param {boolean} options.showPatronSelect
19
 * @param {boolean} options.showItemDetailsSelects
20
 * @param {boolean} options.showPickupLocationSelect
21
 * @param {string|null} options.dateRangeConstraint
22
 */
23
export function useCapacityGuard(options) {
24
    const {
25
        circulationRules,
26
        circulationRulesContext,
27
        loading,
28
        bookableItems,
29
        bookingPatron,
30
        bookingItemId,
31
        bookingItemtypeId,
32
        pickupLibraryId,
33
        showPatronSelect,
34
        showItemDetailsSelects,
35
        showPickupLocationSelect,
36
        dateRangeConstraint,
37
    } = options;
38
39
    const hasPositiveCapacity = computed(() => {
40
        const rules = circulationRules.value?.[0] || {};
41
        const issuelength = Number(rules.issuelength) || 0;
42
        const renewalperiod = Number(rules.renewalperiod) || 0;
43
        const renewalsallowed = Number(rules.renewalsallowed) || 0;
44
        const withRenewals = issuelength + renewalperiod * renewalsallowed;
45
46
        // Backend-calculated period (end_date_only mode) if present
47
        const calculatedDays =
48
            rules.calculated_period_days != null
49
                ? Number(rules.calculated_period_days) || 0
50
                : null;
51
52
        // Respect explicit constraint if provided
53
        if (dateRangeConstraint === "issuelength") return issuelength > 0;
54
        if (dateRangeConstraint === "issuelength_with_renewals")
55
            return withRenewals > 0;
56
57
        // Fallback logic: if backend provided a period, use it; otherwise consider both forms
58
        if (calculatedDays != null) return calculatedDays > 0;
59
        return issuelength > 0 || withRenewals > 0;
60
    });
61
62
    // Tailored error message based on rule values and available inputs
63
    const zeroCapacityMessage = computed(() => {
64
        const rules = circulationRules.value?.[0] || {};
65
        const issuelength = rules.issuelength;
66
        const hasExplicitZero = issuelength != null && Number(issuelength) === 0;
67
        const hasNull = issuelength === null || issuelength === undefined;
68
69
        // If rule explicitly set to zero, it's a circulation policy issue
70
        if (hasExplicitZero) {
71
            if (showPatronSelect && showItemDetailsSelects && showPickupLocationSelect) {
72
                return $__(
73
                    "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."
74
                );
75
            }
76
            if (showItemDetailsSelects && showPickupLocationSelect) {
77
                return $__(
78
                    "Bookings are not permitted for this item type at the selected pickup location. The circulation rules set the booking period to zero days."
79
                );
80
            }
81
            if (showItemDetailsSelects) {
82
                return $__(
83
                    "Bookings are not permitted for this item type. The circulation rules set the booking period to zero days."
84
                );
85
            }
86
            return $__(
87
                "Bookings are not permitted for this item. The circulation rules set the booking period to zero days."
88
            );
89
        }
90
91
        // If null, no specific rule exists - suggest trying different options
92
        if (hasNull) {
93
            const suggestions = [];
94
            if (showItemDetailsSelects) suggestions.push($__("item type"));
95
            if (showPickupLocationSelect) suggestions.push($__("pickup location"));
96
            if (showPatronSelect) suggestions.push($__("patron"));
97
98
            if (suggestions.length > 0) {
99
                const suggestionText = suggestions.join($__(" or "));
100
                return $__(
101
                    "No circulation rule is defined for this combination. Try a different %s."
102
                ).replace("%s", suggestionText);
103
            }
104
        }
105
106
        // Fallback for other edge cases
107
        const both = showItemDetailsSelects && showPickupLocationSelect;
108
        if (both) {
109
            return $__(
110
                "No valid booking period is available with the current selection. Try a different item type or pickup location."
111
            );
112
        }
113
        if (showItemDetailsSelects) {
114
            return $__(
115
                "No valid booking period is available with the current selection. Try a different item type."
116
            );
117
        }
118
        if (showPickupLocationSelect) {
119
            return $__(
120
                "No valid booking period is available with the current selection. Try a different pickup location."
121
            );
122
        }
123
        return $__(
124
            "No valid booking period is available for this record with your current settings. Please try again later or contact your library."
125
        );
126
    });
127
128
    // Compute when to show the global capacity banner
129
    const showCapacityWarning = computed(() => {
130
        const dataReady =
131
            !loading.value?.bookings &&
132
            !loading.value?.checkouts &&
133
            !loading.value?.bookableItems;
134
        const hasItems = (bookableItems.value?.length ?? 0) > 0;
135
        const hasRules = (circulationRules.value?.length ?? 0) > 0;
136
137
        // Only show warning when we have complete context for circulation rules.
138
        // Use the stored context from the last API request rather than inferring from UI state.
139
        // Complete context means all three components were provided: patron_category, item_type, library.
140
        const context = circulationRulesContext.value;
141
        const hasCompleteContext =
142
            context &&
143
            context.patron_category_id != null &&
144
            context.item_type_id != null &&
145
            context.library_id != null;
146
147
        // Only show warning after we have the most specific circulation rule (not while loading)
148
        // and all required context is present
149
        const rulesReady = !loading.value?.circulationRules;
150
151
        return dataReady && rulesReady && hasItems && hasRules && hasCompleteContext && !hasPositiveCapacity.value;
152
    });
153
154
    return { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning };
155
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs (+32 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
import dayjs from "../../../utils/dayjs.mjs";
3
import {
4
    toEffectiveRules,
5
    calculateConstraintHighlighting,
6
} from "../lib/booking/manager.mjs";
7
8
/**
9
 * Provides reactive constraint highlighting data for the calendar based on
10
 * selected start date, circulation rules, and constraint options.
11
 *
12
 * @param {import('../types/bookings').BookingStoreLike} store
13
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>|undefined} constraintOptionsRef
14
 * @returns {{
15
 *   highlightingData: import('vue').ComputedRef<null | import('../types/bookings').ConstraintHighlighting>
16
 * }}
17
 */
18
export function useConstraintHighlighting(store, constraintOptionsRef) {
19
    const highlightingData = computed(() => {
20
        const startISO = store.selectedDateRange?.[0];
21
        if (!startISO) return null;
22
        const opts = constraintOptionsRef?.value ?? {};
23
        const effectiveRules = toEffectiveRules(store.circulationRules, opts);
24
        return calculateConstraintHighlighting(
25
            dayjs(startISO).toDate(),
26
            effectiveRules,
27
            opts
28
        );
29
    });
30
31
    return { highlightingData };
32
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDefaultPickup.mjs (+64 lines)
Line 0 Link Here
1
import { watch } from "vue";
2
import { idsEqual } from "../lib/booking/id-utils.mjs";
3
4
/**
5
 * Sets a sensible default pickup library when none is selected.
6
 * Preference order:
7
 * - OPAC default when enabled and valid
8
 * - Patron's home library if available at pickup locations
9
 * - First bookable item's home library if available at pickup locations
10
 *
11
 * @param {import('../types/bookings').DefaultPickupOptions} options
12
 * @returns {{ stop: import('vue').WatchStopHandle }}
13
 */
14
export function useDefaultPickup(options) {
15
    const {
16
        bookingPickupLibraryId, // ref
17
        bookingPatron, // ref
18
        pickupLocations, // ref(Array)
19
        bookableItems, // ref(Array)
20
        opacDefaultBookingLibraryEnabled, // prop value
21
        opacDefaultBookingLibrary, // prop value
22
    } = options;
23
24
    const stop = watch(
25
        [() => bookingPatron.value, () => pickupLocations.value],
26
        ([patron, locations]) => {
27
            if (bookingPickupLibraryId.value) return;
28
            const list = Array.isArray(locations) ? locations : [];
29
30
            // 1) OPAC default override
31
            const enabled =
32
                opacDefaultBookingLibraryEnabled === true ||
33
                String(opacDefaultBookingLibraryEnabled) === "1";
34
            const def = opacDefaultBookingLibrary ?? "";
35
            if (enabled && def && list.some(l => idsEqual(l.library_id, def))) {
36
                bookingPickupLibraryId.value = def;
37
                return;
38
            }
39
40
            // 2) Patron library
41
            if (patron && list.length > 0) {
42
                const patronLib = patron.library_id;
43
                if (list.some(l => idsEqual(l.library_id, patronLib))) {
44
                    bookingPickupLibraryId.value = patronLib;
45
                    return;
46
                }
47
            }
48
49
            // 3) First item's home library
50
            const items = Array.isArray(bookableItems.value)
51
                ? bookableItems.value
52
                : [];
53
            if (items.length > 0 && list.length > 0) {
54
                const homeLib = items[0]?.home_library_id;
55
                if (list.some(l => idsEqual(l.library_id, homeLib))) {
56
                    bookingPickupLibraryId.value = homeLib;
57
                }
58
            }
59
        },
60
        { immediate: true }
61
    );
62
63
    return { stop };
64
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useDerivedItemType.mjs (+48 lines)
Line 0 Link Here
1
import { watch } from "vue";
2
import { idsEqual } from "../lib/booking/id-utils.mjs";
3
4
/**
5
 * Auto-derive item type: prefer a single constrained type; otherwise infer
6
 * from currently selected item.
7
 *
8
 * @param {import('../types/bookings').DerivedItemTypeOptions} options
9
 * @returns {import('vue').WatchStopHandle} Stop handle from Vue watch()
10
 */
11
export function useDerivedItemType(options) {
12
    const {
13
        bookingItemtypeId,
14
        bookingItemId,
15
        constrainedItemTypes,
16
        bookableItems,
17
    } = options;
18
19
    return watch(
20
        [
21
            constrainedItemTypes,
22
            () => bookingItemId.value,
23
            () => bookableItems.value,
24
        ],
25
        ([types, itemId, items]) => {
26
            if (
27
                !bookingItemtypeId.value &&
28
                Array.isArray(types) &&
29
                types.length === 1
30
            ) {
31
                bookingItemtypeId.value = types[0].item_type_id;
32
                return;
33
            }
34
            if (!bookingItemtypeId.value && itemId) {
35
                const item = (items || []).find(i =>
36
                    idsEqual(i.item_id, itemId)
37
                );
38
                if (item) {
39
                    bookingItemtypeId.value =
40
                        item.effective_item_type_id ||
41
                        item.item_type_id ||
42
                        null;
43
                }
44
            }
45
        },
46
        { immediate: true }
47
    );
48
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useErrorState.mjs (+30 lines)
Line 0 Link Here
1
import { reactive, computed } from "vue";
2
3
/**
4
 * Simple error state composable used across booking components.
5
 * Exposes a reactive error object with message and code, and helpers
6
 * to set/clear it consistently.
7
 *
8
 * @param {import('../types/bookings').ErrorStateInit} [initial]
9
 * @returns {import('../types/bookings').ErrorStateResult}
10
 */
11
export function useErrorState(initial = {}) {
12
    const state = reactive({
13
        message: initial.message || "",
14
        code: initial.code || null,
15
    });
16
17
    function setError(message, code = "ui") {
18
        state.message = message || "";
19
        state.code = message ? code || "ui" : null;
20
    }
21
22
    function clear() {
23
        state.message = "";
24
        state.code = null;
25
    }
26
27
    const hasError = computed(() => !!state.message);
28
29
    return { error: state, setError, clear, hasError };
30
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs (+277 lines)
Line 0 Link Here
1
import { onMounted, onUnmounted, watch } from "vue";
2
import flatpickr from "flatpickr";
3
import { isoArrayToDates } from "../lib/booking/date-utils.mjs";
4
import { useBookingStore } from "../../../stores/bookings.js";
5
import {
6
    applyCalendarHighlighting,
7
    clearCalendarHighlighting,
8
    createOnDayCreate,
9
    createOnClose,
10
    createOnChange,
11
    getVisibleCalendarDates,
12
    buildMarkerGrid,
13
    getCurrentLanguageCode,
14
    preloadFlatpickrLocale,
15
} from "../lib/adapters/calendar.mjs";
16
import {
17
    CLASS_FLATPICKR_DAY,
18
    CLASS_BOOKING_MARKER_GRID,
19
} from "../lib/booking/constants.mjs";
20
import {
21
    getBookingMarkersForDate,
22
    aggregateMarkersByType,
23
} from "../lib/booking/manager.mjs";
24
import { useConstraintHighlighting } from "./useConstraintHighlighting.mjs";
25
import { win } from "../lib/adapters/globals.mjs";
26
27
/**
28
 * Flatpickr integration for the bookings calendar.
29
 *
30
 * Date type policy:
31
 * - Store holds ISO strings in selectedDateRange (single source of truth)
32
 * - Flatpickr works with Date objects; we convert at the boundary
33
 * - API receives ISO strings
34
 *
35
 * @param {{ value: HTMLInputElement|null }} elRef - ref to the input element
36
 * @param {Object} options
37
 * @param {import('../types/bookings').BookingStoreLike} [options.store] - booking store (defaults to pinia store)
38
 * @param {import('../types/bookings').RefLike<import('../types/bookings').DisableFn>} options.disableFnRef - ref to disable fn
39
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} options.constraintOptionsRef
40
 * @param {(msg: string) => void} options.setError - set error message callback
41
 * @param {import('vue').Ref<{visibleStartDate?: Date|null, visibleEndDate?: Date|null}>} [options.visibleRangeRef]
42
 * @param {import('../types/bookings').RefLike<import('../types/bookings').CalendarMarker[]>} [options.tooltipMarkersRef]
43
 * @param {import('../types/bookings').RefLike<boolean>} [options.tooltipVisibleRef]
44
 * @param {import('../types/bookings').RefLike<number>} [options.tooltipXRef]
45
 * @param {import('../types/bookings').RefLike<number>} [options.tooltipYRef]
46
 * @returns {{ clear: () => void, getInstance: () => import('../types/bookings').FlatpickrInstanceWithHighlighting | null }}
47
 */
48
export function useFlatpickr(elRef, options) {
49
    const store = options.store || useBookingStore();
50
51
    const disableFnRef = options.disableFnRef; // Ref<Function>
52
    const constraintOptionsRef = options.constraintOptionsRef; // Ref<{dateRangeConstraint,maxBookingPeriod}>
53
    const setError = options.setError; // function(string)
54
    const tooltipMarkersRef = options.tooltipMarkersRef; // Ref<Array>
55
    const tooltipVisibleRef = options.tooltipVisibleRef; // Ref<boolean>
56
    const tooltipXRef = options.tooltipXRef; // Ref<number>
57
    const tooltipYRef = options.tooltipYRef; // Ref<number>
58
    const visibleRangeRef = options.visibleRangeRef; // Ref<{visibleStartDate,visibleEndDate}>
59
60
    let fp = null;
61
62
    function toDateArrayFromStore() {
63
        return isoArrayToDates(store.selectedDateRange || []);
64
    }
65
66
    function setDisableOnInstance() {
67
        if (!fp) return;
68
        const disableFn = disableFnRef?.value;
69
        fp.set("disable", [
70
            typeof disableFn === "function" ? disableFn : () => false,
71
        ]);
72
    }
73
74
    function syncInstanceDatesFromStore() {
75
        if (!fp) return;
76
        try {
77
            const dates = toDateArrayFromStore();
78
            if (dates.length > 0) {
79
                fp.setDate(dates, false);
80
                if (dates[0] && fp.jumpToDate) fp.jumpToDate(dates[0]);
81
            } else {
82
                fp.clear();
83
            }
84
        } catch (e) {
85
            // noop
86
        }
87
    }
88
89
    onMounted(async () => {
90
        if (!elRef?.value) return;
91
92
        // Ensure locale is loaded before initializing flatpickr
93
        await preloadFlatpickrLocale();
94
95
        const dateFormat =
96
            typeof win("flatpickr_dateformat_string") === "string"
97
                ? /** @type {string} */ (win("flatpickr_dateformat_string"))
98
                : "d.m.Y";
99
100
        const langCode = getCurrentLanguageCode();
101
        // Use locale from window.flatpickr.l10ns (populated by dynamic import in preloadFlatpickrLocale)
102
        // The ES module flatpickr import and window.flatpickr are different instances
103
        const locale = langCode !== "en" ? win("flatpickr")?.["l10ns"]?.[langCode] : undefined;
104
105
        /** @type {Partial<import('flatpickr/dist/types/options').Options>} */
106
        const baseConfig = {
107
            mode: "range",
108
            minDate: "today",
109
            disable: [() => false],
110
            clickOpens: true,
111
            dateFormat,
112
            ...(locale && { locale }),
113
            allowInput: false,
114
            onChange: createOnChange(store, {
115
                setError,
116
                tooltipVisibleRef: tooltipVisibleRef || { value: false },
117
                constraintOptions: constraintOptionsRef?.value || {},
118
            }),
119
            onClose: createOnClose(
120
                tooltipMarkersRef || { value: [] },
121
                tooltipVisibleRef || { value: false }
122
            ),
123
            onDayCreate: createOnDayCreate(
124
                store,
125
                tooltipMarkersRef || { value: [] },
126
                tooltipVisibleRef || { value: false },
127
                tooltipXRef || { value: 0 },
128
                tooltipYRef || { value: 0 }
129
            ),
130
        };
131
132
        fp = flatpickr(elRef.value, {
133
            ...baseConfig,
134
            onReady: [
135
                function (_selectedDates, _dateStr, instance) {
136
                    try {
137
                        if (visibleRangeRef && instance) {
138
                            const visible = getVisibleCalendarDates(instance);
139
                            if (visible && visible.length > 0) {
140
                                visibleRangeRef.value = {
141
                                    visibleStartDate: visible[0],
142
                                    visibleEndDate: visible[visible.length - 1],
143
                                };
144
                            }
145
                        }
146
                    } catch (e) {
147
                        // non-fatal
148
                    }
149
                },
150
            ],
151
            onMonthChange: [
152
                function (_selectedDates, _dateStr, instance) {
153
                    try {
154
                        if (visibleRangeRef && instance) {
155
                            const visible = getVisibleCalendarDates(instance);
156
                            if (visible && visible.length > 0) {
157
                                visibleRangeRef.value = {
158
                                    visibleStartDate: visible[0],
159
                                    visibleEndDate: visible[visible.length - 1],
160
                                };
161
                            }
162
                        }
163
                    } catch (e) {}
164
                },
165
            ],
166
            onYearChange: [
167
                function (_selectedDates, _dateStr, instance) {
168
                    try {
169
                        if (visibleRangeRef && instance) {
170
                            const visible = getVisibleCalendarDates(instance);
171
                            if (visible && visible.length > 0) {
172
                                visibleRangeRef.value = {
173
                                    visibleStartDate: visible[0],
174
                                    visibleEndDate: visible[visible.length - 1],
175
                                };
176
                            }
177
                        }
178
                    } catch (e) {}
179
                },
180
            ],
181
        });
182
183
        setDisableOnInstance();
184
        syncInstanceDatesFromStore();
185
    });
186
187
    // React to availability updates
188
    if (disableFnRef) {
189
        watch(disableFnRef, () => {
190
            setDisableOnInstance();
191
        });
192
    }
193
194
    // Recalculate visual constraint highlighting when constraint options or rules change
195
    if (constraintOptionsRef) {
196
        const { highlightingData } = useConstraintHighlighting(
197
            store,
198
            constraintOptionsRef
199
        );
200
        watch(
201
            () => highlightingData.value,
202
            data => {
203
                if (!fp) return;
204
                if (!data) {
205
                    // Clear the cache to prevent onDayCreate from reapplying stale data
206
                    const instWithCache =
207
                        /** @type {import('../types/bookings').FlatpickrInstanceWithHighlighting} */ (
208
                            fp
209
                        );
210
                    instWithCache._constraintHighlighting = null;
211
                    clearCalendarHighlighting(fp);
212
                    return;
213
                }
214
                applyCalendarHighlighting(fp, data);
215
            }
216
        );
217
    }
218
219
    // Refresh marker dots when unavailableByDate changes
220
    watch(
221
        () => store.unavailableByDate,
222
        () => {
223
            if (!fp || !fp.calendarContainer) return;
224
            try {
225
                const dayElements = fp.calendarContainer.querySelectorAll(
226
                    `.${CLASS_FLATPICKR_DAY}`
227
                );
228
                dayElements.forEach(dayElem => {
229
                    const existingGrids = dayElem.querySelectorAll(
230
                        `.${CLASS_BOOKING_MARKER_GRID}`
231
                    );
232
                    existingGrids.forEach(grid => grid.remove());
233
234
                    /** @type {import('flatpickr/dist/types/instance').DayElement} */
235
                    const el = /** @type {import('flatpickr/dist/types/instance').DayElement} */ (dayElem);
236
                    if (!el.dateObj) return;
237
                    const markersForDots = getBookingMarkersForDate(
238
                        store.unavailableByDate,
239
                        el.dateObj,
240
                        store.bookableItems
241
                    );
242
                    if (markersForDots.length > 0) {
243
                        const aggregated = aggregateMarkersByType(markersForDots);
244
                        const grid = buildMarkerGrid(aggregated);
245
                        if (grid.hasChildNodes()) dayElem.appendChild(grid);
246
                    }
247
                });
248
            } catch (e) {
249
                // non-fatal
250
            }
251
        },
252
        { deep: true }
253
    );
254
255
    // Sync UI when dates change programmatically
256
    watch(
257
        () => store.selectedDateRange,
258
        () => {
259
            syncInstanceDatesFromStore();
260
        },
261
        { deep: true }
262
    );
263
264
    onUnmounted(() => {
265
        if (fp?.destroy) fp.destroy();
266
        fp = null;
267
    });
268
269
    return {
270
        clear() {
271
            if (fp?.clear) fp.clear();
272
        },
273
        getInstance() {
274
            return fp;
275
        },
276
    };
277
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs (+84 lines)
Line 0 Link Here
1
import { watchEffect, ref } from "vue";
2
3
/**
4
 * Watch core selections and fetch pickup locations and circulation rules.
5
 * De-duplicates rules fetches by building a stable key from inputs.
6
 *
7
 * @param {Object} options
8
 * @param {import('../types/bookings').StoreWithActions} options.store
9
 * @param {import('../types/bookings').RefLike<import('../types/bookings').PatronLike|null>} options.bookingPatron
10
 * @param {import('../types/bookings').RefLike<string|null>} options.bookingPickupLibraryId
11
 * @param {import('../types/bookings').RefLike<string|number|null>} options.bookingItemtypeId
12
 * @param {import('../types/bookings').RefLike<Array<import('../types/bookings').ItemType>>} options.constrainedItemTypes
13
 * @param {import('../types/bookings').RefLike<Array<string>>} options.selectedDateRange
14
 * @param {string|import('../types/bookings').RefLike<string>} options.biblionumber
15
 * @returns {{ lastRulesKey: import('vue').Ref<string|null> }}
16
 */
17
export function useRulesFetcher(options) {
18
    const {
19
        store,
20
        bookingPatron, // ref(Object|null)
21
        bookingPickupLibraryId, // ref(String|null)
22
        bookingItemtypeId, // ref(String|Number|null)
23
        constrainedItemTypes, // ref(Array)
24
        selectedDateRange, // ref([ISO, ISO])
25
        biblionumber, // string or ref(optional)
26
    } = options;
27
28
    const lastRulesKey = ref(null);
29
30
    watchEffect(
31
        () => {
32
            const patronId = bookingPatron.value?.patron_id;
33
            const biblio =
34
                typeof biblionumber === "object"
35
                    ? biblionumber.value
36
                    : biblionumber;
37
38
            if (patronId && biblio) {
39
                store.fetchPickupLocations(biblio, patronId);
40
            }
41
42
            const patron = bookingPatron.value;
43
            const derivedItemTypeId =
44
                bookingItemtypeId.value ??
45
                (Array.isArray(constrainedItemTypes.value) &&
46
                constrainedItemTypes.value.length === 1
47
                    ? constrainedItemTypes.value[0].item_type_id
48
                    : undefined);
49
50
            const rulesParams = {
51
                patron_category_id: patron?.category_id,
52
                item_type_id: derivedItemTypeId,
53
                library_id: bookingPickupLibraryId.value,
54
            };
55
            const key = buildRulesKey(rulesParams);
56
            if (lastRulesKey.value !== key) {
57
                lastRulesKey.value = key;
58
                // Invalidate stale backend due so UI falls back to maxPeriod until fresh rules arrive
59
                store.invalidateCalculatedDue();
60
                store.fetchCirculationRules(rulesParams);
61
            }
62
        },
63
        { flush: "post" }
64
    );
65
66
    return { lastRulesKey };
67
}
68
69
/**
70
 * Stable, explicit, order-preserving key builder to avoid JSON quirks
71
 *
72
 * @param {import('../types/bookings').RulesParams} params
73
 * @returns {string}
74
 */
75
function buildRulesKey(params) {
76
    return [
77
        ["pc", params.patron_category_id],
78
        ["it", params.item_type_id],
79
        ["lib", params.library_id],
80
    ]
81
        .filter(([, v]) => v ?? v === 0)
82
        .map(([k, v]) => `${k}=${String(v)}`)
83
        .join("|");
84
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js (+216 lines)
Line 0 Link Here
1
/**
2
 * @module opacBookingApi
3
 * @description Service module for all OPAC booking-related API calls.
4
 * All functions return promises and use async/await.
5
 */
6
7
import { bookingValidation } from "../../booking/validation-messages.js";
8
9
/**
10
 * Fetches bookable items for a given biblionumber
11
 * @param {number|string} biblionumber - The biblionumber to fetch items for
12
 * @returns {Promise<Array<Object>>} Array of bookable items
13
 * @throws {Error} If the request fails or returns a non-OK status
14
 */
15
export async function fetchBookableItems(biblionumber) {
16
    if (!biblionumber) {
17
        throw bookingValidation.validationError("biblionumber_required");
18
    }
19
20
    const response = await fetch(
21
        `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/items`,
22
        {
23
            headers: {
24
                "x-koha-embed": "+strings",
25
            },
26
        }
27
    );
28
29
    if (!response.ok) {
30
        throw bookingValidation.validationError("fetch_bookable_items_failed", {
31
            status: response.status,
32
            statusText: response.statusText,
33
        });
34
    }
35
36
    return await response.json();
37
}
38
39
/**
40
 * Fetches bookings for a given biblionumber
41
 * @param {number|string} biblionumber - The biblionumber to fetch bookings for
42
 * @returns {Promise<Array<Object>>} Array of bookings
43
 * @throws {Error} If the request fails or returns a non-OK status
44
 */
45
export async function fetchBookings(biblionumber) {
46
    if (!biblionumber) {
47
        throw bookingValidation.validationError("biblionumber_required");
48
    }
49
50
    const response = await fetch(
51
        `/api/v1/public/biblios/${encodeURIComponent(
52
            biblionumber
53
        )}/bookings?q={"status":{"-in":["new","pending","active"]}}`
54
    );
55
56
    if (!response.ok) {
57
        throw bookingValidation.validationError("fetch_bookings_failed", {
58
            status: response.status,
59
            statusText: response.statusText,
60
        });
61
    }
62
63
    return await response.json();
64
}
65
66
/**
67
 * Fetches checkouts for a given biblionumber
68
 * @param {number|string} biblionumber - The biblionumber to fetch checkouts for
69
 * @returns {Promise<Array<Object>>} Array of checkouts
70
 * @throws {Error} If the request fails or returns a non-OK status
71
 */
72
export async function fetchCheckouts(biblionumber) {
73
    if (!biblionumber) {
74
        throw bookingValidation.validationError("biblionumber_required");
75
    }
76
77
    const response = await fetch(
78
        `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/checkouts`
79
    );
80
81
    if (!response.ok) {
82
        throw bookingValidation.validationError("fetch_checkouts_failed", {
83
            status: response.status,
84
            statusText: response.statusText,
85
        });
86
    }
87
88
    return await response.json();
89
}
90
91
/**
92
 * Fetches a single patron by ID
93
 * @param {number|string} patronId - The ID of the patron to fetch
94
 * @returns {Promise<Object>} The patron object
95
 * @throws {Error} If the request fails or returns a non-OK status
96
 */
97
export async function fetchPatron(patronId) {
98
    const response = await fetch(`/api/v1/public/patrons/${patronId}`, {
99
        headers: { "x-koha-embed": "library" },
100
    });
101
102
    if (!response.ok) {
103
        throw bookingValidation.validationError("fetch_patron_failed", {
104
            status: response.status,
105
            statusText: response.statusText,
106
        });
107
    }
108
109
    return await response.json();
110
}
111
112
/**
113
 * Searches for patrons - not used in OPAC
114
 * @returns {Promise<Array>}
115
 */
116
export async function fetchPatrons() {
117
    return [];
118
}
119
120
/**
121
 * Fetches pickup locations for a biblionumber
122
 * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for
123
 * @returns {Promise<Array<Object>>} Array of pickup location objects
124
 * @throws {Error} If the request fails or returns a non-OK status
125
 */
126
export async function fetchPickupLocations(biblionumber, patronId) {
127
    if (!biblionumber) {
128
        throw bookingValidation.validationError("biblionumber_required");
129
    }
130
131
    const params = new URLSearchParams({
132
        _order_by: "name",
133
        _per_page: "-1",
134
    });
135
136
    if (patronId) {
137
        params.append("patron_id", patronId);
138
    }
139
140
    const response = await fetch(
141
        `/api/v1/public/biblios/${encodeURIComponent(
142
            biblionumber
143
        )}/pickup_locations?${params.toString()}`
144
    );
145
146
    if (!response.ok) {
147
        throw bookingValidation.validationError(
148
            "fetch_pickup_locations_failed",
149
            {
150
                status: response.status,
151
                statusText: response.statusText,
152
            }
153
        );
154
    }
155
156
    return await response.json();
157
}
158
159
/**
160
 * Fetches circulation rules for booking constraints
161
 * Now uses the enhanced circulation_rules endpoint with date calculation capabilities
162
 * @param {Object} params - Parameters for circulation rules query
163
 * @param {string|number} [params.patron_category_id] - Patron category ID
164
 * @param {string|number} [params.item_type_id] - Item type ID
165
 * @param {string|number} [params.library_id] - Library ID
166
 * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules)
167
 * @returns {Promise<Object>} Object containing circulation rules with calculated dates
168
 * @throws {Error} If the request fails or returns a non-OK status
169
 */
170
export async function fetchCirculationRules(params = {}) {
171
    const filteredParams = {};
172
    for (const key in params) {
173
        if (
174
            params[key] !== null &&
175
            params[key] !== undefined &&
176
            params[key] !== ""
177
        ) {
178
            filteredParams[key] = params[key];
179
        }
180
    }
181
182
    if (!filteredParams.rules) {
183
        filteredParams.rules =
184
            "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod";
185
    }
186
187
    const urlParams = new URLSearchParams();
188
    Object.entries(filteredParams).forEach(([k, v]) => {
189
        if (v === undefined || v === null) return;
190
        urlParams.set(k, String(v));
191
    });
192
193
    const response = await fetch(
194
        `/api/v1/public/circulation_rules?${urlParams.toString()}`
195
    );
196
197
    if (!response.ok) {
198
        throw bookingValidation.validationError(
199
            "fetch_circulation_rules_failed",
200
            {
201
                status: response.status,
202
                statusText: response.statusText,
203
            }
204
        );
205
    }
206
207
    return await response.json();
208
}
209
210
export async function createBooking() {
211
    return {};
212
}
213
214
export async function updateBooking() {
215
    return {};
216
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js (+386 lines)
Line 0 Link Here
1
/**
2
 * @module bookingApi
3
 * @description Service module for all booking-related API calls.
4
 * All functions return promises and use async/await.
5
 */
6
7
import { bookingValidation } from "../../booking/validation-messages.js";
8
9
/**
10
 * Fetches bookable items for a given biblionumber
11
 * @param {number|string} biblionumber - The biblionumber to fetch items for
12
 * @returns {Promise<Array<Object>>} Array of bookable items
13
 * @throws {Error} If the request fails or returns a non-OK status
14
 */
15
export async function fetchBookableItems(biblionumber) {
16
    if (!biblionumber) {
17
        throw bookingValidation.validationError("biblionumber_required");
18
    }
19
20
    const response = await fetch(
21
        `/api/v1/biblios/${encodeURIComponent(biblionumber)}/items?bookable=1`,
22
        {
23
            headers: {
24
                "x-koha-embed": "+strings",
25
            },
26
        }
27
    );
28
29
    if (!response.ok) {
30
        throw bookingValidation.validationError("fetch_bookable_items_failed", {
31
            status: response.status,
32
            statusText: response.statusText,
33
        });
34
    }
35
36
    return await response.json();
37
}
38
39
/**
40
 * Fetches bookings for a given biblionumber
41
 * @param {number|string} biblionumber - The biblionumber to fetch bookings for
42
 * @returns {Promise<Array<Object>>} Array of bookings
43
 * @throws {Error} If the request fails or returns a non-OK status
44
 */
45
export async function fetchBookings(biblionumber) {
46
    if (!biblionumber) {
47
        throw bookingValidation.validationError("biblionumber_required");
48
    }
49
50
    const response = await fetch(
51
        `/api/v1/biblios/${encodeURIComponent(
52
            biblionumber
53
        )}/bookings?q={"status":{"-in":["new","pending","active"]}}`
54
    );
55
56
    if (!response.ok) {
57
        throw bookingValidation.validationError("fetch_bookings_failed", {
58
            status: response.status,
59
            statusText: response.statusText,
60
        });
61
    }
62
63
    return await response.json();
64
}
65
66
/**
67
 * Fetches checkouts for a given biblionumber
68
 * @param {number|string} biblionumber - The biblionumber to fetch checkouts for
69
 * @returns {Promise<Array<Object>>} Array of checkouts
70
 * @throws {Error} If the request fails or returns a non-OK status
71
 */
72
export async function fetchCheckouts(biblionumber) {
73
    if (!biblionumber) {
74
        throw bookingValidation.validationError("biblionumber_required");
75
    }
76
77
    const response = await fetch(
78
        `/api/v1/biblios/${encodeURIComponent(biblionumber)}/checkouts`
79
    );
80
81
    if (!response.ok) {
82
        throw bookingValidation.validationError("fetch_checkouts_failed", {
83
            status: response.status,
84
            statusText: response.statusText,
85
        });
86
    }
87
88
    return await response.json();
89
}
90
91
/**
92
 * Fetches a single patron by ID
93
 * @param {number|string} patronId - The ID of the patron to fetch
94
 * @returns {Promise<Object>} The patron object
95
 * @throws {Error} If the request fails or returns a non-OK status
96
 */
97
export async function fetchPatron(patronId) {
98
    if (!patronId) {
99
        throw bookingValidation.validationError("patron_id_required");
100
    }
101
102
    const params = new URLSearchParams({
103
        patron_id: String(patronId),
104
    });
105
106
    const response = await fetch(`/api/v1/patrons?${params.toString()}`, {
107
        headers: { "x-koha-embed": "library" },
108
    });
109
110
    if (!response.ok) {
111
        throw bookingValidation.validationError("fetch_patron_failed", {
112
            status: response.status,
113
            statusText: response.statusText,
114
        });
115
    }
116
117
    return await response.json();
118
}
119
120
import { buildPatronSearchQuery } from "../patron.mjs";
121
122
/**
123
 * Searches for patrons matching a search term
124
 * @param {string} term - The search term to match against patron names, cardnumbers, etc.
125
 * @param {number} [page=1] - The page number for pagination
126
 * @returns {Promise<Object>} Object containing patron search results
127
 * @throws {Error} If the request fails or returns a non-OK status
128
 */
129
export async function fetchPatrons(term, page = 1) {
130
    if (!term) {
131
        return { results: [] };
132
    }
133
134
    const query = buildPatronSearchQuery(term, {
135
        search_type: "contains",
136
    });
137
138
    const params = new URLSearchParams({
139
        q: JSON.stringify(query), // Send the query as a JSON string
140
        _page: String(page),
141
        _per_page: "10", // Limit results per page
142
        _order_by: "surname,firstname",
143
    });
144
145
    const response = await fetch(`/api/v1/patrons?${params.toString()}`, {
146
        headers: {
147
            "x-koha-embed": "library",
148
            Accept: "application/json",
149
        },
150
    });
151
152
    if (!response.ok) {
153
        const error = bookingValidation.validationError(
154
            "fetch_patrons_failed",
155
            {
156
                status: response.status,
157
                statusText: response.statusText,
158
            }
159
        );
160
161
        try {
162
            const errorData = await response.json();
163
            if (errorData.error) {
164
                error.message += ` - ${errorData.error}`;
165
            }
166
        } catch (e) {}
167
168
        throw error;
169
    }
170
171
    return await response.json();
172
}
173
174
/**
175
 * Fetches pickup locations for a biblionumber, optionally filtered by patron
176
 * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for
177
 * @param {number|string|null} [patronId] - Optional patron ID to filter pickup locations
178
 * @returns {Promise<Array<Object>>} Array of pickup location objects
179
 * @throws {Error} If the request fails or returns a non-OK status
180
 */
181
export async function fetchPickupLocations(biblionumber, patronId) {
182
    if (!biblionumber) {
183
        throw bookingValidation.validationError("biblionumber_required");
184
    }
185
186
    const params = new URLSearchParams({
187
        _order_by: "name",
188
        _per_page: "-1",
189
    });
190
191
    if (patronId) {
192
        params.append("patron_id", String(patronId));
193
    }
194
195
    const response = await fetch(
196
        `/api/v1/biblios/${encodeURIComponent(
197
            biblionumber
198
        )}/pickup_locations?${params.toString()}`
199
    );
200
201
    if (!response.ok) {
202
        throw bookingValidation.validationError(
203
            "fetch_pickup_locations_failed",
204
            {
205
                status: response.status,
206
                statusText: response.statusText,
207
            }
208
        );
209
    }
210
211
    return await response.json();
212
}
213
214
/**
215
 * Fetches circulation rules based on the provided context parameters
216
 * Now uses the enhanced circulation_rules endpoint with date calculation capabilities
217
 * @param {Object} [params={}] - Context parameters for circulation rules
218
 * @param {string|number} [params.patron_category_id] - Patron category ID
219
 * @param {string|number} [params.item_type_id] - Item type ID
220
 * @param {string|number} [params.library_id] - Library ID
221
 * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules)
222
 * @returns {Promise<Object>} Object containing circulation rules with calculated dates
223
 * @throws {Error} If the request fails or returns a non-OK status
224
 */
225
export async function fetchCirculationRules(params = {}) {
226
    // Only include defined (non-null, non-undefined, non-empty) params
227
    const filteredParams = {};
228
    for (const key in params) {
229
        if (
230
            params[key] !== null &&
231
            params[key] !== undefined &&
232
            params[key] !== ""
233
        ) {
234
            filteredParams[key] = params[key];
235
        }
236
    }
237
238
    // Default to booking rules unless specified
239
    if (!filteredParams.rules) {
240
        filteredParams.rules =
241
            "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod";
242
    }
243
244
    const urlParams = new URLSearchParams();
245
    Object.entries(filteredParams).forEach(([k, v]) => {
246
        if (v === undefined || v === null) return;
247
        urlParams.set(k, String(v));
248
    });
249
250
    const response = await fetch(
251
        `/api/v1/circulation_rules?${urlParams.toString()}`
252
    );
253
254
    if (!response.ok) {
255
        throw bookingValidation.validationError(
256
            "fetch_circulation_rules_failed",
257
            {
258
                status: response.status,
259
                statusText: response.statusText,
260
            }
261
        );
262
    }
263
264
    return await response.json();
265
}
266
267
/**
268
 * Creates a new booking
269
 * @param {Object} bookingData - The booking data to create
270
 * @param {string} bookingData.start_date - Start date of the booking (ISO 8601 format)
271
 * @param {string} bookingData.end_date - End date of the booking (ISO 8601 format)
272
 * @param {number|string} bookingData.biblio_id - Biblionumber for the booking
273
 * @param {number|string} [bookingData.item_id] - Optional item ID for the booking
274
 * @param {number|string} bookingData.patron_id - Patron ID for the booking
275
 * @param {number|string} bookingData.pickup_library_id - Pickup library ID
276
 * @returns {Promise<Object>} The created booking object
277
 * @throws {Error} If the request fails or returns a non-OK status
278
 */
279
export async function createBooking(bookingData) {
280
    if (!bookingData) {
281
        throw bookingValidation.validationError("booking_data_required");
282
    }
283
284
    const validationError = bookingValidation.validateRequiredFields(
285
        bookingData,
286
        [
287
            "start_date",
288
            "end_date",
289
            "biblio_id",
290
            "patron_id",
291
            "pickup_library_id",
292
        ]
293
    );
294
295
    if (validationError) {
296
        throw validationError;
297
    }
298
299
    const response = await fetch("/api/v1/bookings", {
300
        method: "POST",
301
        headers: {
302
            "Content-Type": "application/json",
303
            Accept: "application/json",
304
        },
305
        body: JSON.stringify(bookingData),
306
    });
307
308
    if (!response.ok) {
309
        let errorMessage = bookingValidation.validationError(
310
            "create_booking_failed",
311
            {
312
                status: response.status,
313
                statusText: response.statusText,
314
            }
315
        ).message;
316
        try {
317
            const errorData = await response.json();
318
            if (errorData.error) {
319
                errorMessage += ` - ${errorData.error}`;
320
            }
321
        } catch (e) {}
322
        /** @type {Error & { status?: number }} */
323
        const error = Object.assign(new Error(errorMessage), {
324
            status: response.status,
325
        });
326
        throw error;
327
    }
328
329
    return await response.json();
330
}
331
332
/**
333
 * Updates an existing booking
334
 * @param {number|string} bookingId - The ID of the booking to update
335
 * @param {Object} bookingData - The updated booking data
336
 * @param {string} [bookingData.start_date] - New start date (ISO 8601 format)
337
 * @param {string} [bookingData.end_date] - New end date (ISO 8601 format)
338
 * @param {number|string} [bookingData.pickup_library_id] - New pickup library ID
339
 * @param {number|string} [bookingData.item_id] - New item ID (if changing the item)
340
 * @returns {Promise<Object>} The updated booking object
341
 * @throws {Error} If the request fails or returns a non-OK status
342
 */
343
export async function updateBooking(bookingId, bookingData) {
344
    if (!bookingId) {
345
        throw bookingValidation.validationError("booking_id_required");
346
    }
347
348
    if (!bookingData || Object.keys(bookingData).length === 0) {
349
        throw bookingValidation.validationError("no_update_data");
350
    }
351
352
    const response = await fetch(
353
        `/api/v1/bookings/${encodeURIComponent(bookingId)}`,
354
        {
355
            method: "PUT",
356
            headers: {
357
                "Content-Type": "application/json",
358
                Accept: "application/json",
359
            },
360
            body: JSON.stringify({ ...bookingData, booking_id: bookingId }),
361
        }
362
    );
363
364
    if (!response.ok) {
365
        let errorMessage = bookingValidation.validationError(
366
            "update_booking_failed",
367
            {
368
                status: response.status,
369
                statusText: response.statusText,
370
            }
371
        ).message;
372
        try {
373
            const errorData = await response.json();
374
            if (errorData.error) {
375
                errorMessage += ` - ${errorData.error}`;
376
            }
377
        } catch (e) {}
378
        /** @type {Error & { status?: number }} */
379
        const error = Object.assign(new Error(errorMessage), {
380
            status: response.status,
381
        });
382
        throw error;
383
    }
384
385
    return await response.json();
386
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar.mjs (+726 lines)
Line 0 Link Here
1
import {
2
    handleBookingDateChange,
3
    getBookingMarkersForDate,
4
    calculateConstraintHighlighting,
5
    getCalendarNavigationTarget,
6
    aggregateMarkersByType,
7
    deriveEffectiveRules,
8
} from "../booking/manager.mjs";
9
import { toISO, formatYMD, toDayjs, startOfDayTs } from "../booking/date-utils.mjs";
10
import { calendarLogger as logger } from "../booking/logger.mjs";
11
import {
12
    CONSTRAINT_MODE_END_DATE_ONLY,
13
    CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
14
    CLASS_BOOKING_DAY_HOVER_LEAD,
15
    CLASS_BOOKING_DAY_HOVER_TRAIL,
16
    CLASS_BOOKING_INTERMEDIATE_BLOCKED,
17
    CLASS_BOOKING_MARKER_COUNT,
18
    CLASS_BOOKING_MARKER_DOT,
19
    CLASS_BOOKING_MARKER_GRID,
20
    CLASS_BOOKING_MARKER_ITEM,
21
    CLASS_BOOKING_OVERRIDE_ALLOWED,
22
    CLASS_FLATPICKR_DAY,
23
    CLASS_FLATPICKR_DISABLED,
24
    CLASS_FLATPICKR_NOT_ALLOWED,
25
    CLASS_BOOKING_LOAN_BOUNDARY,
26
    DATA_ATTRIBUTE_BOOKING_OVERRIDE,
27
} from "../booking/constants.mjs";
28
29
/**
30
 * Clear constraint highlighting from the Flatpickr calendar.
31
 *
32
 * @param {import('flatpickr/dist/types/instance').Instance} instance
33
 * @returns {void}
34
 */
35
export function clearCalendarHighlighting(instance) {
36
    logger.debug("Clearing calendar highlighting");
37
38
    if (!instance || !instance.calendarContainer) return;
39
40
    // Query separately to accommodate simple test DOM mocks
41
    const lists = [
42
        instance.calendarContainer.querySelectorAll(
43
            `.${CLASS_BOOKING_CONSTRAINED_RANGE_MARKER}`
44
        ),
45
        instance.calendarContainer.querySelectorAll(
46
            `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}`
47
        ),
48
        instance.calendarContainer.querySelectorAll(
49
            `.${CLASS_BOOKING_LOAN_BOUNDARY}`
50
        ),
51
    ];
52
    const existingHighlights = lists.flatMap(list => Array.from(list || []));
53
    existingHighlights.forEach(elem => {
54
        elem.classList.remove(
55
            CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
56
            CLASS_BOOKING_INTERMEDIATE_BLOCKED,
57
            CLASS_BOOKING_LOAN_BOUNDARY
58
        );
59
    });
60
}
61
62
/**
63
 * Apply constraint highlighting to the Flatpickr calendar.
64
 *
65
 * @param {import('flatpickr/dist/types/instance').Instance} instance
66
 * @param {import('../../types/bookings').ConstraintHighlighting} highlightingData
67
 * @returns {void}
68
 */
69
export function applyCalendarHighlighting(instance, highlightingData) {
70
    if (!instance || !instance.calendarContainer || !highlightingData) {
71
        logger.debug("Missing requirements", {
72
            hasInstance: !!instance,
73
            hasContainer: !!instance?.calendarContainer,
74
            hasData: !!highlightingData,
75
        });
76
        return;
77
    }
78
79
    // Cache highlighting data for re-application after navigation
80
    const instWithCache =
81
        /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../types/bookings').ConstraintHighlighting | null }} */ (
82
            instance
83
        );
84
    instWithCache._constraintHighlighting = highlightingData;
85
86
    clearCalendarHighlighting(instance);
87
88
    const applyHighlighting = (retryCount = 0) => {
89
        if (retryCount === 0) {
90
            logger.group("applyCalendarHighlighting");
91
        }
92
        const dayElements = instance.calendarContainer.querySelectorAll(
93
            `.${CLASS_FLATPICKR_DAY}`
94
        );
95
96
        if (dayElements.length === 0 && retryCount < 5) {
97
            logger.debug(`No day elements found, retry ${retryCount + 1}`);
98
            requestAnimationFrame(() => applyHighlighting(retryCount + 1));
99
            return;
100
        }
101
102
        let highlightedCount = 0;
103
        let blockedCount = 0;
104
105
        // Preload loan boundary times cached on instance (if present)
106
        const instWithCacheForBoundary =
107
            /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set<number> }} */ (
108
                instance
109
            );
110
        const boundaryTimes = instWithCacheForBoundary?._loanBoundaryTimes;
111
112
        dayElements.forEach(dayElem => {
113
            if (!dayElem.dateObj) return;
114
115
            const dayTime = dayElem.dateObj.getTime();
116
            const startTime = highlightingData.startDate.getTime();
117
            const targetTime = highlightingData.targetEndDate.getTime();
118
119
            // Apply bold styling to loan period boundary dates
120
            if (boundaryTimes && boundaryTimes.has(dayTime)) {
121
                dayElem.classList.add(CLASS_BOOKING_LOAN_BOUNDARY);
122
            }
123
124
            if (dayTime >= startTime && dayTime <= targetTime) {
125
                if (
126
                    highlightingData.constraintMode ===
127
                    CONSTRAINT_MODE_END_DATE_ONLY
128
                ) {
129
                    const isBlocked =
130
                        highlightingData.blockedIntermediateDates.some(
131
                            blockedDate => dayTime === blockedDate.getTime()
132
                        );
133
134
                    if (isBlocked) {
135
                        if (
136
                            !dayElem.classList.contains(
137
                                CLASS_FLATPICKR_DISABLED
138
                            )
139
                        ) {
140
                            dayElem.classList.add(
141
                                CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
142
                                CLASS_BOOKING_INTERMEDIATE_BLOCKED
143
                            );
144
                            blockedCount++;
145
                        }
146
                    } else {
147
                        if (
148
                            !dayElem.classList.contains(
149
                                CLASS_FLATPICKR_DISABLED
150
                            )
151
                        ) {
152
                            dayElem.classList.add(
153
                                CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
154
                            );
155
                            highlightedCount++;
156
                        }
157
                    }
158
                } else {
159
                    if (!dayElem.classList.contains(CLASS_FLATPICKR_DISABLED)) {
160
                        dayElem.classList.add(
161
                            CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
162
                        );
163
                        highlightedCount++;
164
                    }
165
                }
166
            }
167
        });
168
169
        logger.debug("Highlighting applied", {
170
            highlightedCount,
171
            blockedCount,
172
            retryCount,
173
            constraintMode: highlightingData.constraintMode,
174
        });
175
176
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
177
            applyClickPrevention(instance);
178
            fixTargetEndDateAvailability(
179
                instance,
180
                dayElements,
181
                highlightingData.targetEndDate
182
            );
183
184
            const targetEndElem = Array.from(dayElements).find(
185
                elem =>
186
                    elem.dateObj &&
187
                    elem.dateObj.getTime() ===
188
                        highlightingData.targetEndDate.getTime()
189
            );
190
            if (
191
                targetEndElem &&
192
                !targetEndElem.classList.contains(CLASS_FLATPICKR_DISABLED)
193
            ) {
194
                targetEndElem.classList.add(
195
                    CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
196
                );
197
                logger.debug(
198
                    "Re-applied highlighting to target end date after availability fix"
199
                );
200
            }
201
        }
202
203
        logger.groupEnd();
204
    };
205
206
    requestAnimationFrame(() => applyHighlighting());
207
}
208
209
/**
210
 * Fix incorrect target-end unavailability via a CSS-based override.
211
 *
212
 * @param {import('flatpickr/dist/types/instance').Instance} _instance
213
 * @param {NodeListOf<Element>|Element[]} dayElements
214
 * @param {Date} targetEndDate
215
 * @returns {void}
216
 */
217
function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) {
218
    if (!dayElements || typeof dayElements.length !== "number") {
219
        logger.warn(
220
            "Invalid dayElements passed to fixTargetEndDateAvailability",
221
            dayElements
222
        );
223
        return;
224
    }
225
226
    const targetEndElem = Array.from(dayElements).find(
227
        elem =>
228
            elem.dateObj && elem.dateObj.getTime() === targetEndDate.getTime()
229
    );
230
231
    if (!targetEndElem) {
232
        logger.warn("Target end date element not found", targetEndDate);
233
        return;
234
    }
235
236
    // Mark the element as explicitly allowed, overriding Flatpickr's styles
237
    targetEndElem.classList.remove(CLASS_FLATPICKR_NOT_ALLOWED);
238
    targetEndElem.removeAttribute("tabindex");
239
    targetEndElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED);
240
241
    targetEndElem.setAttribute(DATA_ATTRIBUTE_BOOKING_OVERRIDE, "allowed");
242
243
    logger.debug("Applied CSS override for target end date availability", {
244
        targetDate: targetEndDate,
245
        element: targetEndElem,
246
    });
247
248
    if (targetEndElem.classList.contains(CLASS_FLATPICKR_DISABLED)) {
249
        targetEndElem.classList.remove(
250
            CLASS_FLATPICKR_DISABLED,
251
            CLASS_FLATPICKR_NOT_ALLOWED
252
        );
253
        targetEndElem.removeAttribute("tabindex");
254
        targetEndElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED);
255
256
        logger.debug("Applied fix for target end date availability", {
257
            finalClasses: Array.from(targetEndElem.classList),
258
        });
259
    }
260
}
261
262
/**
263
 * Apply click prevention for intermediate dates in end_date_only mode.
264
 *
265
 * @param {import('flatpickr/dist/types/instance').Instance} instance
266
 * @returns {void}
267
 */
268
function applyClickPrevention(instance) {
269
    if (!instance || !instance.calendarContainer) return;
270
271
    const blockedElements = instance.calendarContainer.querySelectorAll(
272
        `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}`
273
    );
274
    blockedElements.forEach(elem => {
275
        elem.removeEventListener("click", preventClick, { capture: true });
276
        elem.addEventListener("click", preventClick, { capture: true });
277
    });
278
}
279
280
/** Click prevention handler. */
281
function preventClick(e) {
282
    e.preventDefault();
283
    e.stopPropagation();
284
    return false;
285
}
286
287
/**
288
 * Get the current language code from the HTML lang attribute
289
 *
290
 * @returns {string}
291
 */
292
export function getCurrentLanguageCode() {
293
    const htmlLang = document.documentElement.lang || "en";
294
    return htmlLang.split("-")[0].toLowerCase();
295
}
296
297
/**
298
 * Pre-load flatpickr locale based on current language
299
 * This should ideally be called once when the page loads
300
 *
301
 * @returns {Promise<void>}
302
 */
303
export async function preloadFlatpickrLocale() {
304
    const langCode = getCurrentLanguageCode();
305
306
    if (langCode === "en") {
307
        return;
308
    }
309
310
    try {
311
        await import(`flatpickr/dist/l10n/${langCode}.js`);
312
    } catch (e) {
313
        console.warn(
314
            `Flatpickr locale for '${langCode}' not found, will use fallback translations`
315
        );
316
    }
317
}
318
319
/**
320
 * Create a Flatpickr `onChange` handler bound to the booking store.
321
 *
322
 * @param {object} store - Booking Pinia store (or compatible shape)
323
 * @param {import('../../types/bookings').OnChangeOptions} options
324
 */
325
export function createOnChange(
326
    store,
327
    { setError = null, tooltipVisibleRef = null, constraintOptions = {} } = {}
328
) {
329
    // Allow tests to stub globals; fall back to imported functions
330
    const _getVisibleCalendarDates =
331
        globalThis.getVisibleCalendarDates || getVisibleCalendarDates;
332
    const _calculateConstraintHighlighting =
333
        globalThis.calculateConstraintHighlighting ||
334
        calculateConstraintHighlighting;
335
    const _handleBookingDateChange =
336
        globalThis.handleBookingDateChange || handleBookingDateChange;
337
    const _getCalendarNavigationTarget =
338
        globalThis.getCalendarNavigationTarget || getCalendarNavigationTarget;
339
340
    return function (selectedDates, _dateStr, instance) {
341
        logger.debug("handleDateChange triggered", { selectedDates });
342
343
        const validDates = (selectedDates || []).filter(
344
            d => d instanceof Date && !Number.isNaN(d.getTime())
345
        );
346
        // clear any existing error and sync the store, but skip validation.
347
        if ((selectedDates || []).length === 0) {
348
            // Clear cached loan boundaries when clearing selection
349
            if (instance) {
350
                const instWithCache =
351
                    /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set<number> }} */ (
352
                        instance
353
                    );
354
                delete instWithCache._loanBoundaryTimes;
355
            }
356
            if (
357
                Array.isArray(store.selectedDateRange) &&
358
                store.selectedDateRange.length
359
            ) {
360
                store.selectedDateRange = [];
361
            }
362
            if (typeof setError === "function") setError("");
363
            return;
364
        }
365
        if ((selectedDates || []).length > 0 && validDates.length === 0) {
366
            logger.warn(
367
                "All dates invalid, skipping processing to preserve state"
368
            );
369
            return;
370
        }
371
372
        const isoDateRange = validDates.map(d => toISO(d));
373
        const current = store.selectedDateRange || [];
374
        const same =
375
            current.length === isoDateRange.length &&
376
            current.every((v, i) => v === isoDateRange[i]);
377
        if (!same) store.selectedDateRange = isoDateRange;
378
379
        const baseRules =
380
            (store.circulationRules && store.circulationRules[0]) || {};
381
        const effectiveRules = deriveEffectiveRules(
382
            baseRules,
383
            constraintOptions
384
        );
385
386
        // Compute loan boundary times (end of initial loan and renewals) and cache on instance
387
        try {
388
            if (instance && validDates.length > 0) {
389
                const instWithCache =
390
                    /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set<number> }} */ (
391
                        instance
392
                    );
393
                const startDate = toDayjs(validDates[0]).startOf("day");
394
                const issuelength = parseInt(baseRules?.issuelength) || 0;
395
                const renewalperiod = parseInt(baseRules?.renewalperiod) || 0;
396
                const renewalsallowed =
397
                    parseInt(baseRules?.renewalsallowed) || 0;
398
                const times = new Set();
399
                if (issuelength > 0) {
400
                    // End aligns with due date semantics: start + issuelength days
401
                    const initialEnd = startDate
402
                        .add(issuelength, "day")
403
                        .toDate()
404
                        .getTime();
405
                    times.add(initialEnd);
406
                    if (renewalperiod > 0 && renewalsallowed > 0) {
407
                        for (let k = 1; k <= renewalsallowed; k++) {
408
                            const t = startDate
409
                                .add(issuelength + k * renewalperiod, "day")
410
                                .toDate()
411
                                .getTime();
412
                            times.add(t);
413
                        }
414
                    }
415
                }
416
                instWithCache._loanBoundaryTimes = times;
417
            }
418
        } catch (e) {
419
            // non-fatal: boundary decoration best-effort
420
        }
421
422
        let calcOptions = {};
423
        if (instance) {
424
            const visible = _getVisibleCalendarDates(instance);
425
            if (visible && visible.length > 0) {
426
                calcOptions = {
427
                    onDemand: true,
428
                    visibleStartDate: visible[0],
429
                    visibleEndDate: visible[visible.length - 1],
430
                };
431
            }
432
        }
433
434
        const result = _handleBookingDateChange(
435
            selectedDates,
436
            effectiveRules,
437
            store.bookings,
438
            store.checkouts,
439
            store.bookableItems,
440
            store.bookingItemId,
441
            store.bookingId,
442
            undefined,
443
            calcOptions
444
        );
445
446
        if (typeof setError === "function") {
447
            // Support multiple result shapes from handler (backward compatibility for tests)
448
            const isValid =
449
                (result && Object.prototype.hasOwnProperty.call(result, "valid")
450
                    ? result.valid
451
                    : result?.isValid) ?? true;
452
453
            let message = "";
454
            if (!isValid) {
455
                if (Array.isArray(result?.errors)) {
456
                    message = result.errors.join(", ");
457
                } else if (typeof result?.errorMessage === "string") {
458
                    message = result.errorMessage;
459
                } else if (result?.errorMessage != null) {
460
                    message = String(result.errorMessage);
461
                } else if (result?.errors != null) {
462
                    message = String(result.errors);
463
                }
464
            }
465
            setError(message);
466
        }
467
        if (tooltipVisibleRef && "value" in tooltipVisibleRef) {
468
            tooltipVisibleRef.value = false;
469
        }
470
471
        if (instance) {
472
            if (selectedDates.length === 1) {
473
                const highlightingData = _calculateConstraintHighlighting(
474
                    selectedDates[0],
475
                    effectiveRules,
476
                    constraintOptions
477
                );
478
                if (highlightingData) {
479
                    applyCalendarHighlighting(instance, highlightingData);
480
                    // Compute current visible date range for smarter navigation
481
                    const visible = _getVisibleCalendarDates(instance);
482
                    const currentView =
483
                        visible && visible.length > 0
484
                            ? {
485
                                  visibleStartDate: visible[0],
486
                                  visibleEndDate: visible[visible.length - 1],
487
                              }
488
                            : {};
489
                    const nav = _getCalendarNavigationTarget(
490
                        highlightingData.startDate,
491
                        highlightingData.targetEndDate,
492
                        currentView
493
                    );
494
                    if (nav.shouldNavigate && nav.targetDate) {
495
                        setTimeout(() => {
496
                            if (instance.jumpToDate) {
497
                                instance.jumpToDate(nav.targetDate);
498
                            } else if (instance.changeMonth) {
499
                                // Fallback for older flatpickr builds: first ensure year, then adjust month absolutely
500
                                if (
501
                                    typeof instance.changeYear === "function" &&
502
                                    typeof nav.targetYear === "number" &&
503
                                    instance.currentYear !== nav.targetYear
504
                                ) {
505
                                    instance.changeYear(nav.targetYear);
506
                                }
507
                                const offset =
508
                                    typeof instance.currentMonth === "number"
509
                                        ? nav.targetMonth -
510
                                          instance.currentMonth
511
                                        : 0;
512
                                instance.changeMonth(offset, false);
513
                            }
514
                        }, 100);
515
                    }
516
                }
517
            }
518
            if (selectedDates.length === 0) {
519
                const instWithCache =
520
                    /** @type {import('../../types/bookings').FlatpickrInstanceWithHighlighting} */ (
521
                        instance
522
                    );
523
                instWithCache._constraintHighlighting = null;
524
                clearCalendarHighlighting(instance);
525
            }
526
        }
527
    };
528
}
529
530
/**
531
 * Create Flatpickr `onDayCreate` handler.
532
 *
533
 * Renders per-day marker dots, hover classes, and shows a tooltip with
534
 * aggregated markers. Reapplies constraint highlighting across month
535
 * navigation using the instance's cached highlighting data.
536
 *
537
 * @param {object} store - booking store or compatible state
538
 * @param {import('../../types/bookings').RefLike<import('../../types/bookings').CalendarMarker[]>} tooltipMarkers - ref of markers shown in tooltip
539
 * @param {import('../../types/bookings').RefLike<boolean>} tooltipVisible - visibility ref for tooltip
540
 * @param {import('../../types/bookings').RefLike<number>} tooltipX - x position ref
541
 * @param {import('../../types/bookings').RefLike<number>} tooltipY - y position ref
542
 * @returns {import('flatpickr/dist/types/options').Hook}
543
 */
544
export function createOnDayCreate(
545
    store,
546
    tooltipMarkers,
547
    tooltipVisible,
548
    tooltipX,
549
    tooltipY
550
) {
551
    return function (
552
        ...[
553
            ,
554
            ,
555
            /** @type {import('flatpickr/dist/types/instance').Instance} */ fp,
556
            /** @type {import('flatpickr/dist/types/instance').DayElement} */ dayElem,
557
        ]
558
    ) {
559
        const existingGrids = dayElem.querySelectorAll(
560
            `.${CLASS_BOOKING_MARKER_GRID}`
561
        );
562
        existingGrids.forEach(grid => grid.remove());
563
564
        const el =
565
            /** @type {import('flatpickr/dist/types/instance').DayElement} */ (
566
                dayElem
567
            );
568
        const dateStrForMarker = formatYMD(el.dateObj);
569
        const markersForDots = getBookingMarkersForDate(
570
            store.unavailableByDate,
571
            dateStrForMarker,
572
            store.bookableItems
573
        );
574
575
        if (markersForDots.length > 0) {
576
            const aggregatedMarkers = aggregateMarkersByType(markersForDots);
577
            const grid = buildMarkerGrid(aggregatedMarkers);
578
            if (grid.hasChildNodes()) dayElem.appendChild(grid);
579
        }
580
581
        // Existing tooltip mouseover logic - DO NOT CHANGE unless necessary for aggregation
582
        dayElem.addEventListener("mouseover", () => {
583
            const hoveredDateStr = formatYMD(el.dateObj);
584
            const currentTooltipMarkersData = getBookingMarkersForDate(
585
                store.unavailableByDate,
586
                hoveredDateStr,
587
                store.bookableItems
588
            );
589
590
            el.classList.remove(
591
                CLASS_BOOKING_DAY_HOVER_LEAD,
592
                CLASS_BOOKING_DAY_HOVER_TRAIL
593
            ); // Clear first
594
            let hasLeadMarker = false;
595
            let hasTrailMarker = false;
596
597
            currentTooltipMarkersData.forEach(marker => {
598
                if (marker.type === "lead") hasLeadMarker = true;
599
                if (marker.type === "trail") hasTrailMarker = true;
600
            });
601
602
            if (hasLeadMarker) {
603
                el.classList.add(CLASS_BOOKING_DAY_HOVER_LEAD);
604
            }
605
            if (hasTrailMarker) {
606
                el.classList.add(CLASS_BOOKING_DAY_HOVER_TRAIL);
607
            }
608
609
            if (currentTooltipMarkersData.length > 0) {
610
                tooltipMarkers.value = currentTooltipMarkersData;
611
                tooltipVisible.value = true;
612
613
                const rect = el.getBoundingClientRect();
614
                tooltipX.value = rect.left + window.scrollX + rect.width / 2;
615
                tooltipY.value = rect.top + window.scrollY - 10; // Adjust Y to be above the cell
616
            } else {
617
                tooltipMarkers.value = [];
618
                tooltipVisible.value = false;
619
            }
620
        });
621
622
        dayElem.addEventListener("mouseout", () => {
623
            dayElem.classList.remove(
624
                CLASS_BOOKING_DAY_HOVER_LEAD,
625
                CLASS_BOOKING_DAY_HOVER_TRAIL
626
            );
627
            tooltipVisible.value = false; // Hide tooltip when mouse leaves the day cell
628
        });
629
630
        // Reapply constraint highlighting if it exists (for month navigation, etc.)
631
        const fpWithCache =
632
            /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../types/bookings').ConstraintHighlighting | null }} */ (
633
                fp
634
            );
635
        if (
636
            fpWithCache &&
637
            fpWithCache._constraintHighlighting &&
638
            fpWithCache.calendarContainer
639
        ) {
640
            requestAnimationFrame(() => {
641
                applyCalendarHighlighting(
642
                    fpWithCache,
643
                    fpWithCache._constraintHighlighting
644
                );
645
            });
646
        }
647
    };
648
}
649
650
/**
651
 * Create Flatpickr `onClose` handler to clear tooltip state.
652
 * @param {import('../../types/bookings').RefLike<import('../../types/bookings').CalendarMarker[]>} tooltipMarkers
653
 * @param {import('../../types/bookings').RefLike<boolean>} tooltipVisible
654
 */
655
export function createOnClose(tooltipMarkers, tooltipVisible) {
656
    return function () {
657
        tooltipMarkers.value = [];
658
        tooltipVisible.value = false;
659
    };
660
}
661
662
/**
663
 * Generate all visible dates for the current calendar view.
664
 * UI-level helper; belongs with calendar DOM logic.
665
 *
666
 * @param {import('../../types/bookings').FlatpickrInstanceWithHighlighting} flatpickrInstance - Flatpickr instance
667
 * @returns {Date[]} Array of Date objects
668
 */
669
export function getVisibleCalendarDates(flatpickrInstance) {
670
    try {
671
        if (!flatpickrInstance) return [];
672
673
        // Prefer the calendar container; fall back to `.days` if present
674
        const container =
675
            flatpickrInstance.calendarContainer || flatpickrInstance.days;
676
        if (!container || !container.querySelectorAll) return [];
677
678
        const dayNodes = container.querySelectorAll(`.${CLASS_FLATPICKR_DAY}`);
679
        if (!dayNodes || dayNodes.length === 0) return [];
680
681
        // Map visible day elements to normalized Date objects and de-duplicate
682
        const seen = new Set();
683
        const dates = [];
684
        Array.from(dayNodes).forEach(el => {
685
            const d = el && el.dateObj ? el.dateObj : null;
686
            if (!d) return;
687
            const ts = startOfDayTs(d);
688
            if (!seen.has(ts)) {
689
                seen.add(ts);
690
                dates.push(toDayjs(d).startOf("day").toDate());
691
            }
692
        });
693
        return dates;
694
    } catch (e) {
695
        return [];
696
    }
697
}
698
699
/**
700
 * Build the DOM grid for aggregated booking markers.
701
 *
702
 * @param {import('../../types/bookings').MarkerAggregation} aggregatedMarkers - counts by marker type
703
 * @returns {HTMLDivElement} container element with marker items
704
 */
705
export function buildMarkerGrid(aggregatedMarkers) {
706
    const gridContainer = document.createElement("div");
707
    gridContainer.className = CLASS_BOOKING_MARKER_GRID;
708
    Object.entries(aggregatedMarkers).forEach(([type, count]) => {
709
        const markerSpan = document.createElement("span");
710
        markerSpan.className = CLASS_BOOKING_MARKER_ITEM;
711
712
        const dot = document.createElement("span");
713
        dot.className = `${CLASS_BOOKING_MARKER_DOT} ${CLASS_BOOKING_MARKER_DOT}--${type}`;
714
        dot.title = type.charAt(0).toUpperCase() + type.slice(1);
715
        markerSpan.appendChild(dot);
716
717
        if (count > 0) {
718
            const countSpan = document.createElement("span");
719
            countSpan.className = CLASS_BOOKING_MARKER_COUNT;
720
            countSpan.textContent = ` ${count}`;
721
            markerSpan.appendChild(countSpan);
722
        }
723
        gridContainer.appendChild(markerSpan);
724
    });
725
    return gridContainer;
726
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs (+242 lines)
Line 0 Link Here
1
import dayjs from "../../../../utils/dayjs.mjs";
2
import { win } from "./globals.mjs";
3
4
/** @typedef {import('../../types/bookings').ExternalDependencies} ExternalDependencies */
5
6
/**
7
 * Debounce utility with simple trailing invocation.
8
 *
9
 * @param {(...args:any[]) => any} fn
10
 * @param {number} delay
11
 * @returns {(...args:any[]) => void}
12
 */
13
export function debounce(fn, delay) {
14
    let timeout;
15
    return function (...args) {
16
        clearTimeout(timeout);
17
        timeout = setTimeout(() => fn.apply(this, args), delay);
18
    };
19
}
20
21
/**
22
 * Default dependencies for external updates - can be overridden in tests
23
 * @type {ExternalDependencies}
24
 */
25
const defaultDependencies = {
26
    timeline: () => win("timeline"),
27
    bookingsTable: () => win("bookings_table"),
28
    patronRenderer: () => win("$patron_to_html"),
29
    domQuery: selector => document.querySelectorAll(selector),
30
    logger: {
31
        warn: (msg, data) => console.warn(msg, data),
32
        error: (msg, error) => console.error(msg, error),
33
    },
34
};
35
36
/**
37
 * Renders patron content for display, with injected dependency
38
 *
39
 * @param {{ cardnumber?: string }|null} bookingPatron
40
 * @param {ExternalDependencies} [dependencies=defaultDependencies]
41
 * @returns {string}
42
 */
43
function renderPatronContent(
44
    bookingPatron,
45
    dependencies = defaultDependencies
46
) {
47
    try {
48
        const patronRenderer = dependencies.patronRenderer();
49
        if (typeof patronRenderer === "function" && bookingPatron) {
50
            return patronRenderer(bookingPatron, {
51
                display_cardnumber: true,
52
                url: true,
53
            });
54
        }
55
56
        if (bookingPatron?.cardnumber) {
57
            return bookingPatron.cardnumber;
58
        }
59
60
        return "";
61
    } catch (error) {
62
        dependencies.logger.error("Failed to render patron content", {
63
            error,
64
            bookingPatron,
65
        });
66
        return bookingPatron?.cardnumber || "";
67
    }
68
}
69
70
/**
71
 * Updates timeline component with booking data
72
 *
73
 * @param {import('../../types/bookings').Booking} newBooking
74
 * @param {{ cardnumber?: string }|null} bookingPatron
75
 * @param {boolean} isUpdate
76
 * @param {ExternalDependencies} dependencies
77
 * @returns {{ success: boolean, reason?: string }}
78
 */
79
function updateTimelineComponent(
80
    newBooking,
81
    bookingPatron,
82
    isUpdate,
83
    dependencies
84
) {
85
    const timeline = dependencies.timeline();
86
    if (!timeline) return { success: false, reason: "Timeline not available" };
87
88
    try {
89
        const itemData = {
90
            id: newBooking.booking_id,
91
            booking: newBooking.booking_id,
92
            patron: newBooking.patron_id,
93
            start: dayjs(newBooking.start_date).toDate(),
94
            end: dayjs(newBooking.end_date).toDate(),
95
            content: renderPatronContent(bookingPatron, dependencies),
96
            type: "range",
97
            group: newBooking.item_id ? newBooking.item_id : 0,
98
        };
99
100
        if (isUpdate) {
101
            timeline.itemsData.update(itemData);
102
        } else {
103
            timeline.itemsData.add(itemData);
104
        }
105
        timeline.focus(newBooking.booking_id);
106
107
        return { success: true };
108
    } catch (error) {
109
        dependencies.logger.error("Failed to update timeline", {
110
            error,
111
            newBooking,
112
        });
113
        return { success: false, reason: error.message };
114
    }
115
}
116
117
/**
118
 * Updates bookings table component
119
 *
120
 * @param {ExternalDependencies} dependencies
121
 * @returns {{ success: boolean, reason?: string }}
122
 */
123
function updateBookingsTable(dependencies) {
124
    const bookingsTable = dependencies.bookingsTable();
125
    if (!bookingsTable)
126
        return { success: false, reason: "Bookings table not available" };
127
128
    try {
129
        bookingsTable.api().ajax.reload();
130
        return { success: true };
131
    } catch (error) {
132
        dependencies.logger.error("Failed to update bookings table", { error });
133
        return { success: false, reason: error.message };
134
    }
135
}
136
137
/**
138
 * Updates booking count elements in the DOM
139
 *
140
 * @param {boolean} isUpdate
141
 * @param {ExternalDependencies} dependencies
142
 * @returns {{ success: boolean, reason?: string, updatedElements?: number, totalElements?: number }}
143
 */
144
function updateBookingCounts(isUpdate, dependencies) {
145
    if (isUpdate)
146
        return { success: true, reason: "No count update needed for updates" };
147
148
    try {
149
        const countEls = dependencies.domQuery(".bookings_count");
150
        let updatedCount = 0;
151
152
        countEls.forEach(el => {
153
            const html = el.innerHTML;
154
            const match = html.match(/(\d+)/);
155
            if (match) {
156
                const newCount = parseInt(match[1], 10) + 1;
157
                el.innerHTML = html.replace(/(\d+)/, String(newCount));
158
                updatedCount++;
159
            }
160
        });
161
162
        return {
163
            success: true,
164
            updatedElements: updatedCount,
165
            totalElements: countEls.length,
166
        };
167
    } catch (error) {
168
        dependencies.logger.error("Failed to update booking counts", { error });
169
        return { success: false, reason: error.message };
170
    }
171
}
172
173
/**
174
 * Updates external components that depend on booking data
175
 *
176
 * This function is designed with dependency injection to make it testable
177
 * and to provide proper error handling with detailed feedback.
178
 *
179
 * @param {import('../../types/bookings').Booking} newBooking - The booking data that was created/updated
180
 * @param {{ cardnumber?: string }|null} bookingPatron - The patron data for rendering
181
 * @param {boolean} isUpdate - Whether this is an update (true) or create (false)
182
 * @param {ExternalDependencies} dependencies - Injectable dependencies (for testing)
183
 * @returns {Record<string, { attempted: boolean, success?: boolean, reason?: string }>} Results summary with success/failure details
184
 */
185
export function updateExternalDependents(
186
    newBooking,
187
    bookingPatron,
188
    isUpdate = false,
189
    dependencies = defaultDependencies
190
) {
191
    const results = {
192
        timeline: { attempted: false },
193
        bookingsTable: { attempted: false },
194
        bookingCounts: { attempted: false },
195
    };
196
197
    // Update timeline if available
198
    if (dependencies.timeline()) {
199
        results.timeline = {
200
            attempted: true,
201
            ...updateTimelineComponent(
202
                newBooking,
203
                bookingPatron,
204
                isUpdate,
205
                dependencies
206
            ),
207
        };
208
    }
209
210
    // Update bookings table if available
211
    if (dependencies.bookingsTable()) {
212
        results.bookingsTable = {
213
            attempted: true,
214
            ...updateBookingsTable(dependencies),
215
        };
216
    }
217
218
    // Update booking counts
219
    results.bookingCounts = {
220
        attempted: true,
221
        ...updateBookingCounts(isUpdate, dependencies),
222
    };
223
224
    // Log summary for debugging
225
    const successCount = Object.values(results).filter(
226
        r => r.attempted && r.success
227
    ).length;
228
    const attemptedCount = Object.values(results).filter(
229
        r => r.attempted
230
    ).length;
231
232
    dependencies.logger.warn(
233
        `External dependents update complete: ${successCount}/${attemptedCount} successful`,
234
        {
235
            isUpdate,
236
            bookingId: newBooking.booking_id,
237
            results,
238
        }
239
    );
240
241
    return results;
242
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/form.mjs (+17 lines)
Line 0 Link Here
1
/**
2
 * Append hidden input fields to a form from a list of entries.
3
 * Skips undefined/null values.
4
 *
5
 * @param {HTMLFormElement} form
6
 * @param {Array<[string, unknown]>} entries
7
 */
8
export function appendHiddenInputs(form, entries) {
9
    entries.forEach(([name, value]) => {
10
        if (value === undefined || value === null) return;
11
        const input = document.createElement("input");
12
        input.type = "hidden";
13
        input.name = String(name);
14
        input.value = String(value);
15
        form.appendChild(input);
16
    });
17
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/globals.mjs (+27 lines)
Line 0 Link Here
1
/**
2
 * Safe accessors for window-scoped globals using bracket notation
3
 */
4
5
/**
6
 * Get a value from window by key using bracket notation
7
 *
8
 * @param {string} key
9
 * @returns {unknown}
10
 */
11
export function win(key) {
12
    if (typeof window === "undefined") return undefined;
13
    return window[key];
14
}
15
16
/**
17
 * Get a value from window with default initialization
18
 *
19
 * @param {string} key
20
 * @param {unknown} defaultValue
21
 * @returns {unknown}
22
 */
23
export function getWindowValue(key, defaultValue) {
24
    if (typeof window === "undefined") return defaultValue;
25
    if (window[key] === undefined) window[key] = defaultValue;
26
    return window[key];
27
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/modal-scroll.mjs (+36 lines)
Line 0 Link Here
1
/**
2
 * Modal scroll helpers shared across components.
3
 * Uses a window-scoped counter to manage body scroll lock for nested modals.
4
 */
5
6
/**
7
 * Enable body scroll when the last modal closes.
8
 */
9
export function enableBodyScroll() {
10
    const count = Number(window["kohaModalCount"] ?? 0);
11
    window["kohaModalCount"] = Math.max(0, count - 1);
12
13
    if ((window["kohaModalCount"] ?? 0) === 0) {
14
        document.body.classList.remove("modal-open");
15
        if (document.body.style.paddingRight) {
16
            document.body.style.paddingRight = "";
17
        }
18
    }
19
}
20
21
/**
22
 * Disable body scroll while a modal is open.
23
 */
24
export function disableBodyScroll() {
25
    const current = Number(window["kohaModalCount"] ?? 0);
26
    window["kohaModalCount"] = current + 1;
27
28
    if (!document.body.classList.contains("modal-open")) {
29
        const scrollbarWidth =
30
            window.innerWidth - document.documentElement.clientWidth;
31
        if (scrollbarWidth > 0) {
32
            document.body.style.paddingRight = `${scrollbarWidth}px`;
33
        }
34
        document.body.classList.add("modal-open");
35
    }
36
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs (+77 lines)
Line 0 Link Here
1
import { win } from "./globals.mjs";
2
/**
3
 * Builds a search query for patron searches
4
 * This is a wrapper around the global buildPatronSearchQuery function
5
 * @param {string} term - The search term
6
 * @param {Object} [options] - Search options
7
 * @param {string} [options.search_type] - 'contains' or 'starts_with'
8
 * @param {string} [options.search_fields] - Comma-separated list of fields to search
9
 * @param {Array} [options.extended_attribute_types] - Extended attribute types to search
10
 * @param {string} [options.table_prefix] - Table name prefix for fields
11
 * @returns {Array} Query conditions for the API
12
 */
13
export function buildPatronSearchQuery(term, options = {}) {
14
    /** @type {((term: string, options?: object) => any) | null} */
15
    const globalBuilder =
16
        typeof win("buildPatronSearchQuery") === "function"
17
            ? /** @type {any} */ (win("buildPatronSearchQuery"))
18
            : null;
19
    if (globalBuilder) {
20
        return globalBuilder(term, options);
21
    }
22
23
    // Fallback implementation if the global function is not available
24
    console.warn(
25
        "window.buildPatronSearchQuery is not available, using fallback implementation"
26
    );
27
    const q = [];
28
    if (!term) return q;
29
30
    const table_prefix = options.table_prefix || "me";
31
    const search_fields = options.search_fields
32
        ? options.search_fields.split(",").map(f => f.trim())
33
        : ["surname", "firstname", "cardnumber", "userid"];
34
35
    search_fields.forEach(field => {
36
        q.push({
37
            [`${table_prefix}.${field}`]: {
38
                like: `%${term}%`,
39
            },
40
        });
41
    });
42
43
    return [{ "-or": q }];
44
}
45
46
/**
47
 * Transforms patron data into a consistent format for display
48
 * @param {Object} patron - The patron object to transform
49
 * @returns {Object} Transformed patron object with a display label
50
 */
51
export function transformPatronData(patron) {
52
    if (!patron) return null;
53
54
    return {
55
        ...patron,
56
        label: [
57
            patron.surname,
58
            patron.firstname,
59
            patron.cardnumber ? `(${patron.cardnumber})` : "",
60
        ]
61
            .filter(Boolean)
62
            .join(" ")
63
            .trim(),
64
    };
65
}
66
67
/**
68
 * Transforms an array of patrons using transformPatronData
69
 * @param {Array|Object} data - The patron data (single object or array)
70
 * @returns {Array|Object} Transformed patron(s)
71
 */
72
export function transformPatronsData(data) {
73
    if (!data) return [];
74
75
    const patrons = Array.isArray(data) ? data : data.results || [];
76
    return patrons.map(transformPatronData);
77
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs (+610 lines)
Line 0 Link Here
1
/**
2
 * IntervalTree.js - Efficient interval tree data structure for booking date queries
3
 *
4
 * Provides O(log n) query performance for finding overlapping bookings/checkouts
5
 * Based on augmented red-black tree with interval overlap detection
6
 */
7
8
import dayjs from "../../../../../utils/dayjs.mjs";
9
import { managerLogger as logger } from "../logger.mjs";
10
11
/**
12
 * Represents a booking or checkout interval
13
 * @class BookingInterval
14
 */
15
export class BookingInterval {
16
    /**
17
     * Create a booking interval
18
     * @param {string|Date|import("dayjs").Dayjs} startDate - Start date of the interval
19
     * @param {string|Date|import("dayjs").Dayjs} endDate - End date of the interval
20
     * @param {string|number} itemId - Item ID (will be converted to string)
21
     * @param {'booking'|'checkout'|'lead'|'trail'|'query'} type - Type of interval
22
     * @param {Object} metadata - Additional metadata (booking_id, patron_id, etc.)
23
     * @param {number} [metadata.booking_id] - Booking ID for bookings
24
     * @param {number} [metadata.patron_id] - Patron ID
25
     * @param {number} [metadata.checkout_id] - Checkout ID for checkouts
26
     * @param {number} [metadata.days] - Number of lead/trail days
27
     */
28
    constructor(startDate, endDate, itemId, type, metadata = {}) {
29
        /** @type {number} Unix timestamp for start date */
30
        this.start = dayjs(startDate).valueOf(); // Convert to timestamp for fast comparison
31
        /** @type {number} Unix timestamp for end date */
32
        this.end = dayjs(endDate).valueOf();
33
        /** @type {string} Item ID as string for consistent comparison */
34
        this.itemId = String(itemId); // Ensure string for consistent comparison
35
        /** @type {'booking'|'checkout'|'lead'|'trail'|'query'} Type of interval */
36
        this.type = type; // 'booking', 'checkout', 'lead', 'trail'
37
        /** @type {Object} Additional metadata */
38
        this.metadata = metadata; // booking_id, patron info, etc.
39
40
        // Validate interval
41
        if (this.start > this.end) {
42
            throw new Error(
43
                `Invalid interval: start (${startDate}) is after end (${endDate})`
44
            );
45
        }
46
    }
47
48
    /**
49
     * Check if this interval contains a specific date
50
     * @param {number|Date|import("dayjs").Dayjs} date - Date to check (timestamp, Date object, or dayjs instance)
51
     * @returns {boolean} True if the date is within this interval (inclusive)
52
     */
53
    containsDate(date) {
54
        const timestamp =
55
            typeof date === "number" ? date : dayjs(date).valueOf();
56
        return timestamp >= this.start && timestamp <= this.end;
57
    }
58
59
    /**
60
     * Check if this interval overlaps with another interval
61
     * @param {BookingInterval} other - The other interval to check for overlap
62
     * @returns {boolean} True if the intervals overlap
63
     */
64
    overlaps(other) {
65
        return this.start <= other.end && other.start <= this.end;
66
    }
67
68
    /**
69
     * Get a string representation for debugging
70
     * @returns {string} Human-readable string representation
71
     */
72
    toString() {
73
        const startStr = dayjs(this.start).format("YYYY-MM-DD");
74
        const endStr = dayjs(this.end).format("YYYY-MM-DD");
75
        return `${this.type}[${startStr} to ${endStr}] item:${this.itemId}`;
76
    }
77
}
78
79
/**
80
 * Node in the interval tree (internal class)
81
 * @class IntervalTreeNode
82
 * @private
83
 */
84
class IntervalTreeNode {
85
    /**
86
     * Create an interval tree node
87
     * @param {BookingInterval} interval - The interval stored in this node
88
     */
89
    constructor(interval) {
90
        /** @type {BookingInterval} The interval stored in this node */
91
        this.interval = interval;
92
        /** @type {number} Maximum end value in this subtree (for efficient queries) */
93
        this.max = interval.end; // Max end value in this subtree
94
        /** @type {IntervalTreeNode|null} Left child node */
95
        this.left = null;
96
        /** @type {IntervalTreeNode|null} Right child node */
97
        this.right = null;
98
        /** @type {number} Height of this node for AVL balancing */
99
        this.height = 1;
100
    }
101
102
    /**
103
     * Update the max value based on children (internal method)
104
     */
105
    updateMax() {
106
        this.max = this.interval.end;
107
        if (this.left && this.left.max > this.max) {
108
            this.max = this.left.max;
109
        }
110
        if (this.right && this.right.max > this.max) {
111
            this.max = this.right.max;
112
        }
113
    }
114
}
115
116
/**
117
 * Interval tree implementation with AVL balancing
118
 * Provides efficient O(log n) queries for interval overlaps
119
 * @class IntervalTree
120
 */
121
export class IntervalTree {
122
    /**
123
     * Create a new interval tree
124
     */
125
    constructor() {
126
        /** @type {IntervalTreeNode|null} Root node of the tree */
127
        this.root = null;
128
        /** @type {number} Number of intervals in the tree */
129
        this.size = 0;
130
    }
131
132
    /**
133
     * Get the height of a node (internal method)
134
     * @param {IntervalTreeNode|null} node - The node to get height for
135
     * @returns {number} Height of the node (0 for null nodes)
136
     * @private
137
     */
138
    _getHeight(node) {
139
        return node ? node.height : 0;
140
    }
141
142
    /**
143
     * Get the balance factor of a node (internal method)
144
     * @param {IntervalTreeNode|null} node - The node to get balance factor for
145
     * @returns {number} Balance factor (left height - right height)
146
     * @private
147
     */
148
    _getBalance(node) {
149
        return node
150
            ? this._getHeight(node.left) - this._getHeight(node.right)
151
            : 0;
152
    }
153
154
    /**
155
     * Update node height based on children
156
     * @param {IntervalTreeNode} node
157
     */
158
    _updateHeight(node) {
159
        if (node) {
160
            node.height =
161
                1 +
162
                Math.max(
163
                    this._getHeight(node.left),
164
                    this._getHeight(node.right)
165
                );
166
        }
167
    }
168
169
    /**
170
     * Rotate right (for balancing)
171
     * @param {IntervalTreeNode} y
172
     * @returns {IntervalTreeNode}
173
     */
174
    _rotateRight(y) {
175
        if (!y || !y.left) {
176
            logger.error("Invalid rotation: y or y.left is null", {
177
                y: y?.interval?.toString(),
178
            });
179
            return y;
180
        }
181
182
        const x = y.left;
183
        const T2 = x.right;
184
185
        x.right = y;
186
        y.left = T2;
187
188
        this._updateHeight(y);
189
        this._updateHeight(x);
190
191
        // Update max values after rotation
192
        y.updateMax();
193
        x.updateMax();
194
195
        return x;
196
    }
197
198
    /**
199
     * Rotate left (for balancing)
200
     * @param {IntervalTreeNode} x
201
     * @returns {IntervalTreeNode}
202
     */
203
    _rotateLeft(x) {
204
        if (!x || !x.right) {
205
            logger.error("Invalid rotation: x or x.right is null", {
206
                x: x?.interval?.toString(),
207
            });
208
            return x;
209
        }
210
211
        const y = x.right;
212
        const T2 = y.left;
213
214
        y.left = x;
215
        x.right = T2;
216
217
        this._updateHeight(x);
218
        this._updateHeight(y);
219
220
        // Update max values after rotation
221
        x.updateMax();
222
        y.updateMax();
223
224
        return y;
225
    }
226
227
    /**
228
     * Insert an interval into the tree
229
     * @param {BookingInterval} interval - The interval to insert
230
     * @throws {Error} If the interval is invalid
231
     */
232
    insert(interval) {
233
        logger.debug(`Inserting interval: ${interval.toString()}`);
234
        this.root = this._insertNode(this.root, interval);
235
        this.size++;
236
    }
237
238
    /**
239
     * Recursive helper for insertion with balancing
240
     * @param {IntervalTreeNode} node
241
     * @param {BookingInterval} interval
242
     * @returns {IntervalTreeNode}
243
     */
244
    _insertNode(node, interval) {
245
        // Standard BST insertion based on start time
246
        if (!node) {
247
            return new IntervalTreeNode(interval);
248
        }
249
250
        if (interval.start < node.interval.start) {
251
            node.left = this._insertNode(node.left, interval);
252
        } else {
253
            node.right = this._insertNode(node.right, interval);
254
        }
255
256
        // Update height and max
257
        this._updateHeight(node);
258
        node.updateMax();
259
260
        // Balance the tree
261
        const balance = this._getBalance(node);
262
263
        // Left heavy
264
        if (balance > 1) {
265
            if (interval.start < node.left.interval.start) {
266
                return this._rotateRight(node);
267
            } else {
268
                node.left = this._rotateLeft(node.left);
269
                return this._rotateRight(node);
270
            }
271
        }
272
273
        // Right heavy
274
        if (balance < -1) {
275
            if (interval.start > node.right.interval.start) {
276
                return this._rotateLeft(node);
277
            } else {
278
                node.right = this._rotateRight(node.right);
279
                return this._rotateLeft(node);
280
            }
281
        }
282
283
        return node;
284
    }
285
286
    /**
287
     * Query all intervals that contain a specific date
288
     * @param {Date|import("dayjs").Dayjs|number} date - The date to query (Date object, dayjs instance, or timestamp)
289
     * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items)
290
     * @returns {BookingInterval[]} Array of intervals that contain the date
291
     */
292
    query(date, itemId = null) {
293
        const timestamp =
294
            typeof date === "number" ? date : dayjs(date).valueOf();
295
        logger.debug(
296
            `Querying intervals containing date: ${dayjs(timestamp).format(
297
                "YYYY-MM-DD"
298
            )}`,
299
            { itemId }
300
        );
301
302
        const results = [];
303
        this._queryNode(this.root, timestamp, results, itemId);
304
305
        logger.debug(`Found ${results.length} intervals`);
306
        return results;
307
    }
308
309
    /**
310
     * Recursive helper for point queries
311
     * @param {IntervalTreeNode} node
312
     * @param {number} timestamp
313
     * @param {BookingInterval[]} results
314
     * @param {string} itemId
315
     */
316
    _queryNode(node, timestamp, results, itemId) {
317
        if (!node) return;
318
319
        // Check if current interval contains the timestamp
320
        if (node.interval.containsDate(timestamp)) {
321
            if (!itemId || node.interval.itemId === itemId) {
322
                results.push(node.interval);
323
            }
324
        }
325
326
        // Recurse left if possible
327
        if (node.left && node.left.max >= timestamp) {
328
            this._queryNode(node.left, timestamp, results, itemId);
329
        }
330
331
        // Recurse right if possible
332
        if (node.right && node.interval.start <= timestamp) {
333
            this._queryNode(node.right, timestamp, results, itemId);
334
        }
335
    }
336
337
    /**
338
     * Query all intervals that overlap with a date range
339
     * @param {Date|import("dayjs").Dayjs|number} startDate - Start of the range to query
340
     * @param {Date|import("dayjs").Dayjs|number} endDate - End of the range to query
341
     * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items)
342
     * @returns {BookingInterval[]} Array of intervals that overlap with the range
343
     */
344
    queryRange(startDate, endDate, itemId = null) {
345
        const startTimestamp =
346
            typeof startDate === "number"
347
                ? startDate
348
                : dayjs(startDate).valueOf();
349
        const endTimestamp =
350
            typeof endDate === "number" ? endDate : dayjs(endDate).valueOf();
351
352
        logger.debug(
353
            `Querying intervals in range: ${dayjs(startTimestamp).format(
354
                "YYYY-MM-DD"
355
            )} to ${dayjs(endTimestamp).format("YYYY-MM-DD")}`,
356
            { itemId }
357
        );
358
359
        const queryInterval = new BookingInterval(
360
            new Date(startTimestamp),
361
            new Date(endTimestamp),
362
            "",
363
            "query"
364
        );
365
        const results = [];
366
        this._queryRangeNode(this.root, queryInterval, results, itemId);
367
368
        logger.debug(`Found ${results.length} overlapping intervals`);
369
        return results;
370
    }
371
372
    /**
373
     * Recursive helper for range queries
374
     * @param {IntervalTreeNode} node
375
     * @param {BookingInterval} queryInterval
376
     * @param {BookingInterval[]} results
377
     * @param {string} itemId
378
     */
379
    _queryRangeNode(node, queryInterval, results, itemId) {
380
        if (!node) return;
381
382
        // Check if current interval overlaps with query
383
        if (node.interval.overlaps(queryInterval)) {
384
            if (!itemId || node.interval.itemId === itemId) {
385
                results.push(node.interval);
386
            }
387
        }
388
389
        // Recurse left if possible
390
        if (node.left && node.left.max >= queryInterval.start) {
391
            this._queryRangeNode(node.left, queryInterval, results, itemId);
392
        }
393
394
        // Recurse right if possible
395
        if (node.right && node.interval.start <= queryInterval.end) {
396
            this._queryRangeNode(node.right, queryInterval, results, itemId);
397
        }
398
    }
399
400
    /**
401
     * Remove all intervals matching a predicate
402
     * @param {Function} predicate - Function that returns true for intervals to remove
403
     * @returns {number} Number of intervals removed
404
     */
405
    removeWhere(predicate) {
406
        const toRemove = [];
407
        this._collectNodes(this.root, node => {
408
            if (predicate(node.interval)) {
409
                toRemove.push(node.interval);
410
            }
411
        });
412
413
        toRemove.forEach(interval => {
414
            this.root = this._removeNode(this.root, interval);
415
            this.size--;
416
        });
417
418
        logger.debug(`Removed ${toRemove.length} intervals`);
419
        return toRemove.length;
420
    }
421
422
    /**
423
     * Helper to collect all nodes
424
     * @param {IntervalTreeNode} node
425
     * @param {Function} callback
426
     */
427
    _collectNodes(node, callback) {
428
        if (!node) return;
429
        this._collectNodes(node.left, callback);
430
        callback(node);
431
        this._collectNodes(node.right, callback);
432
    }
433
434
    /**
435
     * Remove a specific interval (simplified - doesn't rebalance)
436
     * @param {IntervalTreeNode} node
437
     * @param {BookingInterval} interval
438
     * @returns {IntervalTreeNode}
439
     */
440
    _removeNode(node, interval) {
441
        if (!node) return null;
442
443
        if (interval.start < node.interval.start) {
444
            node.left = this._removeNode(node.left, interval);
445
        } else if (interval.start > node.interval.start) {
446
            node.right = this._removeNode(node.right, interval);
447
        } else if (
448
            interval.end === node.interval.end &&
449
            interval.itemId === node.interval.itemId &&
450
            interval.type === node.interval.type
451
        ) {
452
            // Found the node to remove
453
            if (!node.left) return node.right;
454
            if (!node.right) return node.left;
455
456
            // Node has two children - get inorder successor
457
            let minNode = node.right;
458
            while (minNode.left) {
459
                minNode = minNode.left;
460
            }
461
462
            node.interval = minNode.interval;
463
            node.right = this._removeNode(node.right, minNode.interval);
464
        } else {
465
            // Continue searching
466
            node.right = this._removeNode(node.right, interval);
467
        }
468
469
        if (node) {
470
            this._updateHeight(node);
471
            node.updateMax();
472
        }
473
474
        return node;
475
    }
476
477
    /**
478
     * Clear all intervals
479
     */
480
    clear() {
481
        this.root = null;
482
        this.size = 0;
483
        logger.debug("Interval tree cleared");
484
    }
485
486
    /**
487
     * Get statistics about the tree for debugging and monitoring
488
     * @returns {Object} Statistics object
489
     */
490
    getStats() {
491
        const stats = {
492
            size: this.size,
493
            height: this._getHeight(this.root),
494
            balanced: Math.abs(this._getBalance(this.root)) <= 1,
495
        };
496
497
        logger.debug("Interval tree stats:", stats);
498
        return stats;
499
    }
500
}
501
502
/**
503
 * Build an interval tree from bookings and checkouts data
504
 * @param {Array<Object>} bookings - Array of booking objects
505
 * @param {Array<Object>} checkouts - Array of checkout objects
506
 * @param {Object} circulationRules - Circulation rules configuration
507
 * @returns {IntervalTree} Populated interval tree ready for queries
508
 */
509
export function buildIntervalTree(bookings, checkouts, circulationRules) {
510
    logger.time("buildIntervalTree");
511
    logger.info("Building interval tree", {
512
        bookingsCount: bookings.length,
513
        checkoutsCount: checkouts.length,
514
    });
515
516
    const tree = new IntervalTree();
517
518
    // Add booking intervals with lead/trail times
519
    bookings.forEach(booking => {
520
        try {
521
            // Skip invalid bookings
522
            if (!booking.item_id || !booking.start_date || !booking.end_date) {
523
                logger.warn("Skipping invalid booking", { booking });
524
                return;
525
            }
526
527
            // Core booking interval
528
            const bookingInterval = new BookingInterval(
529
                booking.start_date,
530
                booking.end_date,
531
                booking.item_id,
532
                "booking",
533
                { booking_id: booking.booking_id, patron_id: booking.patron_id }
534
            );
535
            tree.insert(bookingInterval);
536
537
            // Lead time interval
538
            const leadDays = circulationRules?.bookings_lead_period || 0;
539
            if (leadDays > 0) {
540
                const leadStart = dayjs(booking.start_date).subtract(
541
                    leadDays,
542
                    "day"
543
                );
544
                const leadEnd = dayjs(booking.start_date).subtract(1, "day");
545
                const leadInterval = new BookingInterval(
546
                    leadStart,
547
                    leadEnd,
548
                    booking.item_id,
549
                    "lead",
550
                    { booking_id: booking.booking_id, days: leadDays }
551
                );
552
                tree.insert(leadInterval);
553
            }
554
555
            // Trail time interval
556
            const trailDays = circulationRules?.bookings_trail_period || 0;
557
            if (trailDays > 0) {
558
                const trailStart = dayjs(booking.end_date).add(1, "day");
559
                const trailEnd = dayjs(booking.end_date).add(trailDays, "day");
560
                const trailInterval = new BookingInterval(
561
                    trailStart,
562
                    trailEnd,
563
                    booking.item_id,
564
                    "trail",
565
                    { booking_id: booking.booking_id, days: trailDays }
566
                );
567
                tree.insert(trailInterval);
568
            }
569
        } catch (error) {
570
            logger.error("Failed to insert booking interval", {
571
                booking,
572
                error,
573
            });
574
        }
575
    });
576
577
    // Add checkout intervals
578
    checkouts.forEach(checkout => {
579
        try {
580
            if (
581
                checkout.item_id &&
582
                checkout.checkout_date &&
583
                checkout.due_date
584
            ) {
585
                const checkoutInterval = new BookingInterval(
586
                    checkout.checkout_date,
587
                    checkout.due_date,
588
                    checkout.item_id,
589
                    "checkout",
590
                    {
591
                        checkout_id: checkout.issue_id,
592
                        patron_id: checkout.patron_id,
593
                    }
594
                );
595
                tree.insert(checkoutInterval);
596
            }
597
        } catch (error) {
598
            logger.error("Failed to insert checkout interval", {
599
                checkout,
600
                error,
601
            });
602
        }
603
    });
604
605
    const stats = tree.getStats();
606
    logger.info("Interval tree built", stats);
607
    logger.timeEnd("buildIntervalTree");
608
609
    return tree;
610
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs (+401 lines)
Line 0 Link Here
1
/**
2
 * SweepLineProcessor.js - Efficient sweep line algorithm for processing date ranges
3
 *
4
 * Processes all bookings/checkouts in a date range using a sweep line algorithm
5
 * to efficiently determine availability for each day in O(n log n) time
6
 */
7
8
import dayjs from "../../../../../utils/dayjs.mjs";
9
import { startOfDayTs, endOfDayTs, formatYMD } from "../date-utils.mjs";
10
import { managerLogger as logger } from "../logger.mjs";
11
12
/**
13
 * Event types for the sweep line algorithm
14
 * @readonly
15
 * @enum {string}
16
 */
17
const EventType = {
18
    /** Start of an interval */
19
    START: "start",
20
    /** End of an interval */
21
    END: "end",
22
};
23
24
/**
25
 * Represents an event in the sweep line algorithm (internal class)
26
 * @class SweepEvent
27
 * @private
28
 */
29
class SweepEvent {
30
    /**
31
     * Create a sweep event
32
     * @param {number} timestamp - Unix timestamp of the event
33
     * @param {'start'|'end'} type - Type of event
34
     * @param {import('./interval-tree.mjs').BookingInterval} interval - The interval associated with this event
35
     */
36
    constructor(timestamp, type, interval) {
37
        /** @type {number} Unix timestamp of the event */
38
        this.timestamp = timestamp;
39
        /** @type {'start'|'end'} Type of event */
40
        this.type = type; // 'start' or 'end'
41
        /** @type {import('./interval-tree.mjs').BookingInterval} The booking/checkout interval */
42
        this.interval = interval; // The booking/checkout interval
43
    }
44
}
45
46
/**
47
 * Sweep line processor for efficient date range queries
48
 * Uses sweep line algorithm to process intervals in O(n log n) time
49
 * @class SweepLineProcessor
50
 */
51
export class SweepLineProcessor {
52
    /**
53
     * Create a new sweep line processor
54
     */
55
    constructor() {
56
        /** @type {SweepEvent[]} Array of sweep events */
57
        this.events = [];
58
    }
59
60
    /**
61
     * Process intervals to generate unavailability data for a date range
62
     * @param {import('./interval-tree.mjs').BookingInterval[]} intervals - All booking/checkout intervals
63
     * @param {Date|import("dayjs").Dayjs} viewStart - Start of the visible date range
64
     * @param {Date|import("dayjs").Dayjs} viewEnd - End of the visible date range
65
     * @param {Array<string>} allItemIds - All bookable item IDs
66
     * @returns {Object<string, Object<string, Set<string>>>} unavailableByDate map
67
     */
68
    processIntervals(intervals, viewStart, viewEnd, allItemIds) {
69
        logger.time("SweepLineProcessor.processIntervals");
70
        logger.debug("Processing intervals for date range", {
71
            intervalCount: intervals.length,
72
            viewStart: formatYMD(viewStart),
73
            viewEnd: formatYMD(viewEnd),
74
            itemCount: allItemIds.length,
75
        });
76
77
        const startTimestamp = startOfDayTs(viewStart);
78
        const endTimestamp = endOfDayTs(viewEnd);
79
80
        this.events = [];
81
        intervals.forEach(interval => {
82
            if (
83
                interval.end < startTimestamp ||
84
                interval.start > endTimestamp
85
            ) {
86
                return;
87
            }
88
89
            const clampedStart = Math.max(interval.start, startTimestamp);
90
            const nextDayStart = dayjs(interval.end)
91
                .add(1, "day")
92
                .startOf("day")
93
                .valueOf();
94
            const endRemovalTs = Math.min(nextDayStart, endTimestamp + 1);
95
96
            this.events.push(new SweepEvent(clampedStart, "start", interval));
97
            this.events.push(new SweepEvent(endRemovalTs, "end", interval));
98
        });
99
100
        this.events.sort((a, b) => {
101
            if (a.timestamp !== b.timestamp) {
102
                return a.timestamp - b.timestamp;
103
            }
104
            return a.type === "start" ? -1 : 1;
105
        });
106
107
        logger.debug(`Created ${this.events.length} sweep events`);
108
109
        /** @type {Record<string, Record<string, Set<string>>>} */
110
        const unavailableByDate = {};
111
        const activeIntervals = new Map(); // itemId -> Set of intervals
112
113
        allItemIds.forEach(itemId => {
114
            activeIntervals.set(itemId, new Set());
115
        });
116
117
        let currentDate = null;
118
        let eventIndex = 0;
119
120
        for (
121
            let date = dayjs(viewStart).startOf("day");
122
            date.isSameOrBefore(viewEnd, "day");
123
            date = date.add(1, "day")
124
        ) {
125
            const dateKey = date.format("YYYY-MM-DD");
126
            const dateStart = date.valueOf();
127
            const dateEnd = date.endOf("day").valueOf();
128
129
            while (
130
                eventIndex < this.events.length &&
131
                this.events[eventIndex].timestamp <= dateEnd
132
            ) {
133
                const event = this.events[eventIndex];
134
                const itemId = event.interval.itemId;
135
136
                if (event.type === EventType.START) {
137
                    if (!activeIntervals.has(itemId)) {
138
                        activeIntervals.set(itemId, new Set());
139
                    }
140
                    activeIntervals.get(itemId).add(event.interval);
141
                } else {
142
                    if (activeIntervals.has(itemId)) {
143
                        activeIntervals.get(itemId).delete(event.interval);
144
                    }
145
                }
146
147
                eventIndex++;
148
            }
149
150
            unavailableByDate[dateKey] = {};
151
152
            activeIntervals.forEach((intervals, itemId) => {
153
                const reasons = new Set();
154
155
                intervals.forEach(interval => {
156
                    if (
157
                        interval.start <= dateEnd &&
158
                        interval.end >= dateStart
159
                    ) {
160
                        if (interval.type === "booking") {
161
                            reasons.add("core");
162
                        } else if (interval.type === "checkout") {
163
                            reasons.add("checkout");
164
                        } else {
165
                            reasons.add(interval.type); // 'lead' or 'trail'
166
                        }
167
                    }
168
                });
169
170
                if (reasons.size > 0) {
171
                    unavailableByDate[dateKey][itemId] = reasons;
172
                }
173
            });
174
        }
175
176
        logger.debug("Sweep line processing complete", {
177
            datesProcessed: Object.keys(unavailableByDate).length,
178
            totalUnavailable: Object.values(unavailableByDate).reduce(
179
                (sum, items) => sum + Object.keys(items).length,
180
                0
181
            ),
182
        });
183
184
        logger.timeEnd("SweepLineProcessor.processIntervals");
185
186
        return unavailableByDate;
187
    }
188
189
    /**
190
     * Process intervals and return aggregated statistics
191
     * @param {Array} intervals
192
     * @param {Date|import("dayjs").Dayjs} viewStart
193
     * @param {Date|import("dayjs").Dayjs} viewEnd
194
     * @returns {Object} Statistics about the date range
195
     */
196
    getDateRangeStatistics(intervals, viewStart, viewEnd) {
197
        logger.time("getDateRangeStatistics");
198
199
        const stats = {
200
            totalDays: 0,
201
            daysWithBookings: 0,
202
            daysWithCheckouts: 0,
203
            fullyBookedDays: 0,
204
            peakBookingCount: 0,
205
            peakDate: null,
206
            itemUtilization: new Map(),
207
        };
208
209
        const startDate = dayjs(viewStart).startOf("day");
210
        const endDate = dayjs(viewEnd).endOf("day");
211
212
        stats.totalDays = endDate.diff(startDate, "day") + 1;
213
214
        for (
215
            let date = startDate;
216
            date.isSameOrBefore(endDate, "day");
217
            date = date.add(1, "day")
218
        ) {
219
            const dayStart = date.valueOf();
220
            const dayEnd = date.endOf("day").valueOf();
221
222
            let bookingCount = 0;
223
            let checkoutCount = 0;
224
            const itemsInUse = new Set();
225
226
            intervals.forEach(interval => {
227
                if (interval.start <= dayEnd && interval.end >= dayStart) {
228
                    if (interval.type === "booking") {
229
                        bookingCount++;
230
                        itemsInUse.add(interval.itemId);
231
                    } else if (interval.type === "checkout") {
232
                        checkoutCount++;
233
                        itemsInUse.add(interval.itemId);
234
                    }
235
                }
236
            });
237
238
            if (bookingCount > 0) stats.daysWithBookings++;
239
            if (checkoutCount > 0) stats.daysWithCheckouts++;
240
241
            const totalCount = bookingCount + checkoutCount;
242
            if (totalCount > stats.peakBookingCount) {
243
                stats.peakBookingCount = totalCount;
244
                stats.peakDate = date.format("YYYY-MM-DD");
245
            }
246
247
            itemsInUse.forEach(itemId => {
248
                if (!stats.itemUtilization.has(itemId)) {
249
                    stats.itemUtilization.set(itemId, 0);
250
                }
251
                stats.itemUtilization.set(
252
                    itemId,
253
                    stats.itemUtilization.get(itemId) + 1
254
                );
255
            });
256
        }
257
258
        logger.info("Date range statistics calculated", stats);
259
        logger.timeEnd("getDateRangeStatistics");
260
261
        return stats;
262
    }
263
264
    /**
265
     * Find the next available date for a specific item
266
     * @param {Array} intervals
267
     * @param {string} itemId
268
     * @param {Date|import('dayjs').Dayjs} startDate
269
     * @param {number} maxDaysToSearch
270
     * @returns {Date|null}
271
     */
272
    findNextAvailableDate(intervals, itemId, startDate, maxDaysToSearch = 365) {
273
        logger.debug("Finding next available date", {
274
            itemId,
275
            startDate: dayjs(startDate).format("YYYY-MM-DD"),
276
        });
277
278
        const start = dayjs(startDate).startOf("day");
279
        const itemIntervals = intervals.filter(
280
            interval => interval.itemId === itemId
281
        );
282
283
        itemIntervals.sort((a, b) => a.start - b.start);
284
285
        for (let i = 0; i < maxDaysToSearch; i++) {
286
            const checkDate = start.add(i, "day");
287
            const dateStart = checkDate.valueOf();
288
            const dateEnd = checkDate.endOf("day").valueOf();
289
290
            const isAvailable = !itemIntervals.some(
291
                interval =>
292
                    interval.start <= dateEnd && interval.end >= dateStart
293
            );
294
295
            if (isAvailable) {
296
                logger.debug("Found available date", {
297
                    date: checkDate.format("YYYY-MM-DD"),
298
                    daysFromStart: i,
299
                });
300
                return checkDate.toDate();
301
            }
302
        }
303
304
        logger.warn("No available date found within search limit");
305
        return null;
306
    }
307
308
    /**
309
     * Find gaps (available periods) for an item
310
     * @param {Array} intervals
311
     * @param {string} itemId
312
     * @param {Date|import('dayjs').Dayjs} viewStart
313
     * @param {Date|import('dayjs').Dayjs} viewEnd
314
     * @param {number} minGapDays - Minimum gap size to report
315
     * @returns {Array<{start: Date, end: Date, days: number}>}
316
     */
317
    findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) {
318
        logger.debug("Finding available gaps", {
319
            itemId,
320
            viewStart: dayjs(viewStart).format("YYYY-MM-DD"),
321
            viewEnd: dayjs(viewEnd).format("YYYY-MM-DD"),
322
            minGapDays,
323
        });
324
325
        const gaps = [];
326
        const itemIntervals = intervals
327
            .filter(interval => interval.itemId === itemId)
328
            .sort((a, b) => a.start - b.start);
329
330
        const rangeStart = dayjs(viewStart).startOf("day").valueOf();
331
        const rangeEnd = dayjs(viewEnd).endOf("day").valueOf();
332
333
        let lastEnd = rangeStart;
334
335
        itemIntervals.forEach(interval => {
336
            if (interval.end < rangeStart || interval.start > rangeEnd) {
337
                return;
338
            }
339
340
            const gapStart = Math.max(lastEnd, rangeStart);
341
            const gapEnd = Math.min(interval.start, rangeEnd);
342
343
            if (gapEnd > gapStart) {
344
                const gapDays = dayjs(gapEnd).diff(dayjs(gapStart), "day");
345
                if (gapDays >= minGapDays) {
346
                    gaps.push({
347
                        start: new Date(gapStart),
348
                        end: new Date(gapEnd - 1), // End of previous day
349
                        days: gapDays,
350
                    });
351
                }
352
            }
353
354
            lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day
355
        });
356
357
        if (lastEnd < rangeEnd) {
358
            const gapDays = dayjs(rangeEnd).diff(dayjs(lastEnd), "day");
359
            if (gapDays >= minGapDays) {
360
                gaps.push({
361
                    start: new Date(lastEnd),
362
                    end: new Date(rangeEnd),
363
                    days: gapDays,
364
                });
365
            }
366
        }
367
368
        logger.debug(`Found ${gaps.length} available gaps`);
369
        return gaps;
370
    }
371
}
372
373
/**
374
 * Create and process unavailability data using sweep line algorithm
375
 * @param {import('./interval-tree.mjs').IntervalTree} intervalTree - The interval tree containing all bookings/checkouts
376
 * @param {Date|import("dayjs").Dayjs} viewStart - Start of the calendar view date range
377
 * @param {Date|import("dayjs").Dayjs} viewEnd - End of the calendar view date range
378
 * @param {Array<string>} allItemIds - All bookable item IDs
379
 * @returns {Object<string, Object<string, Set<string>>>} unavailableByDate map
380
 */
381
export function processCalendarView(
382
    intervalTree,
383
    viewStart,
384
    viewEnd,
385
    allItemIds
386
) {
387
    logger.time("processCalendarView");
388
389
    const relevantIntervals = intervalTree.queryRange(viewStart, viewEnd);
390
391
    const processor = new SweepLineProcessor();
392
    const unavailableByDate = processor.processIntervals(
393
        relevantIntervals,
394
        viewStart,
395
        viewEnd,
396
        allItemIds
397
    );
398
399
    logger.timeEnd("processCalendarView");
400
    return unavailableByDate;
401
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs (+28 lines)
Line 0 Link Here
1
// Shared constants for booking system (business logic + UI)
2
3
// Constraint modes
4
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
5
export const CONSTRAINT_MODE_NORMAL = "normal";
6
7
// Selection semantics (logging, diagnostics)
8
export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE";
9
export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM";
10
11
// UI class names (used across calendar/adapters/composables)
12
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
13
    "booking-constrained-range-marker";
14
export const CLASS_BOOKING_DAY_HOVER_LEAD = "booking-day--hover-lead";
15
export const CLASS_BOOKING_DAY_HOVER_TRAIL = "booking-day--hover-trail";
16
export const CLASS_BOOKING_INTERMEDIATE_BLOCKED = "booking-intermediate-blocked";
17
export const CLASS_BOOKING_MARKER_COUNT = "booking-marker-count";
18
export const CLASS_BOOKING_MARKER_DOT = "booking-marker-dot";
19
export const CLASS_BOOKING_MARKER_GRID = "booking-marker-grid";
20
export const CLASS_BOOKING_MARKER_ITEM = "booking-marker-item";
21
export const CLASS_BOOKING_OVERRIDE_ALLOWED = "booking-override-allowed";
22
export const CLASS_FLATPICKR_DAY = "flatpickr-day";
23
export const CLASS_FLATPICKR_DISABLED = "flatpickr-disabled";
24
export const CLASS_FLATPICKR_NOT_ALLOWED = "notAllowed";
25
export const CLASS_BOOKING_LOAN_BOUNDARY = "booking-loan-boundary";
26
27
// Data attributes
28
export const DATA_ATTRIBUTE_BOOKING_OVERRIDE = "data-booking-override";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/date-utils.mjs (+42 lines)
Line 0 Link Here
1
import dayjs from "../../../../utils/dayjs.mjs";
2
3
// Convert an array of ISO strings (or Date-like values) to plain Date objects
4
export function isoArrayToDates(values) {
5
    if (!Array.isArray(values)) return [];
6
    return values.filter(Boolean).map(d => dayjs(d).toDate());
7
}
8
9
// Convert a Date-like input to ISO string
10
export function toISO(input) {
11
    return dayjs(
12
        /** @type {import('dayjs').ConfigType} */ (input)
13
    ).toISOString();
14
}
15
16
// Normalize any Date-like input to a dayjs instance
17
export function toDayjs(input) {
18
    return dayjs(/** @type {import('dayjs').ConfigType} */ (input));
19
}
20
21
// Get start-of-day timestamp for a Date-like input
22
export function startOfDayTs(input) {
23
    return toDayjs(input).startOf("day").valueOf();
24
}
25
26
// Get end-of-day timestamp for a Date-like input
27
export function endOfDayTs(input) {
28
    return toDayjs(input).endOf("day").valueOf();
29
}
30
31
// Format a Date-like input as YYYY-MM-DD
32
export function formatYMD(input) {
33
    return toDayjs(input).format("YYYY-MM-DD");
34
}
35
36
// Add or subtract days returning a dayjs instance
37
export function addDays(input, days) {
38
    return toDayjs(input).add(days, "day");
39
}
40
export function subDays(input, days) {
41
    return toDayjs(input).subtract(days, "day");
42
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs (+39 lines)
Line 0 Link Here
1
// Utilities for comparing and handling mixed string/number IDs consistently
2
3
export function idsEqual(a, b) {
4
    if (a == null || b == null) return false;
5
    return String(a) === String(b);
6
}
7
8
export function includesId(list, target) {
9
    if (!Array.isArray(list)) return false;
10
    return list.some(id => idsEqual(id, target));
11
}
12
13
/**
14
 * Normalize an identifier's type to match a sample (number|string) for strict comparisons.
15
 * If sample is a number, casts value to number; otherwise casts to string.
16
 * Falls back to string when sample is null/undefined.
17
 *
18
 * @param {unknown} sample - A sample value to infer the desired type from
19
 * @param {unknown} value - The value to normalize
20
 * @returns {string|number|null}
21
 */
22
export function normalizeIdType(sample, value) {
23
    if (!value == null) return null;
24
    return typeof sample === "number" ? Number(value) : String(value);
25
}
26
27
export function toIdSet(list) {
28
    if (!Array.isArray(list)) return new Set();
29
    return new Set(list.map(v => String(v)));
30
}
31
32
/**
33
 * Normalize any value to a string ID (for Set/Map keys and comparisons)
34
 * @param {unknown} value
35
 * @returns {string}
36
 */
37
export function toStringId(value) {
38
    return String(value);
39
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs (+279 lines)
Line 0 Link Here
1
/**
2
 * bookingLogger.js - Debug logging utility for the booking system
3
 *
4
 * Provides configurable debug logging that can be enabled/disabled at runtime.
5
 * Logs can be controlled via localStorage or global variables.
6
 */
7
8
class BookingLogger {
9
    constructor(module) {
10
        this.module = module;
11
        this.enabled = false;
12
        this.logLevels = {
13
            DEBUG: "debug",
14
            INFO: "info",
15
            WARN: "warn",
16
            ERROR: "error",
17
        };
18
        // Don't log anything by default unless explicitly enabled
19
        this.enabledLevels = new Set();
20
        // Track active timers and groups to prevent console errors
21
        this._activeTimers = new Set();
22
        this._activeGroups = [];
23
24
        // Check for debug configuration
25
        if (typeof window !== "undefined" && window.localStorage) {
26
            // Check localStorage first, then global variable
27
            this.enabled =
28
                window.localStorage.getItem("koha.booking.debug") === "true" ||
29
                window["KOHA_BOOKING_DEBUG"] === true;
30
31
            // Allow configuring specific log levels
32
            const levels = window.localStorage.getItem(
33
                "koha.booking.debug.levels"
34
            );
35
            if (levels) {
36
                this.enabledLevels = new Set(levels.split(","));
37
            }
38
        }
39
    }
40
41
    /**
42
     * Enable or disable debug logging
43
     * @param {boolean} enabled
44
     */
45
    setEnabled(enabled) {
46
        this.enabled = enabled;
47
        if (enabled) {
48
            // When enabling debug, include all levels
49
            this.enabledLevels = new Set(["debug", "info", "warn", "error"]);
50
        } else {
51
            // When disabling, clear all levels
52
            this.enabledLevels = new Set();
53
        }
54
        if (typeof window !== "undefined" && window.localStorage) {
55
            window.localStorage.setItem(
56
                "koha.booking.debug",
57
                enabled.toString()
58
            );
59
        }
60
    }
61
62
    /**
63
     * Set which log levels are enabled
64
     * @param {string[]} levels - Array of level names (debug, info, warn, error)
65
     */
66
    setLevels(levels) {
67
        this.enabledLevels = new Set(levels);
68
        if (typeof window !== "undefined" && window.localStorage) {
69
            window.localStorage.setItem(
70
                "koha.booking.debug.levels",
71
                levels.join(",")
72
            );
73
        }
74
    }
75
76
    /**
77
     * Core logging method
78
     * @param {string} level
79
     * @param {string} message
80
     * @param  {...unknown} args
81
     */
82
    log(level, message, ...args) {
83
        if (!this.enabledLevels.has(level)) return;
84
85
        const timestamp = new Date().toISOString();
86
        const prefix = `[${timestamp}] [${
87
            this.module
88
        }] [${level.toUpperCase()}]`;
89
90
        console[level](prefix, message, ...args);
91
92
        this._logBuffer = this._logBuffer || [];
93
        this._logBuffer.push({
94
            timestamp,
95
            module: this.module,
96
            level,
97
            message,
98
            args,
99
        });
100
101
        if (this._logBuffer.length > 1000) {
102
            this._logBuffer = this._logBuffer.slice(-1000);
103
        }
104
    }
105
106
    // Convenience methods
107
    debug(message, ...args) {
108
        this.log("debug", message, ...args);
109
    }
110
    info(message, ...args) {
111
        this.log("info", message, ...args);
112
    }
113
    warn(message, ...args) {
114
        this.log("warn", message, ...args);
115
    }
116
    error(message, ...args) {
117
        this.log("error", message, ...args);
118
    }
119
120
    /**
121
     * Performance timing utilities
122
     */
123
    time(label) {
124
        if (!this.enabledLevels.has("debug")) return;
125
        const key = `[${this.module}] ${label}`;
126
        console.time(key);
127
        this._activeTimers.add(label);
128
        this._timers = this._timers || {};
129
        this._timers[label] = performance.now();
130
    }
131
132
    timeEnd(label) {
133
        if (!this.enabledLevels.has("debug")) return;
134
        // Only call console.timeEnd if we actually started this timer
135
        if (!this._activeTimers.has(label)) return;
136
137
        const key = `[${this.module}] ${label}`;
138
        console.timeEnd(key);
139
        this._activeTimers.delete(label);
140
141
        // Also log the duration
142
        if (this._timers && this._timers[label]) {
143
            const duration = performance.now() - this._timers[label];
144
            this.debug(`${label} completed in ${duration.toFixed(2)}ms`);
145
            delete this._timers[label];
146
        }
147
    }
148
149
    /**
150
     * Group related log entries
151
     */
152
    group(label) {
153
        if (!this.enabledLevels.has("debug")) return;
154
        console.group(`[${this.module}] ${label}`);
155
        this._activeGroups.push(label);
156
    }
157
158
    groupEnd() {
159
        if (!this.enabledLevels.has("debug")) return;
160
        // Only call console.groupEnd if we have an active group
161
        if (this._activeGroups.length === 0) return;
162
163
        console.groupEnd();
164
        this._activeGroups.pop();
165
    }
166
167
    /**
168
     * Export logs for bug reports
169
     */
170
    exportLogs() {
171
        return {
172
            module: this.module,
173
            enabled: this.enabled,
174
            enabledLevels: Array.from(this.enabledLevels),
175
            logs: this._logBuffer || [],
176
        };
177
    }
178
179
    /**
180
     * Clear log buffer
181
     */
182
    clearLogs() {
183
        this._logBuffer = [];
184
        this._activeTimers.clear();
185
        this._activeGroups = [];
186
    }
187
}
188
189
// Create singleton instances for each module
190
export const managerLogger = new BookingLogger("BookingManager");
191
export const calendarLogger = new BookingLogger("BookingCalendar");
192
193
// Expose debug utilities to browser console
194
if (typeof window !== "undefined") {
195
    const debugObj = {
196
        // Enable/disable all booking debug logs
197
        enable() {
198
            managerLogger.setEnabled(true);
199
            calendarLogger.setEnabled(true);
200
            console.log("Booking debug logging enabled");
201
        },
202
203
        disable() {
204
            managerLogger.setEnabled(false);
205
            calendarLogger.setEnabled(false);
206
            console.log("Booking debug logging disabled");
207
        },
208
209
        // Set specific log levels
210
        setLevels(levels) {
211
            managerLogger.setLevels(levels);
212
            calendarLogger.setLevels(levels);
213
            console.log(`Booking log levels set to: ${levels.join(", ")}`);
214
        },
215
216
        // Export all logs
217
        exportLogs() {
218
            return {
219
                manager: managerLogger.exportLogs(),
220
                calendar: calendarLogger.exportLogs(),
221
            };
222
        },
223
224
        // Clear all logs
225
        clearLogs() {
226
            managerLogger.clearLogs();
227
            calendarLogger.clearLogs();
228
            console.log("Booking logs cleared");
229
        },
230
231
        // Get current status
232
        status() {
233
            return {
234
                enabled: {
235
                    manager: managerLogger.enabled,
236
                    calendar: calendarLogger.enabled,
237
                },
238
                levels: {
239
                    manager: Array.from(managerLogger.enabledLevels),
240
                    calendar: Array.from(calendarLogger.enabledLevels),
241
                },
242
            };
243
        },
244
    };
245
246
    // Set on browser window
247
    window["BookingDebug"] = debugObj;
248
249
    // Only log availability message if debug is already enabled
250
    if (managerLogger.enabled || calendarLogger.enabled) {
251
        console.log("Booking debug utilities available at window.BookingDebug");
252
    }
253
}
254
255
// Additional setup for Node.js testing environment
256
if (typeof globalThis !== "undefined" && typeof window === "undefined") {
257
    // We're in Node.js - set up global.window if it exists
258
    if (globalThis.window) {
259
        const debugObj = {
260
            enable: () => {
261
                managerLogger.setEnabled(true);
262
                calendarLogger.setEnabled(true);
263
            },
264
            disable: () => {
265
                managerLogger.setEnabled(false);
266
                calendarLogger.setEnabled(false);
267
            },
268
            exportLogs: () => ({
269
                manager: managerLogger.exportLogs(),
270
                calendar: calendarLogger.exportLogs(),
271
            }),
272
            status: () => ({
273
                managerEnabled: managerLogger.enabled,
274
                calendarEnabled: calendarLogger.enabled,
275
            }),
276
        };
277
        globalThis.window["BookingDebug"] = debugObj;
278
    }
279
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/manager.mjs (+1355 lines)
Line 0 Link Here
1
import dayjs from "../../../../utils/dayjs.mjs";
2
import {
3
    isoArrayToDates,
4
    toDayjs,
5
    addDays,
6
    subDays,
7
    formatYMD,
8
} from "./date-utils.mjs";
9
import { managerLogger as logger } from "./logger.mjs";
10
import { createConstraintStrategy } from "./strategies.mjs";
11
import {
12
    // eslint-disable-next-line no-unused-vars
13
    IntervalTree,
14
    buildIntervalTree,
15
} from "./algorithms/interval-tree.mjs";
16
import {
17
    SweepLineProcessor,
18
    processCalendarView,
19
} from "./algorithms/sweep-line-processor.mjs";
20
import { idsEqual, includesId } from "./id-utils.mjs";
21
import {
22
    CONSTRAINT_MODE_END_DATE_ONLY,
23
    CONSTRAINT_MODE_NORMAL,
24
    SELECTION_ANY_AVAILABLE,
25
    SELECTION_SPECIFIC_ITEM,
26
} from "./constants.mjs";
27
28
const $__ = globalThis.$__ || (str => str);
29
30
/**
31
 * Calculates the maximum end date for a booking period based on start date and maximum period.
32
 * Follows Koha circulation behavior where maxPeriod represents days to ADD to start date.
33
 *
34
 * @param {Date|string|import('dayjs').Dayjs} startDate - The start date
35
 * @param {number} maxPeriod - Maximum period in days (from circulation rules)
36
 * @returns {import('dayjs').Dayjs} The maximum end date
37
 */
38
export function calculateMaxEndDate(startDate, maxPeriod) {
39
    if (!maxPeriod || maxPeriod <= 0) {
40
        throw new Error('maxPeriod must be a positive number');
41
    }
42
43
    const start = dayjs(startDate).startOf("day");
44
    // Add maxPeriod days (matches CalcDateDue behavior)
45
    return start.add(maxPeriod, "day");
46
}
47
48
/**
49
 * Validates if an end date exceeds the maximum allowed period
50
 *
51
 * @param {Date|string|import('dayjs').Dayjs} startDate - The start date
52
 * @param {Date|string|import('dayjs').Dayjs} endDate - The proposed end date
53
 * @param {number} maxPeriod - Maximum period in days
54
 * @returns {boolean} True if end date is valid (within limits)
55
 */
56
export function validateBookingPeriod(startDate, endDate, maxPeriod) {
57
    if (!maxPeriod || maxPeriod <= 0) {
58
        return true; // No limit
59
    }
60
61
    const maxEndDate = calculateMaxEndDate(startDate, maxPeriod);
62
    const proposedEnd = dayjs(endDate).startOf("day");
63
64
    return !proposedEnd.isAfter(maxEndDate, "day");
65
}
66
67
/**
68
 * Build unavailableByDate map from IntervalTree for backward compatibility
69
 * @param {IntervalTree} intervalTree - The interval tree containing all bookings/checkouts
70
 * @param {import('dayjs').Dayjs} today - Today's date for range calculation
71
 * @param {Array} allItemIds - Array of all item IDs
72
 * @param {number|string|null} editBookingId - The booking_id being edited (exclude from results)
73
 * @param {Object} options - Additional options for optimization
74
 * @param {Object} [options] - Additional options for optimization
75
 * @param {Date} [options.visibleStartDate] - Start of visible calendar range
76
 * @param {Date} [options.visibleEndDate] - End of visible calendar range
77
 * @param {boolean} [options.onDemand] - Whether to build map on-demand for visible dates only
78
 * @returns {import('../../types/bookings').UnavailableByDate}
79
 */
80
function buildUnavailableByDateMap(
81
    intervalTree,
82
    today,
83
    allItemIds,
84
    editBookingId,
85
    options = {}
86
) {
87
    /** @type {import('../../types/bookings').UnavailableByDate} */
88
    const unavailableByDate = {};
89
90
    if (!intervalTree || intervalTree.size === 0) {
91
        return unavailableByDate;
92
    }
93
94
    let startDate, endDate;
95
    if (
96
        options.onDemand &&
97
        options.visibleStartDate &&
98
        options.visibleEndDate
99
    ) {
100
        startDate = subDays(options.visibleStartDate, 7);
101
        endDate = addDays(options.visibleEndDate, 7);
102
        logger.debug("Building unavailableByDate map for visible range only", {
103
            start: formatYMD(startDate),
104
            end: formatYMD(endDate),
105
            days: endDate.diff(startDate, "day") + 1,
106
        });
107
    } else {
108
        startDate = subDays(today, 7);
109
        endDate = addDays(today, 90);
110
        logger.debug("Building unavailableByDate map with limited range", {
111
            start: formatYMD(startDate),
112
            end: formatYMD(endDate),
113
            days: endDate.diff(startDate, "day") + 1,
114
        });
115
    }
116
117
    const rangeIntervals = intervalTree.queryRange(
118
        startDate.toDate(),
119
        endDate.toDate()
120
    );
121
122
    // Exclude the booking being edited
123
    const relevantIntervals = editBookingId
124
        ? rangeIntervals.filter(
125
              interval => interval.metadata?.booking_id != editBookingId
126
          )
127
        : rangeIntervals;
128
129
    const processor = new SweepLineProcessor();
130
    const sweptMap = processor.processIntervals(
131
        relevantIntervals,
132
        startDate.toDate(),
133
        endDate.toDate(),
134
        allItemIds
135
    );
136
137
    // Ensure the map contains all dates in the requested range, even if empty
138
    const filledMap = sweptMap && typeof sweptMap === "object" ? sweptMap : {};
139
    for (
140
        let d = startDate.clone();
141
        d.isSameOrBefore(endDate, "day");
142
        d = d.add(1, "day")
143
    ) {
144
        const key = d.format("YYYY-MM-DD");
145
        if (!filledMap[key]) filledMap[key] = {};
146
    }
147
148
    // Normalize reasons for legacy API expectations: convert 'core' -> 'booking'
149
    Object.keys(filledMap).forEach(dateKey => {
150
        const byItem = filledMap[dateKey];
151
        Object.keys(byItem).forEach(itemId => {
152
            const original = byItem[itemId];
153
            if (original && original instanceof Set) {
154
                const mapped = new Set();
155
                original.forEach(reason => {
156
                    mapped.add(reason === "core" ? "booking" : reason);
157
                });
158
                byItem[itemId] = mapped;
159
            }
160
        });
161
    });
162
163
    return filledMap;
164
}
165
166
// Small helper to standardize constraint function return shape
167
function buildConstraintResult(filtered, total) {
168
    const filteredOutCount = total - filtered.length;
169
    return {
170
        filtered,
171
        filteredOutCount,
172
        total,
173
        constraintApplied: filtered.length !== total,
174
    };
175
}
176
177
/**
178
 * Optimized lead period validation using range queries instead of individual point queries
179
 * @param {import("dayjs").Dayjs} startDate - Potential start date to validate
180
 * @param {number} leadDays - Number of lead period days to check
181
 * @param {Object} intervalTree - Interval tree for conflict checking
182
 * @param {string|null} selectedItem - Selected item ID or null
183
 * @param {number|null} editBookingId - Booking ID being edited
184
 * @param {Array} allItemIds - All available item IDs
185
 * @returns {boolean} True if start date should be blocked due to lead period conflicts
186
 */
187
function validateLeadPeriodOptimized(
188
    startDate,
189
    leadDays,
190
    intervalTree,
191
    selectedItem,
192
    editBookingId,
193
    allItemIds
194
) {
195
    if (leadDays <= 0) return false;
196
197
    const leadStart = startDate.subtract(leadDays, "day");
198
    const leadEnd = startDate.subtract(1, "day");
199
200
    logger.debug(
201
        `Optimized lead period check: ${formatYMD(leadStart)} to ${formatYMD(
202
            leadEnd
203
        )}`
204
    );
205
206
    // Use range query to get all conflicts in the lead period at once
207
    const leadConflicts = intervalTree.queryRange(
208
        leadStart.valueOf(),
209
        leadEnd.valueOf(),
210
        selectedItem != null ? String(selectedItem) : null
211
    );
212
213
    const relevantLeadConflicts = leadConflicts.filter(
214
        c => !editBookingId || c.metadata.booking_id != editBookingId
215
    );
216
217
    if (selectedItem) {
218
        // For specific item, any conflict in lead period blocks the start date
219
        return relevantLeadConflicts.length > 0;
220
    } else {
221
        // For "any item" mode, need to check if there are conflicts for ALL items
222
        // on ANY day in the lead period
223
        if (relevantLeadConflicts.length === 0) return false;
224
225
        const unavailableItemIds = new Set(
226
            relevantLeadConflicts.map(c => c.itemId)
227
        );
228
        const allUnavailable =
229
            allItemIds.length > 0 &&
230
            allItemIds.every(id => unavailableItemIds.has(String(id)));
231
232
        logger.debug(`Lead period multi-item check (optimized):`, {
233
            leadPeriod: `${formatYMD(leadStart)} to ${formatYMD(leadEnd)}`,
234
            totalItems: allItemIds.length,
235
            conflictsFound: relevantLeadConflicts.length,
236
            unavailableItems: Array.from(unavailableItemIds),
237
            allUnavailable: allUnavailable,
238
            decision: allUnavailable ? "BLOCK" : "ALLOW",
239
        });
240
241
        return allUnavailable;
242
    }
243
}
244
245
/**
246
 * Optimized trail period validation using range queries instead of individual point queries
247
 * @param {import("dayjs").Dayjs} endDate - Potential end date to validate
248
 * @param {number} trailDays - Number of trail period days to check
249
 * @param {Object} intervalTree - Interval tree for conflict checking
250
 * @param {string|null} selectedItem - Selected item ID or null
251
 * @param {number|null} editBookingId - Booking ID being edited
252
 * @param {Array} allItemIds - All available item IDs
253
 * @returns {boolean} True if end date should be blocked due to trail period conflicts
254
 */
255
function validateTrailPeriodOptimized(
256
    endDate,
257
    trailDays,
258
    intervalTree,
259
    selectedItem,
260
    editBookingId,
261
    allItemIds
262
) {
263
    if (trailDays <= 0) return false;
264
265
    const trailStart = endDate.add(1, "day");
266
    const trailEnd = endDate.add(trailDays, "day");
267
268
    logger.debug(
269
        `Optimized trail period check: ${formatYMD(trailStart)} to ${formatYMD(
270
            trailEnd
271
        )}`
272
    );
273
274
    // Use range query to get all conflicts in the trail period at once
275
    const trailConflicts = intervalTree.queryRange(
276
        trailStart.valueOf(),
277
        trailEnd.valueOf(),
278
        selectedItem != null ? String(selectedItem) : null
279
    );
280
281
    const relevantTrailConflicts = trailConflicts.filter(
282
        c => !editBookingId || c.metadata.booking_id != editBookingId
283
    );
284
285
    if (selectedItem) {
286
        // For specific item, any conflict in trail period blocks the end date
287
        return relevantTrailConflicts.length > 0;
288
    } else {
289
        // For "any item" mode, need to check if there are conflicts for ALL items
290
        // on ANY day in the trail period
291
        if (relevantTrailConflicts.length === 0) return false;
292
293
        const unavailableItemIds = new Set(
294
            relevantTrailConflicts.map(c => c.itemId)
295
        );
296
        const allUnavailable =
297
            allItemIds.length > 0 &&
298
            allItemIds.every(id => unavailableItemIds.has(String(id)));
299
300
        logger.debug(`Trail period multi-item check (optimized):`, {
301
            trailPeriod: `${trailStart.format(
302
                "YYYY-MM-DD"
303
            )} to ${trailEnd.format("YYYY-MM-DD")}`,
304
            totalItems: allItemIds.length,
305
            conflictsFound: relevantTrailConflicts.length,
306
            unavailableItems: Array.from(unavailableItemIds),
307
            allUnavailable: allUnavailable,
308
            decision: allUnavailable ? "BLOCK" : "ALLOW",
309
        });
310
311
        return allUnavailable;
312
    }
313
}
314
315
/**
316
 * Extracts and validates configuration from circulation rules
317
 * @param {Object} circulationRules - Raw circulation rules object
318
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
319
 * @returns {Object} Normalized configuration object
320
 */
321
function extractBookingConfiguration(circulationRules, todayArg) {
322
    const today = todayArg
323
        ? toDayjs(todayArg).startOf("day")
324
        : dayjs().startOf("day");
325
    const leadDays = Number(circulationRules?.bookings_lead_period) || 0;
326
    const trailDays = Number(circulationRules?.bookings_trail_period) || 0;
327
    // In unconstrained mode, do not enforce a default max period
328
    const maxPeriod =
329
        Number(circulationRules?.maxPeriod) ||
330
        Number(circulationRules?.issuelength) ||
331
        0;
332
    const isEndDateOnly =
333
        circulationRules?.booking_constraint_mode ===
334
        CONSTRAINT_MODE_END_DATE_ONLY;
335
    const calculatedDueDate = circulationRules?.calculated_due_date
336
        ? dayjs(circulationRules.calculated_due_date).startOf("day")
337
        : null;
338
    const calculatedPeriodDays = Number(
339
        circulationRules?.calculated_period_days
340
    )
341
        ? Number(circulationRules.calculated_period_days)
342
        : null;
343
344
    logger.debug("Booking configuration extracted:", {
345
        today: today.format("YYYY-MM-DD"),
346
        leadDays,
347
        trailDays,
348
        maxPeriod,
349
        isEndDateOnly,
350
        rawRules: circulationRules,
351
    });
352
353
    return {
354
        today,
355
        leadDays,
356
        trailDays,
357
        maxPeriod,
358
        isEndDateOnly,
359
        calculatedDueDate,
360
        calculatedPeriodDays,
361
    };
362
}
363
364
/**
365
 * Creates the main disable function that determines if a date should be disabled
366
 * @param {Object} intervalTree - Interval tree for conflict checking
367
 * @param {Object} config - Configuration object from extractBookingConfiguration
368
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems - Array of bookable items
369
 * @param {string|null} selectedItem - Selected item ID or null
370
 * @param {number|null} editBookingId - Booking ID being edited
371
 * @param {Array<Date>} selectedDates - Currently selected dates
372
 * @returns {(date: Date) => boolean} Disable function for Flatpickr
373
 */
374
function createDisableFunction(
375
    intervalTree,
376
    config,
377
    bookableItems,
378
    selectedItem,
379
    editBookingId,
380
    selectedDates
381
) {
382
    const {
383
        today,
384
        leadDays,
385
        trailDays,
386
        maxPeriod,
387
        isEndDateOnly,
388
        calculatedDueDate,
389
    } = config;
390
    const allItemIds = bookableItems.map(i => String(i.item_id));
391
    const strategy = createConstraintStrategy(
392
        isEndDateOnly ? CONSTRAINT_MODE_END_DATE_ONLY : CONSTRAINT_MODE_NORMAL
393
    );
394
395
    return date => {
396
        const dayjs_date = dayjs(date).startOf("day");
397
398
        // Guard clause: Basic past date validation
399
        if (dayjs_date.isBefore(today, "day")) return true;
400
401
        // Guard clause: No bookable items available
402
        if (!bookableItems || bookableItems.length === 0) {
403
            logger.debug(
404
                `Date ${dayjs_date.format(
405
                    "YYYY-MM-DD"
406
                )} disabled - no bookable items available`
407
            );
408
            return true;
409
        }
410
411
        // Mode-specific start date validation
412
        if (
413
            strategy.validateStartDateSelection(
414
                dayjs_date,
415
                {
416
                    today,
417
                    leadDays,
418
                    trailDays,
419
                    maxPeriod,
420
                    isEndDateOnly,
421
                    calculatedDueDate,
422
                },
423
                intervalTree,
424
                selectedItem,
425
                editBookingId,
426
                allItemIds,
427
                selectedDates
428
            )
429
        ) {
430
            return true;
431
        }
432
433
        // Mode-specific intermediate date handling
434
        const intermediateResult = strategy.handleIntermediateDate(
435
            dayjs_date,
436
            selectedDates,
437
            {
438
                today,
439
                leadDays,
440
                trailDays,
441
                maxPeriod,
442
                isEndDateOnly,
443
                calculatedDueDate,
444
            }
445
        );
446
        if (intermediateResult === true) {
447
            return true;
448
        }
449
450
        // Guard clause: Standard point-in-time availability check
451
        const pointConflicts = intervalTree.query(
452
            dayjs_date.valueOf(),
453
            selectedItem != null ? String(selectedItem) : null
454
        );
455
        const relevantPointConflicts = pointConflicts.filter(
456
            interval =>
457
                !editBookingId || interval.metadata.booking_id != editBookingId
458
        );
459
460
        // Guard clause: Specific item conflicts
461
        if (selectedItem && relevantPointConflicts.length > 0) {
462
            logger.debug(
463
                `Date ${dayjs_date.format(
464
                    "YYYY-MM-DD"
465
                )} blocked for item ${selectedItem}:`,
466
                relevantPointConflicts.map(c => c.type)
467
            );
468
            return true;
469
        }
470
471
        // Guard clause: All items unavailable (any item mode)
472
        if (!selectedItem) {
473
            const unavailableItemIds = new Set(
474
                relevantPointConflicts.map(c => c.itemId)
475
            );
476
            const allUnavailable =
477
                allItemIds.length > 0 &&
478
                allItemIds.every(id => unavailableItemIds.has(String(id)));
479
480
            logger.debug(
481
                `Multi-item availability check for ${dayjs_date.format(
482
                    "YYYY-MM-DD"
483
                )}:`,
484
                {
485
                    totalItems: allItemIds.length,
486
                    allItemIds: allItemIds,
487
                    conflictsFound: relevantPointConflicts.length,
488
                    unavailableItemIds: Array.from(unavailableItemIds),
489
                    allUnavailable: allUnavailable,
490
                    decision: allUnavailable ? "BLOCK" : "ALLOW",
491
                }
492
            );
493
494
            if (allUnavailable) {
495
                logger.debug(
496
                    `Date ${dayjs_date.format(
497
                        "YYYY-MM-DD"
498
                    )} blocked - all items unavailable`
499
                );
500
                return true;
501
            }
502
        }
503
504
        // Lead/trail period validation using optimized queries
505
        if (!selectedDates || selectedDates.length === 0) {
506
            // Potential start date - check lead period
507
            if (leadDays > 0) {
508
                logger.debug(
509
                    `Checking lead period for ${dayjs_date.format(
510
                        "YYYY-MM-DD"
511
                    )} (${leadDays} days)`
512
                );
513
            }
514
515
            // Optimized lead period validation using range queries
516
            if (
517
                validateLeadPeriodOptimized(
518
                    dayjs_date,
519
                    leadDays,
520
                    intervalTree,
521
                    selectedItem,
522
                    editBookingId,
523
                    allItemIds
524
                )
525
            ) {
526
                logger.debug(
527
                    `Start date ${dayjs_date.format(
528
                        "YYYY-MM-DD"
529
                    )} blocked - lead period conflict (optimized check)`
530
                );
531
                return true;
532
            }
533
        } else if (
534
            selectedDates[0] &&
535
            (!selectedDates[1] ||
536
                dayjs(selectedDates[1]).isSame(dayjs_date, "day"))
537
        ) {
538
            // Potential end date - check trail period
539
            const start = dayjs(selectedDates[0]).startOf("day");
540
541
            // Basic end date validations
542
            if (dayjs_date.isBefore(start, "day")) return true;
543
            // Respect backend-calculated due date in end_date_only mode only if it's not before start
544
            if (
545
                isEndDateOnly &&
546
                config.calculatedDueDate &&
547
                !config.calculatedDueDate.isBefore(start, "day")
548
            ) {
549
                const targetEnd = config.calculatedDueDate;
550
                if (dayjs_date.isAfter(targetEnd, "day")) return true;
551
            } else if (maxPeriod > 0) {
552
                const maxEndDate = calculateMaxEndDate(start, maxPeriod);
553
                if (dayjs_date.isAfter(maxEndDate, "day"))
554
                    return true;
555
            }
556
557
            // Optimized trail period validation using range queries
558
            if (
559
                validateTrailPeriodOptimized(
560
                    dayjs_date,
561
                    trailDays,
562
                    intervalTree,
563
                    selectedItem,
564
                    editBookingId,
565
                    allItemIds
566
                )
567
            ) {
568
                logger.debug(
569
                    `End date ${dayjs_date.format(
570
                        "YYYY-MM-DD"
571
                    )} blocked - trail period conflict (optimized check)`
572
                );
573
                return true;
574
            }
575
        }
576
577
        return false;
578
    };
579
}
580
581
/**
582
 * Logs comprehensive debug information for OPAC booking selection debugging
583
 * @param {Array} bookings - Array of booking objects
584
 * @param {Array} checkouts - Array of checkout objects
585
 * @param {Array} bookableItems - Array of bookable items
586
 * @param {string|null} selectedItem - Selected item ID
587
 * @param {Object} circulationRules - Circulation rules
588
 */
589
function logBookingDebugInfo(
590
    bookings,
591
    checkouts,
592
    bookableItems,
593
    selectedItem,
594
    circulationRules
595
) {
596
    logger.debug("OPAC Selection Debug:", {
597
        selectedItem: selectedItem,
598
        selectedItemType:
599
            selectedItem === null
600
                ? SELECTION_ANY_AVAILABLE
601
                : SELECTION_SPECIFIC_ITEM,
602
        bookableItems: bookableItems.map(item => ({
603
            item_id: item.item_id,
604
            title: item.title,
605
            item_type_id: item.item_type_id,
606
            holding_library: item.holding_library,
607
            available_pickup_locations: item.available_pickup_locations,
608
        })),
609
        circulationRules: {
610
            booking_constraint_mode: circulationRules?.booking_constraint_mode,
611
            maxPeriod: circulationRules?.maxPeriod,
612
            bookings_lead_period: circulationRules?.bookings_lead_period,
613
            bookings_trail_period: circulationRules?.bookings_trail_period,
614
        },
615
        bookings: bookings.map(b => ({
616
            booking_id: b.booking_id,
617
            item_id: b.item_id,
618
            start_date: b.start_date,
619
            end_date: b.end_date,
620
            patron_id: b.patron_id,
621
        })),
622
        checkouts: checkouts.map(c => ({
623
            item_id: c.item_id,
624
            checkout_date: c.checkout_date,
625
            due_date: c.due_date,
626
            patron_id: c.patron_id,
627
        })),
628
    });
629
}
630
631
/**
632
 * Pure function for Flatpickr's `disable` option.
633
 * Disables dates that overlap with existing bookings or checkouts for the selected item, or when not enough items are available.
634
 * Also handles end_date_only constraint mode by disabling intermediate dates.
635
 *
636
 * @param {Array} bookings - Array of booking objects ({ booking_id, item_id, start_date, end_date })
637
 * @param {Array} checkouts - Array of checkout objects ({ item_id, due_date, ... })
638
 * @param {Array} bookableItems - Array of all bookable item objects (must have item_id)
639
 * @param {number|string|null} selectedItem - The currently selected item (item_id or null for 'any')
640
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
641
 * @param {Array} selectedDates - Array of currently selected dates in Flatpickr (can be empty, or [start], or [start, end])
642
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, booking_constraint_mode, etc.)
643
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
644
 * @param {Object} options - Additional options for optimization
645
 * @returns {import('../../types/bookings').AvailabilityResult}
646
 */
647
export function calculateDisabledDates(
648
    bookings,
649
    checkouts,
650
    bookableItems,
651
    selectedItem,
652
    editBookingId,
653
    selectedDates = [],
654
    circulationRules = {},
655
    todayArg = undefined,
656
    options = {}
657
) {
658
    logger.time("calculateDisabledDates");
659
    const normalizedSelectedItem =
660
        selectedItem != null ? String(selectedItem) : null;
661
    logger.debug("calculateDisabledDates called", {
662
        bookingsCount: bookings.length,
663
        checkoutsCount: checkouts.length,
664
        itemsCount: bookableItems.length,
665
        normalizedSelectedItem,
666
        editBookingId,
667
        selectedDates,
668
        circulationRules,
669
    });
670
671
    // Log comprehensive debug information for OPAC debugging
672
    logBookingDebugInfo(
673
        bookings,
674
        checkouts,
675
        bookableItems,
676
        normalizedSelectedItem,
677
        circulationRules
678
    );
679
680
    // Build IntervalTree with all booking/checkout data
681
    const intervalTree = buildIntervalTree(
682
        bookings,
683
        checkouts,
684
        circulationRules
685
    );
686
687
    // Extract and validate configuration
688
    const config = extractBookingConfiguration(circulationRules, todayArg);
689
    const allItemIds = bookableItems.map(i => String(i.item_id));
690
691
    // Create optimized disable function using extracted helper
692
    const normalizedEditBookingId =
693
        editBookingId != null ? Number(editBookingId) : null;
694
    const disableFunction = createDisableFunction(
695
        intervalTree,
696
        config,
697
        bookableItems,
698
        normalizedSelectedItem,
699
        normalizedEditBookingId,
700
        selectedDates
701
    );
702
703
    // Build unavailableByDate for backward compatibility and markers
704
    // Pass options for performance optimization
705
706
    const unavailableByDate = buildUnavailableByDateMap(
707
        intervalTree,
708
        config.today,
709
        allItemIds,
710
        normalizedEditBookingId,
711
        options
712
    );
713
714
    logger.debug("IntervalTree-based availability calculated", {
715
        treeSize: intervalTree.size,
716
    });
717
    logger.timeEnd("calculateDisabledDates");
718
719
    return {
720
        disable: disableFunction,
721
        unavailableByDate: unavailableByDate,
722
    };
723
}
724
725
/**
726
 * Derive effective circulation rules with constraint options applied.
727
 * - Applies maxPeriod only for constraining modes
728
 * - Strips caps for unconstrained mode
729
 * @param {import('../../types/bookings').CirculationRule} [baseRules={}]
730
 * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
731
 * @returns {import('../../types/bookings').CirculationRule}
732
 */
733
export function deriveEffectiveRules(baseRules = {}, constraintOptions = {}) {
734
    const effectiveRules = { ...baseRules };
735
    const mode = constraintOptions.dateRangeConstraint;
736
    if (mode === "issuelength" || mode === "issuelength_with_renewals") {
737
        if (constraintOptions.maxBookingPeriod) {
738
            effectiveRules.maxPeriod = constraintOptions.maxBookingPeriod;
739
        }
740
    } else {
741
        if ("maxPeriod" in effectiveRules) delete effectiveRules.maxPeriod;
742
        if ("issuelength" in effectiveRules) delete effectiveRules.issuelength;
743
    }
744
    return effectiveRules;
745
}
746
747
/**
748
 * Convenience: take full circulationRules array and constraint options,
749
 * return effective rules applying maxPeriod logic.
750
 * @param {import('../../types/bookings').CirculationRule[]} circulationRules
751
 * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
752
 * @returns {import('../../types/bookings').CirculationRule}
753
 */
754
export function toEffectiveRules(circulationRules, constraintOptions = {}) {
755
    const baseRules = circulationRules?.[0] || {};
756
    return deriveEffectiveRules(baseRules, constraintOptions);
757
}
758
759
/**
760
 * Calculate maximum booking period from circulation rules and constraint mode.
761
 */
762
export function calculateMaxBookingPeriod(
763
    circulationRules,
764
    dateRangeConstraint,
765
    customDateRangeFormula = null
766
) {
767
    if (!dateRangeConstraint) return null;
768
    const rules = circulationRules?.[0];
769
    if (!rules) return null;
770
    const issuelength = parseInt(rules.issuelength) || 0;
771
    switch (dateRangeConstraint) {
772
        case "issuelength":
773
            return issuelength;
774
        case "issuelength_with_renewals":
775
            const renewalperiod = parseInt(rules.renewalperiod) || 0;
776
            const renewalsallowed = parseInt(rules.renewalsallowed) || 0;
777
            return issuelength + renewalperiod * renewalsallowed;
778
        case "custom":
779
            return typeof customDateRangeFormula === "function"
780
                ? customDateRangeFormula(rules)
781
                : null;
782
        default:
783
            return null;
784
    }
785
}
786
787
/**
788
 * Convenience wrapper to calculate availability (disable fn + map) given a dateRange.
789
 * Accepts ISO strings for dateRange and returns the result of calculateDisabledDates.
790
 * @returns {import('../../types/bookings').AvailabilityResult}
791
 */
792
export function calculateAvailabilityData(dateRange, storeData, options = {}) {
793
    const {
794
        bookings,
795
        checkouts,
796
        bookableItems,
797
        circulationRules,
798
        bookingItemId,
799
        bookingId,
800
    } = storeData;
801
802
    if (!bookings || !checkouts || !bookableItems) {
803
        return { disable: () => false, unavailableByDate: {} };
804
    }
805
806
    const baseRules = circulationRules?.[0] || {};
807
    const maxBookingPeriod = calculateMaxBookingPeriod(
808
        circulationRules,
809
        options.dateRangeConstraint,
810
        options.customDateRangeFormula
811
    );
812
    const effectiveRules = deriveEffectiveRules(baseRules, {
813
        dateRangeConstraint: options.dateRangeConstraint,
814
        maxBookingPeriod,
815
    });
816
817
    let selectedDatesArray = [];
818
    if (Array.isArray(dateRange)) {
819
        selectedDatesArray = isoArrayToDates(dateRange);
820
    } else if (typeof dateRange === "string") {
821
        throw new TypeError(
822
            "calculateAvailabilityData expects an array of ISO/date values for dateRange"
823
        );
824
    }
825
826
    return calculateDisabledDates(
827
        bookings,
828
        checkouts,
829
        bookableItems,
830
        bookingItemId,
831
        bookingId,
832
        selectedDatesArray,
833
        effectiveRules
834
    );
835
}
836
837
/**
838
 * Pure function to handle Flatpickr's onChange event logic for booking period selection.
839
 * Determines the valid end date range, applies circulation rules, and returns validation info.
840
 *
841
 * @param {Array} selectedDates - Array of currently selected dates ([start], or [start, end])
842
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, etc.)
843
 * @param {Array} bookings - Array of bookings
844
 * @param {Array} checkouts - Array of checkouts
845
 * @param {Array} bookableItems - Array of all bookable items
846
 * @param {number|string|null} selectedItem - The currently selected item
847
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
848
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
849
 * @returns {Object} - { valid: boolean, errors: Array<string>, newMaxEndDate: Date|null, newMinEndDate: Date|null }
850
 */
851
export function handleBookingDateChange(
852
    selectedDates,
853
    circulationRules,
854
    bookings,
855
    checkouts,
856
    bookableItems,
857
    selectedItem,
858
    editBookingId,
859
    todayArg = undefined,
860
    options = {}
861
) {
862
    logger.time("handleBookingDateChange");
863
    logger.debug("handleBookingDateChange called", {
864
        selectedDates,
865
        circulationRules,
866
        selectedItem,
867
        editBookingId,
868
    });
869
    const dayjsStart = selectedDates[0]
870
        ? toDayjs(selectedDates[0]).startOf("day")
871
        : null;
872
    const dayjsEnd = selectedDates[1]
873
        ? toDayjs(selectedDates[1]).endOf("day")
874
        : null;
875
    const errors = [];
876
    let valid = true;
877
    let newMaxEndDate = null;
878
    let newMinEndDate = null; // Declare and initialize here
879
880
    // Validate: ensure start date is present
881
    if (!dayjsStart) {
882
        errors.push(String($__("Start date is required.")));
883
        valid = false;
884
    } else {
885
        // Apply circulation rules: leadDays, trailDays, maxPeriod (in days)
886
        const leadDays = circulationRules?.leadDays || 0;
887
        const _trailDays = circulationRules?.trailDays || 0; // Still needed for start date check
888
        const maxPeriod =
889
            Number(circulationRules?.maxPeriod) ||
890
            Number(circulationRules?.issuelength) ||
891
            0;
892
893
        // Calculate min end date; max end date only when constrained
894
        newMinEndDate = dayjsStart.add(1, "day").startOf("day");
895
        if (maxPeriod > 0) {
896
            newMaxEndDate = calculateMaxEndDate(dayjsStart, maxPeriod).startOf("day");
897
        } else {
898
            newMaxEndDate = null;
899
        }
900
901
        // Validate: start must be after today + leadDays
902
        const today = todayArg
903
            ? toDayjs(todayArg).startOf("day")
904
            : dayjs().startOf("day");
905
        if (dayjsStart.isBefore(today.add(leadDays, "day"))) {
906
            errors.push(
907
                String($__("Start date is too soon (lead time required)"))
908
            );
909
            valid = false;
910
        }
911
912
        // Validate: end must not be before start (only if end date exists)
913
        if (dayjsEnd && dayjsEnd.isBefore(dayjsStart)) {
914
            errors.push(String($__("End date is before start date")));
915
            valid = false;
916
        }
917
918
        // Validate: period must not exceed maxPeriod unless overridden in end_date_only by backend due date
919
        if (dayjsEnd) {
920
            const isEndDateOnly =
921
                circulationRules?.booking_constraint_mode ===
922
                CONSTRAINT_MODE_END_DATE_ONLY;
923
            const dueStr = circulationRules?.calculated_due_date;
924
            const hasBackendDue = Boolean(dueStr);
925
            if (!isEndDateOnly || !hasBackendDue) {
926
                if (
927
                    maxPeriod > 0 &&
928
                    dayjsEnd.diff(dayjsStart, "day") + 1 > maxPeriod
929
                ) {
930
                    errors.push(
931
                        String($__("Booking period exceeds maximum allowed"))
932
                    );
933
                    valid = false;
934
                }
935
            }
936
        }
937
938
        // Strategy-specific enforcement for end date (e.g., end_date_only)
939
        const strategy = createConstraintStrategy(
940
            circulationRules?.booking_constraint_mode
941
        );
942
        const enforcement = strategy.enforceEndDateSelection(
943
            dayjsStart,
944
            dayjsEnd,
945
            circulationRules
946
        );
947
        if (!enforcement.ok) {
948
            errors.push(
949
                String(
950
                    $__(
951
                        "In end date only mode, you can only select the calculated end date"
952
                    )
953
                )
954
            );
955
            valid = false;
956
        }
957
958
        // Validate: check for booking/checkouts overlap using calculateDisabledDates
959
        // This check is only meaningful if we have at least a start date,
960
        // and if an end date is also present, we check the whole range.
961
        // If only start date, effectively checks that single day.
962
        const endDateForLoop = dayjsEnd || dayjsStart; // If no end date, loop for the start date only
963
964
        const disableFnResults = calculateDisabledDates(
965
            bookings,
966
            checkouts,
967
            bookableItems,
968
            selectedItem,
969
            editBookingId,
970
            selectedDates, // Pass selectedDates
971
            circulationRules, // Pass circulationRules
972
            todayArg, // Pass todayArg
973
            options
974
        );
975
        for (
976
            let d = dayjsStart.clone();
977
            d.isSameOrBefore(endDateForLoop, "day");
978
            d = d.add(1, "day")
979
        ) {
980
            if (disableFnResults.disable(d.toDate())) {
981
                errors.push(
982
                    String(
983
                        $__("Date %s is unavailable.").format(
984
                            d.format("YYYY-MM-DD")
985
                        )
986
                    )
987
                );
988
                valid = false;
989
                break;
990
            }
991
        }
992
    }
993
994
    logger.debug("Date change validation result", { valid, errors });
995
    logger.timeEnd("handleBookingDateChange");
996
997
    return {
998
        valid,
999
        errors,
1000
        newMaxEndDate: newMaxEndDate ? newMaxEndDate.toDate() : null,
1001
        newMinEndDate: newMinEndDate ? newMinEndDate.toDate() : null,
1002
    };
1003
}
1004
1005
/**
1006
 * Aggregate all booking/checkouts for a given date (for calendar indicators)
1007
 * @param {import('../../types/bookings').UnavailableByDate} unavailableByDate - Map produced by buildUnavailableByDateMap
1008
 * @param {string|Date|import("dayjs").Dayjs} dateStr - date to check (YYYY-MM-DD or Date or dayjs)
1009
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems - Array of all bookable items
1010
 * @returns {import('../../types/bookings').CalendarMarker[]} indicators for that date
1011
 */
1012
export function getBookingMarkersForDate(
1013
    unavailableByDate,
1014
    dateStr,
1015
    bookableItems = []
1016
) {
1017
    // Guard against unavailableByDate itself being undefined or null
1018
    if (!unavailableByDate) {
1019
        return []; // No data, so no markers
1020
    }
1021
1022
    const d =
1023
        typeof dateStr === "string"
1024
            ? dayjs(dateStr).startOf("day")
1025
            : dayjs(dateStr).isValid()
1026
            ? dayjs(dateStr).startOf("day")
1027
            : dayjs().startOf("day");
1028
    const key = d.format("YYYY-MM-DD");
1029
    const markers = [];
1030
1031
    const findItem = item_id => {
1032
        if (item_id == null) return undefined;
1033
        return bookableItems.find(i => idsEqual(i?.item_id, item_id));
1034
    };
1035
1036
    const entry = unavailableByDate[key]; // This was line 496
1037
1038
    // Guard against the specific date key not being in the map
1039
    if (!entry) {
1040
        return []; // No data for this specific date, so no markers
1041
    }
1042
1043
    // Now it's safe to use Object.entries(entry)
1044
    for (const [item_id, reasons] of Object.entries(entry)) {
1045
        const item = findItem(item_id);
1046
        for (const reason of reasons) {
1047
            let type = reason;
1048
            // Map IntervalTree/Sweep reasons to CSS class names
1049
            if (type === "booking") type = "booked";
1050
            if (type === "core") type = "booked";
1051
            if (type === "checkout") type = "checked-out";
1052
            // lead and trail periods keep their original names for CSS
1053
            markers.push({
1054
                /** @type {import('../../types/bookings').MarkerType} */
1055
                type: /** @type {any} */ (type),
1056
                item: String(item_id),
1057
                itemName: item?.title || String(item_id),
1058
                barcode: item?.barcode || item?.external_id || null,
1059
            });
1060
        }
1061
    }
1062
    return markers;
1063
}
1064
1065
/**
1066
 * Constrain pickup locations based on selected itemtype or item
1067
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1068
 *
1069
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1070
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1071
 * @param {string|number|null} bookingItemtypeId
1072
 * @param {string|number|null} bookingItemId
1073
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').PickupLocation>}
1074
 */
1075
export function constrainPickupLocations(
1076
    pickupLocations,
1077
    bookableItems,
1078
    bookingItemtypeId,
1079
    bookingItemId
1080
) {
1081
    logger.debug("constrainPickupLocations called", {
1082
        inputLocations: pickupLocations.length,
1083
        bookingItemtypeId,
1084
        bookingItemId,
1085
        bookableItems: bookableItems.length,
1086
        locationDetails: pickupLocations.map(loc => ({
1087
            library_id: loc.library_id,
1088
            pickup_items: loc.pickup_items?.length || 0,
1089
        })),
1090
    });
1091
1092
    if (!bookingItemtypeId && !bookingItemId) {
1093
        logger.debug(
1094
            "constrainPickupLocations: No constraints, returning all locations"
1095
        );
1096
        return buildConstraintResult(pickupLocations, pickupLocations.length);
1097
    }
1098
    const filtered = pickupLocations.filter(loc => {
1099
        if (bookingItemId) {
1100
            return (
1101
                loc.pickup_items && includesId(loc.pickup_items, bookingItemId)
1102
            );
1103
        }
1104
        if (bookingItemtypeId) {
1105
            return (
1106
                loc.pickup_items &&
1107
                bookableItems.some(
1108
                    item =>
1109
                        idsEqual(item.item_type_id, bookingItemtypeId) &&
1110
                        includesId(loc.pickup_items, item.item_id)
1111
                )
1112
            );
1113
        }
1114
        return true;
1115
    });
1116
    logger.debug("constrainPickupLocations result", {
1117
        inputCount: pickupLocations.length,
1118
        outputCount: filtered.length,
1119
        filteredOutCount: pickupLocations.length - filtered.length,
1120
        constraints: {
1121
            bookingItemtypeId,
1122
            bookingItemId,
1123
        },
1124
    });
1125
1126
    return buildConstraintResult(filtered, pickupLocations.length);
1127
}
1128
1129
/**
1130
 * Constrain bookable items based on selected pickup location and/or itemtype
1131
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1132
 *
1133
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1134
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1135
 * @param {string|null} pickupLibraryId
1136
 * @param {string|number|null} bookingItemtypeId
1137
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').BookableItem>}
1138
 */
1139
export function constrainBookableItems(
1140
    bookableItems,
1141
    pickupLocations,
1142
    pickupLibraryId,
1143
    bookingItemtypeId
1144
) {
1145
    logger.debug("constrainBookableItems called", {
1146
        inputItems: bookableItems.length,
1147
        pickupLibraryId,
1148
        bookingItemtypeId,
1149
        pickupLocations: pickupLocations.length,
1150
        itemDetails: bookableItems.map(item => ({
1151
            item_id: item.item_id,
1152
            item_type_id: item.item_type_id,
1153
            title: item.title,
1154
        })),
1155
    });
1156
1157
    if (!pickupLibraryId && !bookingItemtypeId) {
1158
        logger.debug(
1159
            "constrainBookableItems: No constraints, returning all items"
1160
        );
1161
        return buildConstraintResult(bookableItems, bookableItems.length);
1162
    }
1163
    const filtered = bookableItems.filter(item => {
1164
        if (pickupLibraryId && bookingItemtypeId) {
1165
            const found = pickupLocations.find(
1166
                loc =>
1167
                    idsEqual(loc.library_id, pickupLibraryId) &&
1168
                    loc.pickup_items &&
1169
                    includesId(loc.pickup_items, item.item_id)
1170
            );
1171
            const match =
1172
                idsEqual(item.item_type_id, bookingItemtypeId) && found;
1173
            return match;
1174
        }
1175
        if (pickupLibraryId) {
1176
            const found = pickupLocations.find(
1177
                loc =>
1178
                    idsEqual(loc.library_id, pickupLibraryId) &&
1179
                    loc.pickup_items &&
1180
                    includesId(loc.pickup_items, item.item_id)
1181
            );
1182
            return found;
1183
        }
1184
        if (bookingItemtypeId) {
1185
            return idsEqual(item.item_type_id, bookingItemtypeId);
1186
        }
1187
        return true;
1188
    });
1189
    logger.debug("constrainBookableItems result", {
1190
        inputCount: bookableItems.length,
1191
        outputCount: filtered.length,
1192
        filteredOutCount: bookableItems.length - filtered.length,
1193
        filteredItems: filtered.map(item => ({
1194
            item_id: item.item_id,
1195
            item_type_id: item.item_type_id,
1196
            title: item.title,
1197
        })),
1198
        constraints: {
1199
            pickupLibraryId,
1200
            bookingItemtypeId,
1201
        },
1202
    });
1203
1204
    return buildConstraintResult(filtered, bookableItems.length);
1205
}
1206
1207
/**
1208
 * Constrain item types based on selected pickup location or item
1209
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1210
 * @param {Array<import('../../types/bookings').ItemType>} itemTypes
1211
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1212
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1213
 * @param {string|null} pickupLibraryId
1214
 * @param {string|number|null} bookingItemId
1215
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').ItemType>}
1216
 */
1217
export function constrainItemTypes(
1218
    itemTypes,
1219
    bookableItems,
1220
    pickupLocations,
1221
    pickupLibraryId,
1222
    bookingItemId
1223
) {
1224
    if (!pickupLibraryId && !bookingItemId) {
1225
        return buildConstraintResult(itemTypes, itemTypes.length);
1226
    }
1227
    const filtered = itemTypes.filter(type => {
1228
        if (bookingItemId) {
1229
            return bookableItems.some(
1230
                item =>
1231
                    idsEqual(item.item_id, bookingItemId) &&
1232
                    idsEqual(item.item_type_id, type.item_type_id)
1233
            );
1234
        }
1235
        if (pickupLibraryId) {
1236
            return bookableItems.some(
1237
                item =>
1238
                    idsEqual(item.item_type_id, type.item_type_id) &&
1239
                    pickupLocations.find(
1240
                        loc =>
1241
                            idsEqual(loc.library_id, pickupLibraryId) &&
1242
                            loc.pickup_items &&
1243
                            includesId(loc.pickup_items, item.item_id)
1244
                    )
1245
            );
1246
        }
1247
        return true;
1248
    });
1249
    return buildConstraintResult(filtered, itemTypes.length);
1250
}
1251
1252
/**
1253
 * Calculate constraint highlighting data for calendar display
1254
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
1255
 * @param {Object} circulationRules - Circulation rules object
1256
 * @param {Object} constraintOptions - Additional constraint options
1257
 * @returns {import('../../types/bookings').ConstraintHighlighting | null} Constraint highlighting
1258
 */
1259
export function calculateConstraintHighlighting(
1260
    startDate,
1261
    circulationRules,
1262
    constraintOptions = {}
1263
) {
1264
    const strategy = createConstraintStrategy(
1265
        circulationRules?.booking_constraint_mode
1266
    );
1267
    const result = strategy.calculateConstraintHighlighting(
1268
        startDate,
1269
        circulationRules,
1270
        constraintOptions
1271
    );
1272
    logger.debug("Constraint highlighting calculated", result);
1273
    return result;
1274
}
1275
1276
/**
1277
 * Determine if calendar should navigate to show target end date
1278
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
1279
 * @param {Date|import('dayjs').Dayjs} targetEndDate - Calculated target end date
1280
 * @param {import('../../types/bookings').CalendarCurrentView} currentView - Current calendar view info
1281
 * @returns {import('../../types/bookings').CalendarNavigationTarget}
1282
 */
1283
export function getCalendarNavigationTarget(
1284
    startDate,
1285
    targetEndDate,
1286
    currentView = {}
1287
) {
1288
    logger.debug("Checking calendar navigation", {
1289
        startDate,
1290
        targetEndDate,
1291
        currentView,
1292
    });
1293
1294
    const start = toDayjs(startDate);
1295
    const target = toDayjs(targetEndDate);
1296
1297
    // Never navigate backwards if target is before the chosen start
1298
    if (target.isBefore(start, "day")) {
1299
        logger.debug("Target end before start; skip navigation");
1300
        return { shouldNavigate: false };
1301
    }
1302
1303
    // If we know the currently visible range, do not navigate when target is already visible
1304
    if (currentView.visibleStartDate && currentView.visibleEndDate) {
1305
        const visibleStart = toDayjs(currentView.visibleStartDate).startOf(
1306
            "day"
1307
        );
1308
        const visibleEnd = toDayjs(currentView.visibleEndDate).endOf("day");
1309
        const inView =
1310
            target.isSameOrAfter(visibleStart, "day") &&
1311
            target.isSameOrBefore(visibleEnd, "day");
1312
        if (inView) {
1313
            logger.debug("Target end date already visible; no navigation");
1314
            return { shouldNavigate: false };
1315
        }
1316
    }
1317
1318
    // Fallback: navigate when target month differs from start month
1319
    if (start.month() !== target.month() || start.year() !== target.year()) {
1320
        const navigationTarget = {
1321
            shouldNavigate: true,
1322
            targetMonth: target.month(),
1323
            targetYear: target.year(),
1324
            targetDate: target.toDate(),
1325
        };
1326
        logger.debug("Calendar should navigate", navigationTarget);
1327
        return navigationTarget;
1328
    }
1329
1330
    logger.debug("No navigation needed - same month");
1331
    return { shouldNavigate: false };
1332
}
1333
1334
/**
1335
 * Aggregate markers by type for display
1336
 * @param {Array} markers - Array of booking markers
1337
 * @returns {import('../../types/bookings').MarkerAggregation} Aggregated counts by type
1338
 */
1339
export function aggregateMarkersByType(markers) {
1340
    logger.debug("Aggregating markers", { count: markers.length });
1341
1342
    const aggregated = markers.reduce((acc, marker) => {
1343
        // Exclude lead and trail markers from visual display
1344
        if (marker.type !== "lead" && marker.type !== "trail") {
1345
            acc[marker.type] = (acc[marker.type] || 0) + 1;
1346
        }
1347
        return acc;
1348
    }, {});
1349
1350
    logger.debug("Markers aggregated", aggregated);
1351
    return aggregated;
1352
}
1353
1354
// Re-export the new efficient data structure builders
1355
export { buildIntervalTree, processCalendarView };
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs (+245 lines)
Line 0 Link Here
1
import dayjs from "../../../../utils/dayjs.mjs";
2
import { addDays, formatYMD } from "./date-utils.mjs";
3
import { managerLogger as logger } from "./logger.mjs";
4
import { calculateMaxEndDate } from "./manager.mjs";
5
import {
6
    CONSTRAINT_MODE_END_DATE_ONLY,
7
    CONSTRAINT_MODE_NORMAL,
8
} from "./constants.mjs";
9
import { toStringId } from "./id-utils.mjs";
10
11
// Internal helpers for end_date_only mode
12
function validateEndDateOnlyStartDateInternal(
13
    date,
14
    config,
15
    intervalTree,
16
    selectedItem,
17
    editBookingId,
18
    allItemIds
19
) {
20
    // Determine target end date based on backend due date override when available
21
    let targetEndDate;
22
    const due = config?.calculatedDueDate || null;
23
    if (due && !due.isBefore(date, "day")) {
24
        targetEndDate = due.clone();
25
    } else {
26
        const maxPeriod = Number(config?.maxPeriod) || 0;
27
        targetEndDate = maxPeriod > 0 ? calculateMaxEndDate(date, maxPeriod).toDate() : date;
28
    }
29
30
    logger.debug(
31
        `Checking ${CONSTRAINT_MODE_END_DATE_ONLY} range: ${formatYMD(
32
            date
33
        )} to ${formatYMD(targetEndDate)}`
34
    );
35
36
    if (selectedItem) {
37
        const conflicts = intervalTree.queryRange(
38
            date.valueOf(),
39
            targetEndDate.valueOf(),
40
            toStringId(selectedItem)
41
        );
42
        const relevantConflicts = conflicts.filter(
43
            interval =>
44
                !editBookingId || interval.metadata.booking_id != editBookingId
45
        );
46
        return relevantConflicts.length > 0;
47
    } else {
48
        // Any item mode: block if all items are unavailable on any date in the range
49
        for (
50
            let checkDate = date;
51
            checkDate.isSameOrBefore(targetEndDate, "day");
52
            checkDate = checkDate.add(1, "day")
53
        ) {
54
            const dayConflicts = intervalTree.query(checkDate.valueOf());
55
            const relevantDayConflicts = dayConflicts.filter(
56
                interval =>
57
                    !editBookingId ||
58
                    interval.metadata.booking_id != editBookingId
59
            );
60
            const unavailableItemIds = new Set(
61
                relevantDayConflicts.map(c => toStringId(c.itemId))
62
            );
63
            const allItemsUnavailableOnThisDay =
64
                allItemIds.length > 0 &&
65
                allItemIds.every(id => unavailableItemIds.has(toStringId(id)));
66
            if (allItemsUnavailableOnThisDay) {
67
                return true;
68
            }
69
        }
70
        return false;
71
    }
72
}
73
74
function handleEndDateOnlyIntermediateDatesInternal(
75
    date,
76
    selectedDates,
77
    maxPeriod
78
) {
79
    if (!selectedDates || selectedDates.length !== 1) {
80
        return null; // Not applicable
81
    }
82
    const startDate = dayjs(selectedDates[0]).startOf("day");
83
    const expectedEndDate = calculateMaxEndDate(startDate, maxPeriod);
84
    if (date.isSame(expectedEndDate, "day")) {
85
        return null; // Allow normal validation for expected end
86
    }
87
    if (date.isAfter(expectedEndDate, "day")) {
88
        return true; // Hard disable beyond expected end
89
    }
90
    // Intermediate date: leave to UI highlighting (no hard disable)
91
    return null;
92
}
93
94
const EndDateOnlyStrategy = {
95
    name: CONSTRAINT_MODE_END_DATE_ONLY,
96
    validateStartDateSelection(
97
        dayjsDate,
98
        config,
99
        intervalTree,
100
        selectedItem,
101
        editBookingId,
102
        allItemIds,
103
        selectedDates
104
    ) {
105
        if (!selectedDates || selectedDates.length === 0) {
106
            return validateEndDateOnlyStartDateInternal(
107
                dayjsDate,
108
                config,
109
                intervalTree,
110
                selectedItem,
111
                editBookingId,
112
                allItemIds
113
            );
114
        }
115
        return false;
116
    },
117
    handleIntermediateDate(dayjsDate, selectedDates, config) {
118
        // Prefer backend due date when provided and valid; otherwise fall back to maxPeriod
119
        if (config?.calculatedDueDate) {
120
            if (!selectedDates || selectedDates.length !== 1) return null;
121
            const startDate = dayjs(selectedDates[0]).startOf("day");
122
            const due = config.calculatedDueDate;
123
            if (!due.isBefore(startDate, "day")) {
124
                const expectedEndDate = due.clone();
125
                if (dayjsDate.isSame(expectedEndDate, "day")) return null;
126
                if (dayjsDate.isAfter(expectedEndDate, "day")) return true; // disable beyond expected end
127
                return null; // intermediate left to UI highlighting + click prevention
128
            }
129
            // Fall through to maxPeriod handling
130
        }
131
        return handleEndDateOnlyIntermediateDatesInternal(
132
            dayjsDate,
133
            selectedDates,
134
            Number(config?.maxPeriod) || 0
135
        );
136
    },
137
    /**
138
     * @param {Date|import('dayjs').Dayjs} startDate
139
     * @param {import('../../types/bookings').CirculationRule|Object} circulationRules
140
     * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
141
     * @returns {import('../../types/bookings').ConstraintHighlighting|null}
142
     */
143
    calculateConstraintHighlighting(
144
        startDate,
145
        circulationRules,
146
        constraintOptions = {}
147
    ) {
148
        const start = dayjs(startDate).startOf("day");
149
        // Prefer backend-calculated due date when provided (respects closures)
150
        const dueStr = circulationRules?.calculated_due_date;
151
        let targetEnd;
152
        let periodForUi = Number(circulationRules?.calculated_period_days) || 0;
153
        if (dueStr) {
154
            const due = dayjs(dueStr).startOf("day");
155
            const start = dayjs(startDate).startOf("day");
156
            if (!due.isBefore(start, "day")) {
157
                targetEnd = due;
158
            }
159
        }
160
        if (!targetEnd) {
161
            let maxPeriod = constraintOptions.maxBookingPeriod;
162
            if (!maxPeriod) {
163
                maxPeriod =
164
                    Number(circulationRules?.maxPeriod) ||
165
                    Number(circulationRules?.issuelength) ||
166
                    30;
167
            }
168
            if (!maxPeriod) return null;
169
            targetEnd = calculateMaxEndDate(start, maxPeriod);
170
            periodForUi = maxPeriod;
171
        }
172
        const diffDays = Math.max(0, targetEnd.diff(start, "day"));
173
        const blockedIntermediateDates = [];
174
        for (let i = 1; i < diffDays; i++) {
175
            blockedIntermediateDates.push(addDays(start, i).toDate());
176
        }
177
        return {
178
            startDate: start.toDate(),
179
            targetEndDate: targetEnd.toDate(),
180
            blockedIntermediateDates,
181
            constraintMode: CONSTRAINT_MODE_END_DATE_ONLY,
182
            maxPeriod: periodForUi,
183
        };
184
    },
185
    enforceEndDateSelection(dayjsStart, dayjsEnd, circulationRules) {
186
        if (!dayjsEnd) return { ok: true };
187
        const dueStr = circulationRules?.calculated_due_date;
188
        let targetEnd;
189
        if (dueStr) {
190
            const due = dayjs(dueStr).startOf("day");
191
            if (!due.isBefore(dayjsStart, "day")) {
192
                targetEnd = due;
193
            }
194
        }
195
        if (!targetEnd) {
196
            const numericMaxPeriod =
197
                Number(circulationRules?.maxPeriod) ||
198
                Number(circulationRules?.issuelength) ||
199
                0;
200
            targetEnd = addDays(dayjsStart, Math.max(1, numericMaxPeriod) - 1);
201
        }
202
        return {
203
            ok: dayjsEnd.isSame(targetEnd, "day"),
204
            expectedEnd: targetEnd,
205
        };
206
    },
207
};
208
209
const NormalStrategy = {
210
    name: CONSTRAINT_MODE_NORMAL,
211
    validateStartDateSelection() {
212
        return false;
213
    },
214
    handleIntermediateDate() {
215
        return null;
216
    },
217
    /**
218
     * @param {Date|import('dayjs').Dayjs} startDate
219
     * @param {any} _rules
220
     * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
221
     * @returns {import('../../types/bookings').ConstraintHighlighting|null}
222
     */
223
    calculateConstraintHighlighting(startDate, _rules, constraintOptions = {}) {
224
        const start = dayjs(startDate).startOf("day");
225
        const maxPeriod = constraintOptions.maxBookingPeriod;
226
        if (!maxPeriod) return null;
227
        const targetEndDate = calculateMaxEndDate(start, maxPeriod).toDate();
228
        return {
229
            startDate: start.toDate(),
230
            targetEndDate,
231
            blockedIntermediateDates: [],
232
            constraintMode: CONSTRAINT_MODE_NORMAL,
233
            maxPeriod,
234
        };
235
    },
236
    enforceEndDateSelection() {
237
        return { ok: true };
238
    },
239
};
240
241
export function createConstraintStrategy(mode) {
242
    return mode === CONSTRAINT_MODE_END_DATE_ONLY
243
        ? EndDateOnlyStrategy
244
        : NormalStrategy;
245
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js (+69 lines)
Line 0 Link Here
1
import { $__ } from "../../../../i18n/index.js";
2
import { createValidationErrorHandler } from "../../../../utils/validationErrors.js";
3
4
/**
5
 * Booking-specific validation error messages
6
 * Each key maps to a function that returns a translated message
7
 */
8
export const bookingValidationMessages = {
9
    biblionumber_required: () => $__("Biblionumber is required"),
10
    patron_id_required: () => $__("Patron ID is required"),
11
    booking_data_required: () => $__("Booking data is required"),
12
    booking_id_required: () => $__("Booking ID is required"),
13
    no_update_data: () => $__("No update data provided"),
14
    data_required: () => $__("Data is required"),
15
    missing_required_fields: params =>
16
        $__("Missing required fields: %s").format(params.fields),
17
18
    // HTTP failure messages
19
    fetch_bookable_items_failed: params =>
20
        $__("Failed to fetch bookable items: %s %s").format(
21
            params.status,
22
            params.statusText
23
        ),
24
    fetch_bookings_failed: params =>
25
        $__("Failed to fetch bookings: %s %s").format(
26
            params.status,
27
            params.statusText
28
        ),
29
    fetch_checkouts_failed: params =>
30
        $__("Failed to fetch checkouts: %s %s").format(
31
            params.status,
32
            params.statusText
33
        ),
34
    fetch_patron_failed: params =>
35
        $__("Failed to fetch patron: %s %s").format(
36
            params.status,
37
            params.statusText
38
        ),
39
    fetch_patrons_failed: params =>
40
        $__("Failed to fetch patrons: %s %s").format(
41
            params.status,
42
            params.statusText
43
        ),
44
    fetch_pickup_locations_failed: params =>
45
        $__("Failed to fetch pickup locations: %s %s").format(
46
            params.status,
47
            params.statusText
48
        ),
49
    fetch_circulation_rules_failed: params =>
50
        $__("Failed to fetch circulation rules: %s %s").format(
51
            params.status,
52
            params.statusText
53
        ),
54
    create_booking_failed: params =>
55
        $__("Failed to create booking: %s %s").format(
56
            params.status,
57
            params.statusText
58
        ),
59
    update_booking_failed: params =>
60
        $__("Failed to update booking: %s %s").format(
61
            params.status,
62
            params.statusText
63
        ),
64
};
65
66
// Create the booking validation handler
67
export const bookingValidation = createValidationErrorHandler(
68
    bookingValidationMessages
69
);
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs (+110 lines)
Line 0 Link Here
1
/**
2
 * Pure functions for booking validation logic
3
 * Extracted from BookingValidationService to eliminate store coupling
4
 */
5
6
import { handleBookingDateChange } from "./manager.mjs";
7
8
/**
9
 * Validate if user can proceed to step 3 (period selection)
10
 * @param {Object} validationData - All required data for validation
11
 * @param {boolean} validationData.showPatronSelect - Whether patron selection is required
12
 * @param {Object} validationData.bookingPatron - Selected booking patron
13
 * @param {boolean} validationData.showItemDetailsSelects - Whether item details are required
14
 * @param {boolean} validationData.showPickupLocationSelect - Whether pickup location is required
15
 * @param {string} validationData.pickupLibraryId - Selected pickup library ID
16
 * @param {string} validationData.bookingItemtypeId - Selected item type ID
17
 * @param {Array} validationData.itemtypeOptions - Available item type options
18
 * @param {string} validationData.bookingItemId - Selected item ID
19
 * @param {Array} validationData.bookableItems - Available bookable items
20
 * @returns {boolean} Whether the user can proceed to step 3
21
 */
22
export function canProceedToStep3(validationData) {
23
    const {
24
        showPatronSelect,
25
        bookingPatron,
26
        showItemDetailsSelects,
27
        showPickupLocationSelect,
28
        pickupLibraryId,
29
        bookingItemtypeId,
30
        itemtypeOptions,
31
        bookingItemId,
32
        bookableItems,
33
    } = validationData;
34
35
    // Step 1: Patron validation (if required)
36
    if (showPatronSelect && !bookingPatron) {
37
        return false;
38
    }
39
40
    // Step 2: Item details validation
41
    if (showItemDetailsSelects || showPickupLocationSelect) {
42
        if (showPickupLocationSelect && !pickupLibraryId) {
43
            return false;
44
        }
45
        if (showItemDetailsSelects) {
46
            if (!bookingItemtypeId && itemtypeOptions.length > 0) {
47
                return false;
48
            }
49
            if (!bookingItemId && bookableItems.length > 0) {
50
                return false;
51
            }
52
        }
53
    }
54
55
    // Additional validation: Check if there are any bookable items available
56
    if (!bookableItems || bookableItems.length === 0) {
57
        return false;
58
    }
59
60
    return true;
61
}
62
63
/**
64
 * Validate if form can be submitted
65
 * @param {Object} validationData - Data required for step 3 validation
66
 * @param {Array} dateRange - Selected date range
67
 * @returns {boolean} Whether the form can be submitted
68
 */
69
export function canSubmitBooking(validationData, dateRange) {
70
    if (!canProceedToStep3(validationData)) return false;
71
    if (!dateRange || dateRange.length === 0) return false;
72
73
    // For range mode, need both start and end dates
74
    if (Array.isArray(dateRange) && dateRange.length < 2) {
75
        return false;
76
    }
77
78
    return true;
79
}
80
81
/**
82
 * Validate date selection and return detailed result
83
 * @param {Array} selectedDates - Selected dates from calendar
84
 * @param {Array} circulationRules - Circulation rules for validation
85
 * @param {Array} bookings - Existing bookings data
86
 * @param {Array} checkouts - Existing checkouts data
87
 * @param {Array} bookableItems - Available bookable items
88
 * @param {string} bookingItemId - Selected item ID
89
 * @param {string} bookingId - Current booking ID (for updates)
90
 * @returns {Object} Validation result with dates and conflicts
91
 */
92
export function validateDateSelection(
93
    selectedDates,
94
    circulationRules,
95
    bookings,
96
    checkouts,
97
    bookableItems,
98
    bookingItemId,
99
    bookingId
100
) {
101
    return handleBookingDateChange(
102
        selectedDates,
103
        circulationRules,
104
        bookings,
105
        checkouts,
106
        bookableItems,
107
        bookingItemId,
108
        bookingId
109
    );
110
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs (+11 lines)
Line 0 Link Here
1
import { $__ } from "../../../../i18n/index.js";
2
3
export function getMarkerTypeLabel(type) {
4
    const labels = {
5
        booked: $__("Booked"),
6
        "checked-out": $__("Checked out"),
7
        lead: $__("Lead period"),
8
        trail: $__("Trail period"),
9
    };
10
    return labels[type] || type;
11
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs (+34 lines)
Line 0 Link Here
1
import { idsEqual } from "../booking/id-utils.mjs";
2
import { $__ } from "../../../../i18n/index.js";
3
4
export function buildNoItemsAvailableMessage(
5
    pickupLocations,
6
    itemTypes,
7
    pickupLibraryId,
8
    itemtypeId
9
) {
10
    const selectionParts = [];
11
    if (pickupLibraryId) {
12
        const location = (pickupLocations || []).find(l =>
13
            idsEqual(l.library_id, pickupLibraryId)
14
        );
15
        selectionParts.push(
16
            $__("pickup location: %s").format(
17
                (location && location.name) || pickupLibraryId
18
            )
19
        );
20
    }
21
    if (itemtypeId) {
22
        const itemType = (itemTypes || []).find(t =>
23
            idsEqual(t.item_type_id, itemtypeId)
24
        );
25
        selectionParts.push(
26
            $__("item type: %s").format(
27
                (itemType && itemType.description) || itemtypeId
28
            )
29
        );
30
    }
31
    return $__(
32
        "No items are available for booking with the selected criteria (%s). Please adjust your selection."
33
    ).format(selectionParts.join(", "));
34
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs (+58 lines)
Line 0 Link Here
1
/**
2
 * Pure functions for booking step calculation and management
3
 * Extracted from BookingStepService to provide pure, testable functions
4
 */
5
6
/**
7
 * Calculate step numbers based on configuration
8
 * @param {boolean} showPatronSelect - Whether patron selection step is shown
9
 * @param {boolean} showItemDetailsSelects - Whether item details step is shown
10
 * @param {boolean} showPickupLocationSelect - Whether pickup location step is shown
11
 * @param {boolean} showAdditionalFields - Whether additional fields step is shown
12
 * @param {boolean} hasAdditionalFields - Whether additional fields exist
13
 * @returns {Object} Step numbers for each section
14
 */
15
export function calculateStepNumbers(
16
    showPatronSelect,
17
    showItemDetailsSelects,
18
    showPickupLocationSelect,
19
    showAdditionalFields,
20
    hasAdditionalFields
21
) {
22
    let currentStep = 1;
23
    const steps = {
24
        patron: 0,
25
        details: 0,
26
        period: 0,
27
        additionalFields: 0,
28
    };
29
30
    if (showPatronSelect) {
31
        steps.patron = currentStep++;
32
    }
33
34
    if (showItemDetailsSelects || showPickupLocationSelect) {
35
        steps.details = currentStep++;
36
    }
37
38
    steps.period = currentStep++;
39
40
    if (showAdditionalFields && hasAdditionalFields) {
41
        steps.additionalFields = currentStep++;
42
    }
43
44
    return steps;
45
}
46
47
/**
48
 * Determine if additional fields section should be shown
49
 * @param {boolean} showAdditionalFields - Configuration setting for additional fields
50
 * @param {boolean} hasAdditionalFields - Whether additional fields exist
51
 * @returns {boolean} Whether to show additional fields section
52
 */
53
export function shouldShowAdditionalFields(
54
    showAdditionalFields,
55
    hasAdditionalFields
56
) {
57
    return showAdditionalFields && hasAdditionalFields;
58
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json (+30 lines)
Line 0 Link Here
1
{
2
    "compilerOptions": {
3
        "target": "ES2021",
4
        "module": "ES2020",
5
        "moduleResolution": "Node",
6
        "checkJs": true,
7
        "skipLibCheck": true,
8
        "allowJs": true,
9
        "noEmit": true,
10
        "strict": false,
11
        "baseUrl": ".",
12
        "paths": {
13
            "@bookingApi": [
14
                "./lib/adapters/api/staff-interface.js",
15
                "./lib/adapters/api/opac.js"
16
            ]
17
        },
18
        "types": []
19
    },
20
    "include": [
21
        "./**/*.js",
22
        "./**/*.mjs",
23
        "./**/*.vue",
24
        "./**/*.d.ts"
25
    ],
26
    "exclude": [
27
        "node_modules",
28
        "dist"
29
    ]
30
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts (+286 lines)
Line 0 Link Here
1
/**
2
 * Physical item that can be booked (minimum shape used across the UI).
3
 */
4
export type BookableItem = {
5
    /** Internal item identifier */
6
    item_id: Id;
7
    /** Koha item type code */
8
    item_type_id: string;
9
    /** Effective type after MARC policies (when present) */
10
    effective_item_type_id?: string;
11
    /** Owning or home library id */
12
    home_library_id: string;
13
    /** Optional descriptive fields used in UI/logs */
14
    title?: string;
15
    barcode?: string;
16
    external_id?: string;
17
    holding_library?: string;
18
    available_pickup_locations?: any;
19
    /** Localized strings container (when available) */
20
    _strings?: { item_type_id?: { str?: string } };
21
};
22
23
/**
24
 * Booking record (core fields only, as used by the UI).
25
 */
26
export type Booking = {
27
    booking_id: number;
28
    item_id: Id;
29
    start_date: ISODateString;
30
    end_date: ISODateString;
31
    status?: string;
32
    patron_id?: number;
33
};
34
35
/**
36
 * Active checkout record for an item relevant to bookings.
37
 */
38
export type Checkout = {
39
    item_id: Id;
40
    due_date: ISODateString;
41
};
42
43
/**
44
 * Library that can serve as pickup location with optional item whitelist.
45
 */
46
export type PickupLocation = {
47
    library_id: string;
48
    name: string;
49
    /** Allowed item ids for pickup at this location (when restricted) */
50
    pickup_items?: Array<Id>;
51
};
52
53
/**
54
 * Subset of circulation rules used by bookings logic (from backend API).
55
 */
56
export type CirculationRule = {
57
    /** Max booking length in days (effective, UI-enforced) */
58
    maxPeriod?: number;
59
    /** Base issue length in days (backend rule) */
60
    issuelength?: number;
61
    /** Renewal policy: length per renewal (days) */
62
    renewalperiod?: number;
63
    /** Renewal policy: number of renewals allowed */
64
    renewalsallowed?: number;
65
    /** Lead/trail periods around bookings (days) */
66
    leadTime?: number;
67
    leadTimeToday?: boolean;
68
    /** Optional calculated due date from backend (ISO) */
69
    calculated_due_date?: ISODateString;
70
    /** Optional calculated period in days (from backend) */
71
    calculated_period_days?: number;
72
    /** Constraint mode selection */
73
    booking_constraint_mode?: "range" | "end_date_only";
74
};
75
76
/** Visual marker type used in calendar tooltip and markers grid. */
77
export type MarkerType = "booked" | "checked-out" | "lead" | "trail";
78
79
/**
80
 * Visual marker entry for a specific date/item.
81
 */
82
export type Marker = {
83
    type: MarkerType;
84
    barcode?: string;
85
    external_id?: string;
86
    itemnumber?: Id;
87
};
88
89
/**
90
 * Marker used by calendar code (tooltips + aggregation).
91
 * Contains display label (itemName) and resolved barcode (or external id).
92
 */
93
export type CalendarMarker = {
94
    type: MarkerType;
95
    item: string;
96
    itemName: string;
97
    barcode: string | null;
98
};
99
100
/** Minimal item type shape used in constraints */
101
export type ItemType = {
102
    item_type_id: string;
103
    name?: string;
104
};
105
106
/**
107
 * Result of availability calculation: Flatpickr disable function + daily map.
108
 */
109
export type AvailabilityResult = {
110
    disable: DisableFn;
111
    unavailableByDate: UnavailableByDate;
112
};
113
114
/**
115
 * Canonical map of daily unavailability across items.
116
 *
117
 * Keys:
118
 * - Outer key: date in YYYY-MM-DD (calendar day)
119
 * - Inner key: item id as string
120
 * - Value: set of reasons for unavailability on that day
121
 */
122
export type UnavailableByDate = Record<string, Record<string, Set<UnavailabilityReason>>>;
123
124
/** Enumerates reasons an item is not bookable on a specific date. */
125
export type UnavailabilityReason = "booking" | "checkout" | "lead" | "trail" | string;
126
127
/** Disable function for Flatpickr */
128
export type DisableFn = (date: Date) => boolean;
129
130
/** Options affecting constraint calculations (UI + rules composition). */
131
export type ConstraintOptions = {
132
    dateRangeConstraint?: string;
133
    maxBookingPeriod?: number;
134
    /** Start of the currently visible calendar range (on-demand marker build) */
135
    visibleStartDate?: Date;
136
    /** End of the currently visible calendar range (on-demand marker build) */
137
    visibleEndDate?: Date;
138
};
139
140
/** Resulting highlighting metadata for calendar UI. */
141
export type ConstraintHighlighting = {
142
    startDate: Date;
143
    targetEndDate: Date;
144
    blockedIntermediateDates: Date[];
145
    constraintMode: string;
146
    maxPeriod: number;
147
};
148
149
/** Minimal shape of the Pinia booking store used by the UI. */
150
export type BookingStoreLike = {
151
    selectedDateRange?: string[];
152
    circulationRules?: CirculationRule[];
153
    bookings?: Booking[];
154
    checkouts?: Checkout[];
155
    bookableItems?: BookableItem[];
156
    bookingItemId?: Id | null;
157
    bookingId?: Id | null;
158
    unavailableByDate?: UnavailableByDate;
159
};
160
161
/** Store actions used by composables to interact with backend. */
162
export type BookingStoreActions = {
163
    fetchPickupLocations: (
164
        biblionumber: Id,
165
        patronId: Id
166
    ) => Promise<unknown>;
167
    invalidateCalculatedDue: () => void;
168
    fetchCirculationRules: (
169
        params: Record<string, unknown>
170
    ) => Promise<unknown>;
171
};
172
173
/** Dependencies used for updating external widgets after booking changes. */
174
export type ExternalDependencies = {
175
    timeline: () => any;
176
    bookingsTable: () => any;
177
    patronRenderer: () => any;
178
    domQuery: (selector: string) => NodeListOf<HTMLElement>;
179
    logger: {
180
        warn: (msg: any, data?: any) => void;
181
        error: (msg: any, err?: any) => void;
182
        debug?: (msg: any, data?: any) => void;
183
    };
184
};
185
186
/** Generic Ref-like helper for accepting either Vue Ref or plain `{ value }`. */
187
export type RefLike<T> = import('vue').Ref<T> | { value: T };
188
189
/** Minimal patron shape used by composables. */
190
export type PatronLike = {
191
    patron_id?: number | string;
192
    category_id?: string | number;
193
    library_id?: string;
194
    cardnumber?: string;
195
};
196
197
/** Options object for `useDerivedItemType` composable. */
198
export type DerivedItemTypeOptions = {
199
    bookingItemtypeId: import('vue').Ref<string | null | undefined>;
200
    bookingItemId: import('vue').Ref<string | number | null | undefined>;
201
    constrainedItemTypes: import('vue').Ref<Array<ItemType>>;
202
    bookableItems: import('vue').Ref<Array<BookableItem>>;
203
};
204
205
/** Options object for `useDefaultPickup` composable. */
206
export type DefaultPickupOptions = {
207
    bookingPickupLibraryId: import('vue').Ref<string | null | undefined>;
208
    bookingPatron: import('vue').Ref<PatronLike | null>;
209
    pickupLocations: import('vue').Ref<Array<PickupLocation>>;
210
    bookableItems: import('vue').Ref<Array<BookableItem>>;
211
    opacDefaultBookingLibraryEnabled?: boolean | string | number;
212
    opacDefaultBookingLibrary?: string;
213
};
214
215
/** Input shape for `useErrorState`. */
216
export type ErrorStateInit = { message?: string; code?: string | null };
217
/** Return shape for `useErrorState`. */
218
export type ErrorStateResult = {
219
    error: { message: string; code: string | null };
220
    setError: (message: string, code?: string) => void;
221
    clear: () => void;
222
    hasError: import('vue').ComputedRef<boolean>;
223
};
224
225
/** Options for calendar `createOnChange` handler. */
226
export type OnChangeOptions = {
227
    setError?: (msg: string) => void;
228
    tooltipVisibleRef?: { value: boolean };
229
    constraintOptions?: ConstraintOptions;
230
};
231
232
/** Minimal parameter set for circulation rules fetching. */
233
export type RulesParams = {
234
    patron_category_id?: string | number;
235
    item_type_id?: Id;
236
    library_id?: string;
237
};
238
239
/** Flatpickr instance augmented with a cache for constraint highlighting. */
240
export type FlatpickrInstanceWithHighlighting = import('flatpickr/dist/types/instance').Instance & {
241
    _constraintHighlighting?: ConstraintHighlighting | null;
242
};
243
244
/** Convenience alias for stores passed to fetchers. */
245
export type StoreWithActions = BookingStoreLike & BookingStoreActions;
246
247
/** Common result shape for `constrain*` helpers. */
248
export type ConstraintResult<T> = {
249
    filtered: T[];
250
    filteredOutCount: number;
251
    total: number;
252
    constraintApplied: boolean;
253
};
254
255
/** Navigation target calculation for calendar month navigation. */
256
export type CalendarNavigationTarget = {
257
    shouldNavigate: boolean;
258
    targetMonth?: number;
259
    targetYear?: number;
260
    targetDate?: Date;
261
};
262
263
/** Aggregated counts by marker type for the markers grid. */
264
export type MarkerAggregation = Record<string, number>;
265
266
/**
267
 * Current calendar view boundaries (visible date range) for navigation logic.
268
 */
269
export type CalendarCurrentView = {
270
    visibleStartDate?: Date;
271
    visibleEndDate?: Date;
272
};
273
274
/**
275
 * Common identifier type used across UI (string or number).
276
 */
277
export type Id = string | number;
278
279
/** ISO-8601 date string (YYYY-MM-DD or full ISO as returned by backend). */
280
export type ISODateString = string;
281
282
/** Minimal item type shape used in constraints and selection UI. */
283
export type ItemType = {
284
    item_type_id: string;
285
    name?: string;
286
};
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/dayjs-plugins.d.ts (+13 lines)
Line 0 Link Here
1
import "dayjs";
2
declare module "dayjs" {
3
    interface Dayjs {
4
        isSameOrBefore(
5
            date?: import("dayjs").ConfigType,
6
            unit?: import("dayjs").OpUnitType
7
        ): boolean;
8
        isSameOrAfter(
9
            date?: import("dayjs").ConfigType,
10
            unit?: import("dayjs").OpUnitType
11
        ): boolean;
12
    }
13
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/flatpickr-augmentations.d.ts (+17 lines)
Line 0 Link Here
1
// Augment flatpickr Instance to carry cached highlighting data
2
declare module "flatpickr" {
3
    interface Instance {
4
        /** Koha Bookings: cached constraint highlighting for re-application after navigation */
5
        _constraintHighlighting?: import('./bookings').ConstraintHighlighting | null;
6
        /** Koha Bookings: cached loan boundary timestamps for bold styling */
7
        _loanBoundaryTimes?: Set<number>;
8
    }
9
}
10
11
// Augment DOM Element to include flatpickr's custom property used in our UI code
12
declare global {
13
    interface Element {
14
        /** set by flatpickr on day cells */
15
        dateObj?: Date;
16
    }
17
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/KohaAlert.vue (+40 lines)
Line 0 Link Here
1
<template>
2
    <div v-if="show" :class="computedClass" role="alert">
3
        <slot>{{ message }}</slot>
4
        <button
5
            v-if="dismissible"
6
            type="button"
7
            class="close"
8
            aria-label="Close"
9
            @click="$emit('dismiss')"
10
        >
11
            <span aria-hidden="true">&times;</span>
12
        </button>
13
    </div>
14
    <div v-else></div>
15
</template>
16
17
<script>
18
export default {
19
    name: "KohaAlert",
20
    props: {
21
        show: { type: Boolean, default: true },
22
        variant: {
23
            type: String,
24
            default: "info", // info | warning | danger | success | secondary
25
        },
26
        message: { type: String, default: "" },
27
        dismissible: { type: Boolean, default: false },
28
        extraClass: { type: String, default: "" },
29
    },
30
    computed: {
31
        computedClass() {
32
            const base = ["alert", `alert-${this.variant}`];
33
            if (this.dismissible) base.push("alert-dismissible");
34
            if (this.extraClass) base.push(this.extraClass);
35
            return base.join(" ");
36
        },
37
    },
38
};
39
</script>
40
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts (+17 lines)
Lines 4-9 import { $__ } from "../i18n"; Link Here
4
import { useMainStore } from "../stores/main";
4
import { useMainStore } from "../stores/main";
5
import { useNavigationStore } from "../stores/navigation";
5
import { useNavigationStore } from "../stores/navigation";
6
import { useVendorStore } from "../stores/vendors";
6
import { useVendorStore } from "../stores/vendors";
7
import { useBookingStore } from "../stores/bookings";
7
8
8
/**
9
/**
9
 * Represents a web component with an import function and optional configuration.
10
 * Represents a web component with an import function and optional configuration.
Lines 42-47 type WebComponentDynamicImport = { Link Here
42
 */
43
 */
43
export const componentRegistry: Map<string, WebComponentDynamicImport> =
44
export const componentRegistry: Map<string, WebComponentDynamicImport> =
44
    new Map([
45
    new Map([
46
        [
47
            "booking-modal-island",
48
            {
49
                importFn: async () => {
50
                    const module = await import(
51
                        /* webpackChunkName: "booking-modal-island" */
52
                        "../components/Bookings/BookingModal.vue"
53
                    );
54
                    return module.default;
55
                },
56
                config: {
57
                    stores: ["bookings"],
58
                },
59
            },
60
        ],
45
        [
61
        [
46
            "acquisitions-menu",
62
            "acquisitions-menu",
47
            {
63
            {
Lines 85-90 export function hydrate(): void { Link Here
85
            mainStore: useMainStore(pinia),
101
            mainStore: useMainStore(pinia),
86
            navigationStore: useNavigationStore(pinia),
102
            navigationStore: useNavigationStore(pinia),
87
            vendorStore: useVendorStore(pinia),
103
            vendorStore: useVendorStore(pinia),
104
            bookings: useBookingStore(pinia),
88
        };
105
        };
89
106
90
        const islandTagNames = Array.from(componentRegistry.keys()).join(", ");
107
        const islandTagNames = Array.from(componentRegistry.keys()).join(", ");
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/stores/bookings.js (+239 lines)
Line 0 Link Here
1
// bookings.js
2
// Pinia store for booking modal state management
3
4
import { defineStore } from "pinia";
5
import { processApiError } from "../utils/apiErrors.js";
6
import * as bookingApi from "@bookingApi";
7
import {
8
    transformPatronData,
9
    transformPatronsData,
10
} from "../components/Bookings/lib/adapters/patron.mjs";
11
12
/**
13
 * Higher-order function to standardize async operation error handling
14
 * Eliminates repetitive try-catch-finally patterns
15
 */
16
function withErrorHandling(operation, loadingKey, errorKey = null) {
17
    return async function (...args) {
18
        // Use errorKey if provided, otherwise derive from loadingKey
19
        const errorField = errorKey || loadingKey;
20
21
        this.loading[loadingKey] = true;
22
        this.error[errorField] = null;
23
24
        try {
25
            const result = await operation.call(this, ...args);
26
            return result;
27
        } catch (error) {
28
            this.error[errorField] = processApiError(error);
29
            // Re-throw to allow caller to handle if needed
30
            throw error;
31
        } finally {
32
            this.loading[loadingKey] = false;
33
        }
34
    };
35
}
36
37
/**
38
 * State shape with improved organization and consistency
39
 * Maintains backward compatibility with existing API
40
 */
41
42
export const useBookingStore = defineStore("bookings", {
43
    state: () => ({
44
        // System state
45
        dataFetched: false,
46
47
        // Collections - consistent naming and organization
48
        bookableItems: [],
49
        bookings: [],
50
        checkouts: [],
51
        pickupLocations: [],
52
        itemTypes: [],
53
        circulationRules: [],
54
        circulationRulesContext: null, // Track the context used for the last rules fetch
55
        unavailableByDate: {},
56
57
        // Current booking state - normalized property names
58
        bookingId: null,
59
        bookingItemId: null, // kept for backward compatibility
60
        bookingPatron: null,
61
        bookingItemtypeId: null, // kept for backward compatibility
62
        patronId: null,
63
        pickupLibraryId: null,
64
        /**
65
         * Canonical date representation for the bookings UI.
66
         * Always store ISO 8601 strings here (e.g., "2025-03-14T00:00:00.000Z").
67
         * - Widgets (Flatpickr) work with Date objects and must convert to ISO when writing
68
         * - Computation utilities convert ISO -> Date close to the boundary
69
         * - API payloads use ISO strings as-is
70
         */
71
        selectedDateRange: [],
72
73
        // Async operation state - organized structure
74
        loading: {
75
            bookableItems: false,
76
            bookings: false,
77
            checkouts: false,
78
            patrons: false,
79
            bookingPatron: false,
80
            pickupLocations: false,
81
            circulationRules: false,
82
            submit: false,
83
        },
84
        error: {
85
            bookableItems: null,
86
            bookings: null,
87
            checkouts: null,
88
            patrons: null,
89
            bookingPatron: null,
90
            pickupLocations: null,
91
            circulationRules: null,
92
            submit: null,
93
        },
94
    }),
95
96
    actions: {
97
        /**
98
         * Invalidate backend-calculated due values to avoid stale UI when inputs change.
99
         * Keeps the rules object shape but removes calculated fields so consumers
100
         * fall back to maxPeriod-based logic until fresh rules arrive.
101
         */
102
        invalidateCalculatedDue() {
103
            if (Array.isArray(this.circulationRules) && this.circulationRules.length > 0) {
104
                const first = { ...this.circulationRules[0] };
105
                if ("calculated_due_date" in first) delete first.calculated_due_date;
106
                if ("calculated_period_days" in first) delete first.calculated_period_days;
107
                this.circulationRules = [first];
108
            }
109
        },
110
        resetErrors() {
111
            Object.keys(this.error).forEach(key => {
112
                this.error[key] = null;
113
            });
114
        },
115
        setUnavailableByDate(unavailableByDate) {
116
            this.unavailableByDate = unavailableByDate;
117
        },
118
        /**
119
         * Fetch bookable items for a biblionumber
120
         */
121
        fetchBookableItems: withErrorHandling(async function (biblionumber) {
122
            const data = await bookingApi.fetchBookableItems(biblionumber);
123
            this.bookableItems = data;
124
            return data;
125
        }, "bookableItems"),
126
        /**
127
         * Fetch bookings for a biblionumber
128
         */
129
        fetchBookings: withErrorHandling(async function (biblionumber) {
130
            const data = await bookingApi.fetchBookings(biblionumber);
131
            this.bookings = data;
132
            return data;
133
        }, "bookings"),
134
        /**
135
         * Fetch checkouts for a biblionumber
136
         */
137
        fetchCheckouts: withErrorHandling(async function (biblionumber) {
138
            const data = await bookingApi.fetchCheckouts(biblionumber);
139
            this.checkouts = data;
140
            return data;
141
        }, "checkouts"),
142
        /**
143
         * Fetch patrons by search term and page
144
         */
145
        fetchPatron: withErrorHandling(async function (patronId) {
146
            const data = await bookingApi.fetchPatron(patronId);
147
            return transformPatronData(Array.isArray(data) ? data[0] : data);
148
        }, "bookingPatron"),
149
        /**
150
         * Fetch patrons by search term and page
151
         */
152
        fetchPatrons: withErrorHandling(async function (term, page = 1) {
153
            const data = await bookingApi.fetchPatrons(term, page);
154
            return transformPatronsData(data);
155
        }, "patrons"),
156
        /**
157
         * Fetch pickup locations for a biblionumber (optionally filtered by patron)
158
         */
159
        fetchPickupLocations: withErrorHandling(async function (
160
            biblionumber,
161
            patron_id
162
        ) {
163
            const data = await bookingApi.fetchPickupLocations(
164
                biblionumber,
165
                patron_id
166
            );
167
            this.pickupLocations = data;
168
            return data;
169
        },
170
        "pickupLocations"),
171
        /**
172
         * Fetch circulation rules for given context
173
         */
174
        fetchCirculationRules: withErrorHandling(async function (params) {
175
            // Only include defined (non-null, non-undefined) params
176
            const filteredParams = {};
177
            for (const key in params) {
178
                if (
179
                    params[key] !== null &&
180
                    params[key] !== undefined &&
181
                    params[key] !== ""
182
                ) {
183
                    filteredParams[key] = params[key];
184
                }
185
            }
186
            const data = await bookingApi.fetchCirculationRules(filteredParams);
187
            this.circulationRules = data;
188
            // Store the context we requested so we know what specificity we have
189
            this.circulationRulesContext = {
190
                patron_category_id: filteredParams.patron_category_id ?? null,
191
                item_type_id: filteredParams.item_type_id ?? null,
192
                library_id: filteredParams.library_id ?? null,
193
            };
194
            return data;
195
        }, "circulationRules"),
196
        /**
197
         * Derive item types from bookableItems
198
         */
199
        deriveItemTypesFromBookableItems() {
200
            const typesMap = {};
201
            this.bookableItems.forEach(item => {
202
                // Use effective_item_type_id if present, fallback to item_type_id
203
                const typeId = item.effective_item_type_id || item.item_type_id;
204
                if (typeId) {
205
                    // Use the human-readable string if available
206
                    const label = item._strings?.item_type_id?.str ?? typeId;
207
                    typesMap[typeId] = label;
208
                }
209
            });
210
            this.itemTypes = Object.entries(typesMap).map(
211
                ([item_type_id, description]) => ({
212
                    item_type_id,
213
                    description,
214
                })
215
            );
216
        },
217
        /**
218
         * Save (POST) or update (PUT) a booking
219
         * If bookingId is present, update; else, create
220
         */
221
        saveOrUpdateBooking: withErrorHandling(async function (bookingData) {
222
            let result;
223
            if (bookingData.bookingId || bookingData.booking_id) {
224
                // Use bookingId from either field
225
                const id = bookingData.bookingId || bookingData.booking_id;
226
                result = await bookingApi.updateBooking(id, bookingData);
227
                // Update in store
228
                const idx = this.bookings.findIndex(
229
                    b => b.booking_id === result.booking_id
230
                );
231
                if (idx !== -1) this.bookings[idx] = result;
232
            } else {
233
                result = await bookingApi.createBooking(bookingData);
234
                this.bookings.push(result);
235
            }
236
            return result;
237
        }, "submit"),
238
    },
239
});
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/apiErrors.js (+138 lines)
Line 0 Link Here
1
import { $__ } from "../i18n/index.js";
2
3
/**
4
 * Map API error messages to translated versions
5
 *
6
 * This utility translates common Mojolicious::Plugin::OpenAPI and JSON::Validator
7
 * error messages into user-friendly, localized strings.
8
 *
9
 * @param {string} errorMessage - The raw API error message
10
 * @returns {string} - Translated error message
11
 */
12
export function translateApiError(errorMessage) {
13
    if (!errorMessage || typeof errorMessage !== "string") {
14
        return $__("An error occurred.");
15
    }
16
17
    // Common OpenAPI/JSON::Validator error patterns
18
    const errorMappings = [
19
        // Missing required fields
20
        {
21
            pattern: /Missing property/i,
22
            translation: $__("Required field is missing."),
23
        },
24
        {
25
            pattern: /Expected (\w+) - got (\w+)/i,
26
            translation: $__("Invalid data type provided."),
27
        },
28
        {
29
            pattern: /String is too (long|short)/i,
30
            translation: $__("Text length is invalid."),
31
        },
32
        {
33
            pattern: /Not in enum list/i,
34
            translation: $__("Invalid value selected."),
35
        },
36
        {
37
            pattern: /Failed to parse JSON/i,
38
            translation: $__("Invalid data format."),
39
        },
40
        {
41
            pattern: /Schema validation failed/i,
42
            translation: $__("Data validation failed."),
43
        },
44
        {
45
            pattern: /Bad Request/i,
46
            translation: $__("Invalid request."),
47
        },
48
        // Generic fallbacks
49
        {
50
            pattern: /Something went wrong/i,
51
            translation: $__("An unexpected error occurred."),
52
        },
53
        {
54
            pattern: /Internal Server Error/i,
55
            translation: $__("A server error occurred."),
56
        },
57
        {
58
            pattern: /Not Found/i,
59
            translation: $__("The requested resource was not found."),
60
        },
61
        {
62
            pattern: /Unauthorized/i,
63
            translation: $__("You are not authorized to perform this action."),
64
        },
65
        {
66
            pattern: /Forbidden/i,
67
            translation: $__("Access to this resource is forbidden."),
68
        },
69
        {
70
            pattern: /Object not found/i,
71
            translation: $__("The requested item was not found."),
72
        },
73
    ];
74
75
    // Try to match error patterns
76
    for (const mapping of errorMappings) {
77
        if (mapping.pattern.test(errorMessage)) {
78
            return mapping.translation;
79
        }
80
    }
81
82
    // If no pattern matches, return a generic translated error
83
    return $__("An error occurred: %s").format(errorMessage);
84
}
85
86
/**
87
 * Extract error message from various error response formats
88
 * @param {Error|Object|string} error - API error response
89
 * @returns {string} - Raw error message
90
 */
91
function extractErrorMessage(error) {
92
    const extractors = [
93
        // Direct string
94
        err => (typeof err === "string" ? err : null),
95
96
        // OpenAPI validation errors format: { errors: [{ message: "...", path: "..." }] }
97
        err => {
98
            const errors = err?.response?.data?.errors;
99
            if (Array.isArray(errors) && errors.length > 0) {
100
                return errors.map(e => e.message || e).join(", ");
101
            }
102
            return null;
103
        },
104
105
        // Standard API error response with 'error' field
106
        err => err?.response?.data?.error,
107
108
        // Standard API error response with 'message' field
109
        err => err?.response?.data?.message,
110
111
        // Error object message
112
        err => err?.message,
113
114
        // HTTP status text
115
        err => err?.statusText,
116
117
        // Default fallback
118
        () => "Unknown error",
119
    ];
120
121
    for (const extractor of extractors) {
122
        const message = extractor(error);
123
        if (message) return message;
124
    }
125
126
    return "Unknown error"; // This should never be reached due to the fallback extractor
127
}
128
129
/**
130
 * Process API error response and extract user-friendly message
131
 *
132
 * @param {Error|Object|string} error - API error response
133
 * @returns {string} - Translated error message
134
 */
135
export function processApiError(error) {
136
    const errorMessage = extractErrorMessage(error);
137
    return translateApiError(errorMessage);
138
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/dayjs.mjs (+28 lines)
Line 0 Link Here
1
// Adapter for dayjs to use the globally loaded instance from js-date-format.inc
2
// This prevents duplicate bundling and maintains TypeScript support
3
4
/** @typedef {typeof import('dayjs')} DayjsModule */
5
/** @typedef {import('dayjs').PluginFunc} DayjsPlugin */
6
7
if (!window["dayjs"]) {
8
    throw new Error("dayjs is not available globally. Please ensure js-date-format.inc is included before this module.");
9
}
10
11
/** @type {DayjsModule} */
12
const dayjs = /** @type {DayjsModule} */ (window["dayjs"]);
13
14
// Required plugins for booking functionality
15
const requiredPlugins = [
16
    { name: 'isSameOrBefore', global: 'dayjs_plugin_isSameOrBefore' },
17
    { name: 'isSameOrAfter', global: 'dayjs_plugin_isSameOrAfter' }
18
];
19
20
// Verify and extend required plugins
21
for (const plugin of requiredPlugins) {
22
    if (!(plugin.global in window)) {
23
        throw new Error(`Required dayjs plugin '${plugin.name}' is not available. Please ensure js-date-format.inc loads the ${plugin.name} plugin.`);
24
    }
25
    dayjs.extend(/** @type {DayjsPlugin} */ (window[plugin.global]));
26
}
27
28
export default dayjs;
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/utils/validationErrors.js (+69 lines)
Line 0 Link Here
1
import { $__ } from "../i18n/index.js";
2
3
/**
4
 * Generic validation error factory
5
 *
6
 * Creates a validation error handler with injected message mappings
7
 * @param {Object} messageMappings - Object mapping error keys to translation functions
8
 * @returns {Object} - Object with validation error methods
9
 */
10
export function createValidationErrorHandler(messageMappings) {
11
    /**
12
     * Create a validation error with translated message
13
     * @param {string} errorKey - The error key to look up
14
     * @param {Object} params - Optional parameters for string formatting
15
     * @returns {Error} - Error object with translated message
16
     */
17
    function validationError(errorKey, params = {}) {
18
        const messageFunc = messageMappings[errorKey];
19
20
        if (!messageFunc) {
21
            // Fallback for unknown error keys
22
            return new Error($__("Validation error: %s").format(errorKey));
23
        }
24
25
        // Call the message function with params to get translated message
26
        const message = messageFunc(params);
27
        /** @type {Error & { status?: number }} */
28
        const error = Object.assign(new Error(message), {});
29
30
        // If status is provided in params, set it on the error object
31
        if (params.status !== undefined) {
32
            error.status = params.status;
33
        }
34
35
        return error;
36
    }
37
38
    /**
39
     * Validate required fields
40
     * @param {Object} data - Data object to validate
41
     * @param {Array<string>} requiredFields - List of required field names
42
     * @param {string} errorKey - Error key to use if validation fails
43
     * @returns {Error|null} - Error if validation fails, null if passes
44
     */
45
    function validateRequiredFields(
46
        data,
47
        requiredFields,
48
        errorKey = "missing_required_fields"
49
    ) {
50
        if (!data) {
51
            return validationError("data_required");
52
        }
53
54
        const missingFields = requiredFields.filter(field => !data[field]);
55
56
        if (missingFields.length > 0) {
57
            return validationError(errorKey, {
58
                fields: missingFields.join(", "),
59
            });
60
        }
61
62
        return null;
63
    }
64
65
    return {
66
        validationError,
67
        validateRequiredFields,
68
    };
69
}
(-)a/rspack.config.js (-1 / +90 lines)
Lines 11-16 module.exports = [ Link Here
11
                    __dirname,
11
                    __dirname,
12
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
12
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
13
                ),
13
                ),
14
                "@bookingApi": path.resolve(
15
                    __dirname,
16
                    "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js"
17
                ),
14
                "@koha-vue": path.resolve(
18
                "@koha-vue": path.resolve(
15
                    __dirname,
19
                    __dirname,
16
                    "koha-tmpl/intranet-tmpl/prog/js/vue"
20
                    "koha-tmpl/intranet-tmpl/prog/js/vue"
Lines 95-100 module.exports = [ Link Here
95
                    __dirname,
99
                    __dirname,
96
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
100
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
97
                ),
101
                ),
102
                "@bookingApi": path.resolve(
103
                    __dirname,
104
                    "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js"
105
                ),
98
            },
106
            },
99
        },
107
        },
100
        experiments: {
108
        experiments: {
Lines 166-171 module.exports = [ Link Here
166
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
174
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
167
        },
175
        },
168
    },
176
    },
177
    {
178
        resolve: {
179
            alias: {
180
                "@fetch": path.resolve(
181
                    __dirname,
182
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
183
                ),
184
                "@bookingApi": path.resolve(
185
                    __dirname,
186
                    "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js"
187
                ),
188
            },
189
        },
190
        experiments: {
191
            outputModule: true,
192
        },
193
        entry: {
194
            islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts",
195
        },
196
        output: {
197
            filename: "[name].esm.js",
198
            path: path.resolve(
199
                __dirname,
200
                "koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/"
201
            ),
202
            chunkFilename: "[name].[contenthash].esm.js",
203
            globalObject: "window",
204
            library: {
205
                type: "module",
206
            },
207
        },
208
        module: {
209
            rules: [
210
                {
211
                    test: /\.vue$/,
212
                    loader: "vue-loader",
213
                    options: {
214
                        experimentalInlineMatchResource: true,
215
                    },
216
                    exclude: [path.resolve(__dirname, "t/cypress/")],
217
                },
218
                {
219
                    test: /\.ts$/,
220
                    loader: "builtin:swc-loader",
221
                    options: {
222
                        jsc: {
223
                            parser: {
224
                                syntax: "typescript",
225
                            },
226
                        },
227
                        appendTsSuffixTo: [/\.vue$/],
228
                    },
229
                    exclude: [
230
                        /node_modules/,
231
                        path.resolve(__dirname, "t/cypress/"),
232
                    ],
233
                    type: "javascript/auto",
234
                },
235
                {
236
                    test: /\.css$/i,
237
                    type: "javascript/auto",
238
                    use: ["style-loader", "css-loader"],
239
                },
240
            ],
241
        },
242
        plugins: [
243
            new VueLoaderPlugin(),
244
            new rspack.DefinePlugin({
245
                __VUE_OPTIONS_API__: true,
246
                __VUE_PROD_DEVTOOLS__: false,
247
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
248
            }),
249
        ],
250
        externals: {
251
            jquery: "jQuery",
252
            "datatables.net": "DataTable",
253
            "datatables.net-buttons": "DataTable",
254
            "datatables.net-buttons/js/buttons.html5": "DataTable",
255
            "datatables.net-buttons/js/buttons.print": "DataTable",
256
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
257
        },
258
    },
169
    {
259
    {
170
        entry: {
260
        entry: {
171
            "api-client.cjs":
261
            "api-client.cjs":
172
- 

Return to bug 41129