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 286-295 Link Here
286
        <a id="audit_record" class="btn btn-default" href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% biblionumber | html %]&audit=1" role="button"> <i class="fa-solid fa-stethoscope"></i> Audit</a>
286
        <a id="audit_record" class="btn btn-default" href="/cgi-bin/koha/catalogue/detail.pl?biblionumber=[% biblionumber | html %]&audit=1" role="button"> <i class="fa-solid fa-stethoscope"></i> Audit</a>
287
    </div>
287
    </div>
288
288
289
    [% IF ( Koha.Preference('EnableBooking') &&  CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %]
289
    [% IF ( Koha.Preference('EnableBooking') && CAN_user_circulate_manage_bookings && biblio.items.filter_by_bookable.count ) %]
290
        <div class="btn-group"
290
        [% INCLUDE 'modals/booking/button-place.inc' %]
291
            ><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
291
        [% INCLUDE 'modals/booking/island.inc' %]
292
        >
293
    [% END %]
292
    [% END %]
294
293
295
    [% IF Koha.Preference('ArticleRequests') %]
294
    [% IF Koha.Preference('ArticleRequests') %]
Lines 345-349 Link Here
345
        </div>
344
        </div>
346
    </div>
345
    </div>
347
</div>
346
</div>
348
349
[% 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 (-36 / +19 lines)
Lines 62-74 Link Here
62
    <script>
62
    <script>
63
        dayjs.extend(window.dayjs_plugin_isSameOrBefore);
63
        dayjs.extend(window.dayjs_plugin_isSameOrBefore);
64
    </script>
64
    </script>
65
    [% Asset.js("js/modals/place_booking.js") | $raw %]
66
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
65
    [% Asset.js("js/cancel_booking_modal.js") | $raw %]
67
    [% Asset.js("js/combobox.js") | $raw %]
66
    [% Asset.js("js/combobox.js") | $raw %]
68
    [% Asset.js("js/additional-filters.js") | $raw %]
67
    [% Asset.js("js/additional-filters.js") | $raw %]
69
    <script>
68
    <script>
70
        var cancel_success = 0;
69
    var cancel_success = 0;
71
        var update_success = 0;
72
        var bookings_table;
70
        var bookings_table;
73
        var timeline;
71
        var timeline;
74
        let biblionumber = "[% biblionumber | uri %]";
72
        let biblionumber = "[% biblionumber | uri %]";
Lines 170-208 Link Here
170
168
171
                        // action events
169
                        // action events
172
                        onMove: function (data, callback) {
170
                        onMove: function (data, callback) {
173
                            let startDate = dayjs(data.start);
171
                            const startDate = dayjs(data.start).toISOString();
172
                            const endDate = dayjs(data.end)
173
                                .endOf('day')
174
                                .toISOString();
174
175
175
                            // set end datetime hours and minutes to the end of the day
176
                            if (typeof window.openBookingModal === 'function') {
176
                            let endDate = dayjs(data.end).endOf("day");
177
                                window.openBookingModal({
178
                                    booking: data.id,
179
                                    biblionumber: "[% biblionumber | html %]",
180
                                    itemnumber: data.group,
181
                                    patron: data.patron,
182
                                    pickup_library: data.pickup_library,
183
                                    start_date: startDate,
184
                                    end_date: endDate,
185
                                    item_type_id: data.item_type_id,
186
                                });
187
                            }
177
188
178
                            $("#placeBookingModal").modal(
189
                            callback(null);
179
                                "show",
180
                                $(
181
                                    '<button data-booking="' +
182
                                        data.id +
183
                                        '"  data-biblionumber="' +
184
                                        biblionumber +
185
                                        '"  data-itemnumber="' +
186
                                        data.group +
187
                                        '" data-patron="' +
188
                                        data.patron +
189
                                        '" data-pickup_library="' +
190
                                        data.pickup_library +
191
                                        '" data-start_date="' +
192
                                        startDate.toISOString() +
193
                                        '" data-end_date="' +
194
                                        endDate.toISOString() +
195
                                        '">'
196
                                )
197
                            );
198
                            $("#placeBookingModal").on("hide.bs.modal", function (e) {
199
                                if (update_success) {
200
                                    update_success = 0;
201
                                    callback(data);
202
                                } else {
203
                                    callback(null);
204
                                }
205
                            });
206
                        },
190
                        },
207
                        onRemove: function (item, callback) {
191
                        onRemove: function (item, callback) {
208
                            const button = document.createElement("button");
192
                            const button = document.createElement("button");
Lines 377-384 Link Here
377
                                if (permissions.CAN_user_circulate_manage_bookings && !is_readonly) {
361
                                if (permissions.CAN_user_circulate_manage_bookings && !is_readonly) {
378
                                    result += `
362
                                    result += `
379
                                <button type="button" class="btn btn-default btn-xs edit-action"
363
                                <button type="button" class="btn btn-default btn-xs edit-action"
380
                                    data-bs-toggle="modal"
364
                                    data-booking-modal
381
                                    data-bs-target="#placeBookingModal"
382
                                    data-booking="%s"
365
                                    data-booking="%s"
383
                                    data-biblionumber="%s"
366
                                    data-biblionumber="%s"
384
                                    data-itemnumber="%s"
367
                                    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 1735-1741 Link Here
1735
    [% Asset.js("js/browser.js") | $raw %]
1735
    [% Asset.js("js/browser.js") | $raw %]
1736
1736
1737
    [% IF Koha.Preference('EnableBooking') %]
1737
    [% IF Koha.Preference('EnableBooking') %]
1738
        [% Asset.js("js/modals/place_booking.js") | $raw %]
1739
    [% END %]
1738
    [% END %]
1740
    <script>
1739
    <script>
1741
        var browser;
1740
        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 599-605 Link Here
599
    [% IF Koha.Preference('EnableBooking') %]
599
    [% IF Koha.Preference('EnableBooking') %]
600
        [% INCLUDE 'calendar.inc' %]
600
        [% INCLUDE 'calendar.inc' %]
601
        [% INCLUDE 'select2.inc' %]
601
        [% INCLUDE 'select2.inc' %]
602
        [% Asset.js("js/modals/place_booking.js") | $raw %]
603
    [% END %]
602
    [% END %]
604
603
605
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
604
    [% IF ( Koha.Preference('CatalogConcerns') ) %]
(-)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 (+1052 lines)
Line 0 Link Here
1
<template>
2
    <div
3
        ref="modalElement"
4
        class="modal fade"
5
        tabindex="-1"
6
        role="dialog"
7
    >
8
        <div
9
            class="modal-dialog modal-lg"
10
            role="document"
11
        >
12
            <div class="modal-content">
13
                <div class="modal-header">
14
                    <h1 class="modal-title fs-5">
15
                        {{ modalTitle }}
16
                    </h1>
17
                    <button
18
                        type="button"
19
                        class="btn-close"
20
                        aria-label="Close"
21
                        @click="handleClose"
22
                    ></button>
23
                </div>
24
                <div class="modal-body">
25
                    <form
26
                        id="form-booking"
27
                        :action="submitUrl"
28
                        method="post"
29
                        @submit.prevent="handleSubmit"
30
                    >
31
                        <KohaAlert
32
                            :show="showCapacityWarning"
33
                            variant="warning"
34
                            :message="zeroCapacityMessage"
35
                        />
36
                        <BookingPatronStep
37
                            v-if="showPatronSelect"
38
                            v-model="bookingPatron"
39
                            :step-number="stepNumber.patron"
40
                            :set-error="setError"
41
                        />
42
                        <hr
43
                            v-if="
44
                                showPatronSelect ||
45
                                showItemDetailsSelects ||
46
                                showPickupLocationSelect
47
                            "
48
                        />
49
                        <BookingDetailsStep
50
                            v-if="
51
                                showItemDetailsSelects ||
52
                                showPickupLocationSelect
53
                            "
54
                            :step-number="stepNumber.details"
55
                            :details-enabled="readiness.dataReady"
56
                            :show-item-details-selects="showItemDetailsSelects"
57
                            :show-pickup-location-select="
58
                                showPickupLocationSelect
59
                            "
60
                            :selected-patron="bookingPatron"
61
                            :patron-required="showPatronSelect"
62
                            v-model:pickup-library-id="pickupLibraryId"
63
                            v-model:itemtype-id="bookingItemtypeId"
64
                            v-model:item-id="bookingItemId"
65
                            :constrained-pickup-locations="
66
                                constrainedPickupLocations
67
                            "
68
                            :constrained-item-types="constrainedItemTypes"
69
                            :constrained-bookable-items="
70
                                constrainedBookableItems
71
                            "
72
                            :constrained-flags="constrainedFlags"
73
                            :pickup-locations-total="pickupLocationsTotal"
74
                            :pickup-locations-filtered-out="
75
                                pickupLocationsFilteredOut
76
                            "
77
                            :bookable-items-total="bookableItemsTotal"
78
                            :bookable-items-filtered-out="
79
                                bookableItemsFilteredOut
80
                            "
81
                        />
82
                        <hr
83
                            v-if="
84
                                showItemDetailsSelects ||
85
                                showPickupLocationSelect
86
                            "
87
                        />
88
                        <BookingPeriodStep
89
                            :step-number="stepNumber.period"
90
                            :calendar-enabled="readiness.isCalendarReady"
91
                            :constraint-options="constraintOptions"
92
                            :date-range-constraint="dateRangeConstraint"
93
                            :max-booking-period="maxBookingPeriod"
94
                            :error-message="uiError.message"
95
                            :set-error="setError"
96
                            :has-selected-dates="selectedDateRange?.length > 0"
97
                            @clear-dates="clearDateRange"
98
                        />
99
                        <hr
100
                            v-if="
101
                                showAdditionalFields &&
102
                                modalState.hasAdditionalFields
103
                            "
104
                        />
105
                    </form>
106
                </div>
107
                <div class="modal-footer">
108
                    <div class="d-flex gap-2">
109
                        <button
110
                            class="btn btn-primary"
111
                            :disabled="loading.submit || !isSubmitReady"
112
                            type="submit"
113
                            form="form-booking"
114
                        >
115
                            {{ submitLabel }}
116
                        </button>
117
                        <button
118
                            class="btn btn-secondary ml-2"
119
                            @click.prevent="handleClose"
120
                        >
121
                            {{ $__("Cancel") }}
122
                        </button>
123
                    </div>
124
                </div>
125
            </div>
126
        </div>
127
    </div>
128
</template>
129
130
<script>
131
import { toISO } from "./lib/booking/date-utils.mjs";
132
import {
133
    computed,
134
    reactive,
135
    watch,
136
    ref,
137
    onMounted,
138
    onUnmounted,
139
} from "vue";
140
import { Modal } from "bootstrap";
141
import BookingPatronStep from "./BookingPatronStep.vue";
142
import BookingDetailsStep from "./BookingDetailsStep.vue";
143
import BookingPeriodStep from "./BookingPeriodStep.vue";
144
import { $__ } from "../../i18n";
145
import { processApiError } from "../../utils/apiErrors.js";
146
import {
147
    constrainBookableItems,
148
    constrainItemTypes,
149
    constrainPickupLocations,
150
} from "./lib/booking/manager.mjs";
151
import { useBookingStore } from "../../stores/bookings";
152
import { storeToRefs } from "pinia";
153
import { updateExternalDependents } from "./lib/adapters/external-dependents.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
        const modalElement = ref(null);
221
        let bsModal = null;
222
223
        // Properly destructure reactive store state using storeToRefs
224
        const {
225
            bookingId,
226
            bookingItemId,
227
            bookingPatron,
228
            bookingItemtypeId,
229
            pickupLibraryId,
230
            selectedDateRange,
231
            bookableItems,
232
            bookings,
233
            checkouts,
234
            pickupLocations,
235
            itemTypes,
236
            circulationRules,
237
            circulationRulesContext,
238
            loading,
239
        } = storeToRefs(store);
240
241
        // Calculate max booking period from circulation rules and selected constraint
242
243
        // Use validation composable for reactive validation logic
244
        const { canSubmit: canSubmitReactive } = useBookingValidation(store);
245
246
        // Grouped reactive state following Vue 3 best practices
247
        const modalState = reactive({
248
            isOpen: props.open,
249
            step: 1,
250
            hasAdditionalFields: false,
251
        });
252
        const { error: uiError, setError, clear: clearError } = useErrorState();
253
254
        const modalTitle = computed(
255
            () =>
256
                props.title ||
257
                (bookingId.value ? $__("Edit booking") : $__("Place booking"))
258
        );
259
260
        // Determine whether to show pickup location select
261
        // In OPAC: show if default library is not enabled
262
        // In staff: use the explicit prop
263
        const showPickupLocationSelect = computed(() => {
264
            if (props.opacDefaultBookingLibraryEnabled !== null) {
265
                const enabled = props.opacDefaultBookingLibraryEnabled === true ||
266
                    String(props.opacDefaultBookingLibraryEnabled) === "1";
267
                return !enabled;
268
            }
269
            return props.showPickupLocationSelect;
270
        });
271
272
        const stepNumber = computed(() => {
273
            return calculateStepNumbers(
274
                props.showPatronSelect,
275
                props.showItemDetailsSelects,
276
                showPickupLocationSelect.value,
277
                props.showAdditionalFields,
278
                modalState.hasAdditionalFields
279
            );
280
        });
281
282
        const submitLabel = computed(() =>
283
            bookingId.value ? $__("Update booking") : $__("Place booking")
284
        );
285
286
        const isFormSubmission = computed(
287
            () => props.submitType === "form-submission"
288
        );
289
290
        // pickupLibraryId is a ref from the store; use directly
291
292
        // Constraint flags computed from pure function results
293
        const constrainedFlags = computed(() => ({
294
            pickupLocations: pickupLocationConstraint.value.constraintApplied,
295
            bookableItems: bookableItemsConstraint.value.constraintApplied,
296
            itemTypes: itemTypeConstraint.value.constraintApplied,
297
        }));
298
299
        const pickupLocationConstraint = computed(() =>
300
            constrainPickupLocations(
301
                pickupLocations.value,
302
                bookableItems.value,
303
                bookingItemtypeId.value,
304
                bookingItemId.value
305
            )
306
        );
307
        const constrainedPickupLocations = computed(
308
            () => pickupLocationConstraint.value.filtered
309
        );
310
        const pickupLocationsFilteredOut = computed(
311
            () => pickupLocationConstraint.value.filteredOutCount
312
        );
313
        const pickupLocationsTotal = computed(
314
            () => pickupLocationConstraint.value.total
315
        );
316
317
        const bookableItemsConstraint = computed(() =>
318
            constrainBookableItems(
319
                bookableItems.value,
320
                pickupLocations.value,
321
                pickupLibraryId.value,
322
                bookingItemtypeId.value
323
            )
324
        );
325
        const constrainedBookableItems = computed(
326
            () => bookableItemsConstraint.value.filtered
327
        );
328
        const bookableItemsFilteredOut = computed(
329
            () => bookableItemsConstraint.value.filteredOutCount
330
        );
331
        const bookableItemsTotal = computed(
332
            () => bookableItemsConstraint.value.total
333
        );
334
335
        const itemTypeConstraint = computed(() =>
336
            constrainItemTypes(
337
                itemTypes.value,
338
                bookableItems.value,
339
                pickupLocations.value,
340
                pickupLibraryId.value,
341
                bookingItemId.value
342
            )
343
        );
344
        const constrainedItemTypes = computed(
345
            () => itemTypeConstraint.value.filtered
346
        );
347
348
        const maxBookingPeriod = computed(() =>
349
            calculateMaxBookingPeriod(
350
                circulationRules.value,
351
                props.dateRangeConstraint,
352
                props.customDateRangeFormula
353
            )
354
        );
355
356
        const constraintOptions = computed(() => ({
357
            dateRangeConstraint: props.dateRangeConstraint,
358
            maxBookingPeriod: maxBookingPeriod.value,
359
        }));
360
361
        // Centralized capacity guard (extracts UI warning state)
362
        const { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning } =
363
            useCapacityGuard({
364
                circulationRules,
365
                circulationRulesContext,
366
                loading,
367
                bookableItems,
368
                bookingPatron,
369
                bookingItemId,
370
                bookingItemtypeId,
371
                pickupLibraryId,
372
                showPatronSelect: props.showPatronSelect,
373
                showItemDetailsSelects: props.showItemDetailsSelects,
374
                showPickupLocationSelect: showPickupLocationSelect.value,
375
                dateRangeConstraint: props.dateRangeConstraint,
376
            });
377
378
        // Readiness flags
379
        const dataReady = computed(
380
            () =>
381
                !loading.value.bookableItems &&
382
                !loading.value.bookings &&
383
                !loading.value.checkouts &&
384
                (bookableItems.value?.length ?? 0) > 0
385
        );
386
        const formPrefilterValid = computed(() => {
387
            const requireTypeOrItem = !!props.showItemDetailsSelects;
388
            const hasTypeOrItem =
389
                !!bookingItemId.value || !!bookingItemtypeId.value;
390
            const patronOk = !props.showPatronSelect || !!bookingPatron.value;
391
            return patronOk && (requireTypeOrItem ? hasTypeOrItem : true);
392
        });
393
        const hasAvailableItems = computed(
394
            () => constrainedBookableItems.value.length > 0
395
        );
396
397
        const isCalendarReady = computed(() => {
398
            const basicReady = dataReady.value &&
399
                formPrefilterValid.value &&
400
                hasAvailableItems.value;
401
            if (!basicReady) return false;
402
            if (loading.value.circulationRules) return true;
403
404
            return hasPositiveCapacity.value;
405
        });
406
407
        // Separate validation for submit button using reactive composable
408
        const isSubmitReady = computed(
409
            () => isCalendarReady.value && canSubmitReactive.value
410
        );
411
412
        // Grouped readiness for convenient consumption (optional)
413
        const readiness = computed(() => ({
414
            dataReady: dataReady.value,
415
            formPrefilterValid: formPrefilterValid.value,
416
            hasAvailableItems: hasAvailableItems.value,
417
            isCalendarReady: isCalendarReady.value,
418
            canSubmit: isSubmitReady.value,
419
        }));
420
421
        onMounted(() => {
422
            if (modalElement.value) {
423
                bsModal = new Modal(modalElement.value, {
424
                    backdrop: 'static',
425
                    keyboard: false
426
                });
427
428
                // Note: We don't emit "close" here because the close flow is:
429
                // 1. Component emits "close" → 2. Parent sets props.open = false
430
                // 3. Watcher calls bsModal.hide() → 4. This event fires for cleanup
431
                modalElement.value.addEventListener('hidden.bs.modal', () => {
432
                    resetModalState();
433
                });
434
            }
435
        });
436
437
        onUnmounted(() => {
438
            if (bsModal) {
439
                bsModal.dispose();
440
            }
441
        });
442
443
        // Watchers: synchronize open state, orchestrate initial data load,
444
        // push availability to store, fetch rules/pickup locations, and keep
445
        // UX guidance/errors fresh while inputs change.
446
447
        // Controls Bootstrap modal visibility and orchestrates data loading on open.
448
        // Handles async fetch ordering and proper blur/hide sequence on close.
449
        watch(
450
            () => props.open,
451
            async (open) => {
452
                if (!bsModal || !modalElement.value) return;
453
454
                if (open) {
455
                    bsModal.show();
456
                    resetModalState();
457
458
                    modalState.step = 1;
459
                    const biblionumber = props.biblionumber;
460
                    if (!biblionumber) return;
461
462
                    bookingId.value = props.bookingId;
463
464
                    try {
465
                        // Fetch core data first
466
                        await Promise.all([
467
                            store.fetchBookableItems(biblionumber),
468
                            store.fetchBookings(biblionumber),
469
                            store.fetchCheckouts(biblionumber),
470
                        ]);
471
472
                        modalState.hasAdditionalFields = false;
473
474
                        // Derive item types after bookable items are loaded
475
                        store.deriveItemTypesFromBookableItems();
476
477
                        // If editing with patron, fetch patron-specific data
478
                        if (props.patronId) {
479
                            const patron = await store.fetchPatron(props.patronId);
480
                            await store.fetchPickupLocations(
481
                                biblionumber,
482
                                props.patronId
483
                            );
484
485
                            // Now set patron after data is available
486
                            bookingPatron.value = patron;
487
                        }
488
489
                        // Set other form values after all dependencies are loaded
490
491
                        // Normalize itemId type to match bookableItems' item_id type for vue-select strict matching
492
                        bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null;
493
                        if (props.itemtypeId) {
494
                            bookingItemtypeId.value = props.itemtypeId;
495
                        }
496
497
                        if (props.startDate && props.endDate) {
498
                            selectedDateRange.value = [
499
                                toISO(props.startDate),
500
                                toISO(props.endDate),
501
                            ];
502
                        }
503
                    } catch (error) {
504
                        console.error("Error initializing booking modal:", error);
505
                        setError(processApiError(error), "api");
506
                    }
507
                } else {
508
                    // Only hide if modal is actually shown (prevents double-hiding)
509
                    const isShown = modalElement.value.classList.contains('show');
510
                    if (isShown) {
511
                        // Blur active element to prevent aria-hidden warning
512
                        if (document.activeElement instanceof HTMLElement) {
513
                            document.activeElement.blur();
514
                        }
515
                        bsModal.hide();
516
                    }
517
                }
518
            }
519
        );
520
521
        useRulesFetcher({
522
            store,
523
            bookingPatron,
524
            bookingPickupLibraryId: pickupLibraryId,
525
            bookingItemtypeId,
526
            constrainedItemTypes,
527
            selectedDateRange,
528
            biblionumber: String(props.biblionumber),
529
        });
530
531
        useDerivedItemType({
532
            bookingItemtypeId,
533
            bookingItemId,
534
            constrainedItemTypes,
535
            bookableItems,
536
        });
537
538
        watch(
539
            () => ({
540
                patron: bookingPatron.value?.patron_id,
541
                pickup: pickupLibraryId.value,
542
                itemtype: bookingItemtypeId.value,
543
                item: bookingItemId.value,
544
                d0: selectedDateRange.value?.[0],
545
                d1: selectedDateRange.value?.[1],
546
            }),
547
            (curr, prev) => {
548
                const inputsChanged =
549
                    !prev ||
550
                    curr.patron !== prev.patron ||
551
                    curr.pickup !== prev.pickup ||
552
                    curr.itemtype !== prev.itemtype ||
553
                    curr.item !== prev.item ||
554
                    curr.d0 !== prev.d0 ||
555
                    curr.d1 !== prev.d1;
556
                if (inputsChanged) clearErrors();
557
            }
558
        );
559
560
        // Default pickup selection handled by composable
561
        useDefaultPickup({
562
            bookingPickupLibraryId: pickupLibraryId,
563
            bookingPatron,
564
            pickupLocations,
565
            bookableItems,
566
            opacDefaultBookingLibraryEnabled: props.opacDefaultBookingLibraryEnabled,
567
            opacDefaultBookingLibrary: props.opacDefaultBookingLibrary,
568
        });
569
570
        // Show an actionable error when current selection yields no available
571
        // items, helping the user adjust filters.
572
        watch(
573
            [
574
                constrainedBookableItems,
575
                () => bookingPatron.value,
576
                () => pickupLibraryId.value,
577
                () => bookingItemtypeId.value,
578
                dataReady,
579
                () => loading.value.circulationRules,
580
                () => loading.value.pickupLocations,
581
            ],
582
            ([availableItems, patron, pickupLibrary, itemtypeId, isDataReady]) => {
583
                // Only show error if data is loaded and user has made selections that result in no items
584
                // Wait for pickup locations and circulation rules to finish loading to avoid false positives
585
                const pickupLocationsReady = !pickupLibrary || (!loading.value.pickupLocations && pickupLocations.value.length > 0);
586
                const circulationRulesReady = !loading.value.circulationRules;
587
588
                if (
589
                    isDataReady &&
590
                    pickupLocationsReady &&
591
                    circulationRulesReady &&
592
                    patron &&
593
                    (pickupLibrary || itemtypeId) &&
594
                    availableItems.length === 0
595
                ) {
596
                    const msg = buildNoItemsAvailableMessage(
597
                        pickupLocations.value,
598
                        itemTypes.value,
599
                        pickupLibrary,
600
                        itemtypeId
601
                    );
602
                    setError(msg, "no_items");
603
                } else if (uiError.code === "no_items") {
604
                    clearErrors();
605
                }
606
            },
607
            { immediate: true }
608
        );
609
610
611
        // Globally clear all error states (modal + store)
612
        function clearErrors() {
613
            clearError();
614
            store.resetErrors();
615
        }
616
617
618
        function resetModalState() {
619
            bookingPatron.value = null;
620
            pickupLibraryId.value = null;
621
            bookingItemtypeId.value = null;
622
            bookingItemId.value = null;
623
            selectedDateRange.value = [];
624
            modalState.step = 1;
625
            clearErrors();
626
            modalState.hasAdditionalFields = false;
627
        }
628
629
        function clearDateRange() {
630
            selectedDateRange.value = [];
631
            clearErrors();
632
        }
633
634
        function handleClose() {
635
            emit("close");
636
        }
637
638
        async function handleSubmit(event) {
639
            // Use selectedDateRange (clean ISO strings maintained by onChange handler)
640
            const selectedDates = selectedDateRange.value;
641
642
            if (!selectedDates || selectedDates.length === 0) {
643
                setError($__("Please select a valid date range"), "invalid_date_range");
644
                return;
645
            }
646
647
            const start = selectedDates[0];
648
            const end =
649
                selectedDates.length >= 2 ? selectedDates[1] : selectedDates[0];
650
            const bookingData = {
651
                booking_id: props.bookingId ?? undefined,
652
                start_date: start,
653
                end_date: end,
654
                pickup_library_id: pickupLibraryId.value,
655
                biblio_id: props.biblionumber,
656
                item_id: bookingItemId.value || null,
657
                patron_id: bookingPatron.value?.patron_id,
658
            };
659
660
            if (isFormSubmission.value) {
661
                const form = /** @type {HTMLFormElement} */ (event.target);
662
                const csrfToken = /** @type {HTMLInputElement|null} */ (
663
                    document.querySelector('[name="csrf_token"]')
664
                );
665
666
                const dataToSubmit = { ...bookingData };
667
668
                appendHiddenInputs(
669
                    form,
670
                    [
671
                        ...Object.entries(dataToSubmit),
672
                        [csrfToken?.name, csrfToken?.value],
673
                        ['op', 'cud-add'],
674
                    ]
675
                );
676
                form.submit();
677
                return;
678
            }
679
680
            try {
681
                const result = await store.saveOrUpdateBooking(bookingData);
682
                updateExternalDependents(result, bookingPatron.value, !!props.bookingId);
683
                emit("close");
684
            } catch (errorObj) {
685
                setError(processApiError(errorObj), "api");
686
            }
687
        }
688
689
        return {
690
            modalElement,
691
            modalState,
692
            modalTitle,
693
            submitLabel,
694
            isFormSubmission,
695
            loading,
696
            showPickupLocationSelect,
697
            // Store refs from storeToRefs
698
            selectedDateRange,
699
            bookingId,
700
            bookingItemId,
701
            bookingPatron,
702
            bookingItemtypeId,
703
            pickupLibraryId,
704
            bookableItems,
705
            bookings,
706
            checkouts,
707
            pickupLocations,
708
            itemTypes,
709
            uiError,
710
            setError,
711
            // Computed values
712
            constrainedPickupLocations,
713
            constrainedItemTypes,
714
            constrainedBookableItems,
715
            isCalendarReady,
716
            isSubmitReady,
717
            readiness,
718
            constrainedFlags,
719
            constraintOptions,
720
            handleClose,
721
            handleSubmit,
722
            clearDateRange,
723
            resetModalState,
724
            stepNumber,
725
            pickupLocationsFilteredOut,
726
            pickupLocationsTotal,
727
            bookableItemsFilteredOut,
728
            bookableItemsTotal,
729
            maxBookingPeriod,
730
            hasPositiveCapacity,
731
            zeroCapacityMessage,
732
            showCapacityWarning,
733
        };
734
    },
735
};
736
</script>
737
738
<style>
739
/* Global variables for external libraries (flatpickr) and cross-block usage */
740
:root {
741
    /* Success colors for constraint highlighting */
742
    --booking-success-hue: 134;
743
    --booking-success-bg: hsl(var(--booking-success-hue), 40%, 90%);
744
    --booking-success-bg-hover: hsl(var(--booking-success-hue), 35%, 85%);
745
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
746
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
747
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
748
749
    /* Border width used by flatpickr */
750
    --booking-border-width: 1px;
751
752
    /* Variables used by second style block (booking markers, calendar states) */
753
    --booking-marker-size: 0.25em;
754
    --booking-marker-grid-gap: 0.25rem;
755
    --booking-marker-grid-offset: -0.75rem;
756
757
    /* Color hues used in second style block */
758
    --booking-warning-hue: 45;
759
    --booking-danger-hue: 354;
760
    --booking-info-hue: 195;
761
    --booking-neutral-hue: 210;
762
763
    /* Colors derived from hues (used in second style block) */
764
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
765
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
766
767
    /* Spacing used in second style block */
768
    --booking-space-xs: 0.125rem;
769
770
    /* Typography used in second style block */
771
    --booking-text-xs: 0.7rem;
772
773
    /* Border radius used in second style block and other components */
774
    --booking-border-radius-sm: 0.25rem;
775
    --booking-border-radius-md: 0.5rem;
776
    --booking-border-radius-full: 50%;
777
778
    /* Additional colors */
779
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
780
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
781
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
782
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
783
784
    /* Spacing Scale */
785
    --booking-space-sm: 0.25rem; /* 4px */
786
    --booking-space-md: 0.5rem; /* 8px */
787
    --booking-space-lg: 1rem; /* 16px */
788
    --booking-space-xl: 1.5rem; /* 24px */
789
    --booking-space-2xl: 2rem; /* 32px */
790
791
    /* Typography Scale */
792
    --booking-text-sm: 0.8125rem;
793
    --booking-text-base: 1rem;
794
    --booking-text-lg: 1.1rem;
795
    --booking-text-xl: 1.3rem;
796
    --booking-text-2xl: 2rem;
797
798
    /* Layout */
799
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
800
    --booking-input-min-width: 15rem;
801
802
    /* Animation */
803
    --booking-transition-fast: 0.15s ease-in-out;
804
}
805
806
/* Constraint Highlighting Component */
807
.flatpickr-calendar .booking-constrained-range-marker {
808
    background-color: var(--booking-success-bg) !important;
809
    border: var(--booking-border-width) solid var(--booking-success-border) !important;
810
    color: var(--booking-success-text) !important;
811
}
812
813
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker {
814
    background-color: var(--booking-success-bg) !important;
815
    border-color: var(--booking-success-border) !important;
816
    color: var(--booking-success-text) !important;
817
}
818
819
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker:hover {
820
    background-color: var(--booking-success-bg-hover) !important;
821
    border-color: var(--booking-success-border-hover) !important;
822
}
823
824
/* End Date Only Mode - Blocked Intermediate Dates */
825
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked {
826
    background-color: hsl(var(--booking-success-hue), 40%, 90%) !important;
827
    border-color: hsl(var(--booking-success-hue), 40%, 70%) !important;
828
    color: hsl(var(--booking-success-hue), 40%, 50%) !important;
829
    cursor: not-allowed !important;
830
    opacity: 0.7 !important;
831
}
832
833
/* Bold styling for end of loan and renewal period boundaries */
834
.flatpickr-calendar .flatpickr-day.booking-loan-boundary {
835
    font-weight: 700 !important;
836
}
837
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked:hover {
838
    background-color: hsl(var(--booking-success-hue), 40%, 85%) !important;
839
    border-color: hsl(var(--booking-success-hue), 40%, 60%) !important;
840
}
841
842
/* Modal Layout Components */
843
.modal-dialog {
844
    max-height: var(--booking-modal-max-height);
845
    margin: var(--booking-space-lg) auto;
846
}
847
848
.modal-content {
849
    max-height: var(--booking-modal-max-height);
850
    display: flex;
851
    flex-direction: column;
852
}
853
854
.modal-body {
855
    overflow-y: auto;
856
    flex: 1 1 auto;
857
}
858
859
/* Form & Layout Components */
860
.booking-extended-attributes {
861
    list-style: none;
862
    padding: 0;
863
    margin: 0;
864
}
865
866
.step-block {
867
    margin-bottom: var(--booking-space-2xl);
868
}
869
870
.step-header {
871
    font-weight: bold;
872
    font-size: var(--booking-text-lg);
873
    margin-bottom: var(--booking-space-md);
874
}
875
876
hr {
877
    border: none;
878
    border-top: var(--booking-border-width) solid var(--booking-neutral-100);
879
    margin: var(--booking-space-2xl) 0;
880
}
881
882
/* Input Components */
883
.booking-flatpickr-input,
884
.flatpickr-input.booking-flatpickr-input {
885
    min-width: var(--booking-input-min-width);
886
    padding: calc(var(--booking-space-md) - var(--booking-space-xs))
887
        calc(var(--booking-space-md) + var(--booking-space-sm));
888
    border: var(--booking-border-width) solid var(--booking-neutral-300);
889
    border-radius: var(--booking-border-radius-sm);
890
    font-size: var(--booking-text-base);
891
    transition: border-color var(--booking-transition-fast),
892
        box-shadow var(--booking-transition-fast);
893
}
894
895
/* Calendar Legend Component */
896
.calendar-legend {
897
    margin-top: var(--booking-space-lg);
898
    margin-bottom: var(--booking-space-lg);
899
    font-size: var(--booking-text-sm);
900
    display: flex;
901
    align-items: center;
902
}
903
904
.calendar-legend .booking-marker-dot {
905
    /* Make legend dots much larger and more visible */
906
    width: calc(var(--booking-marker-size) * 3) !important;
907
    height: calc(var(--booking-marker-size) * 3) !important;
908
    margin-right: calc(var(--booking-space-sm) * 1.5);
909
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
910
}
911
912
.calendar-legend .ml-3 {
913
    margin-left: var(--booking-space-lg);
914
}
915
916
/* Legend colors match actual calendar markers exactly */
917
.calendar-legend .booking-marker-dot--booked {
918
    background: var(--booking-warning-bg) !important;
919
}
920
921
.calendar-legend .booking-marker-dot--checked-out {
922
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
923
}
924
925
.calendar-legend .booking-marker-dot--lead {
926
    background: hsl(var(--booking-info-hue), 60%, 85%) !important;
927
}
928
929
.calendar-legend .booking-marker-dot--trail {
930
    background: var(--booking-warning-bg) !important;
931
}
932
</style>
933
934
<style>
935
.booking-date-picker {
936
    display: flex;
937
    align-items: stretch;
938
    width: 100%;
939
}
940
941
.booking-date-picker > .form-control {
942
    flex: 1 1 auto;
943
    min-width: 0;
944
    margin-bottom: 0;
945
}
946
947
.booking-date-picker-append {
948
    display: flex;
949
    margin-left: -1px;
950
}
951
952
.booking-date-picker-append .btn {
953
    border-top-left-radius: 0;
954
    border-bottom-left-radius: 0;
955
}
956
957
.booking-date-picker > .form-control:not(:last-child) {
958
    border-top-right-radius: 0;
959
    border-bottom-right-radius: 0;
960
}
961
962
/* External Library Integration */
963
.modal-body .vs__selected {
964
    font-size: var(--vs-font-size);
965
    line-height: var(--vs-line-height);
966
}
967
968
.booking-constraint-info {
969
    margin-top: var(--booking-space-lg);
970
    margin-bottom: var(--booking-space-lg);
971
}
972
973
/* Booking Status Marker System */
974
.booking-marker-grid {
975
    position: relative;
976
    top: var(--booking-marker-grid-offset);
977
    display: flex;
978
    flex-wrap: wrap;
979
    justify-content: center;
980
    gap: var(--booking-marker-grid-gap);
981
    width: fit-content;
982
    max-width: 90%;
983
    margin-left: auto;
984
    margin-right: auto;
985
    line-height: normal;
986
}
987
988
.booking-marker-item {
989
    display: inline-flex;
990
    align-items: center;
991
}
992
993
.booking-marker-dot {
994
    display: inline-block;
995
    width: var(--booking-marker-size);
996
    height: var(--booking-marker-size);
997
    border-radius: var(--booking-border-radius-full);
998
    vertical-align: middle;
999
}
1000
1001
.booking-marker-count {
1002
    font-size: var(--booking-text-xs);
1003
    margin-left: var(--booking-space-xs);
1004
    line-height: 1;
1005
    font-weight: normal;
1006
    color: var(--booking-neutral-600);
1007
}
1008
1009
/* Status Indicator Colors */
1010
.booking-marker-dot--booked {
1011
    background: var(--booking-warning-bg);
1012
}
1013
1014
.booking-marker-dot--checked-out {
1015
    background: hsl(var(--booking-danger-hue), 60%, 85%);
1016
}
1017
1018
.booking-marker-dot--lead {
1019
    background: hsl(var(--booking-info-hue), 60%, 85%);
1020
}
1021
1022
.booking-marker-dot--trail {
1023
    background: var(--booking-warning-bg);
1024
}
1025
1026
/* Calendar Day States */
1027
.booked {
1028
    background: var(--booking-warning-bg) !important;
1029
    color: hsl(var(--booking-warning-hue), 80%, 25%) !important;
1030
    border-radius: var(--booking-border-radius-full) !important;
1031
}
1032
1033
.checked-out {
1034
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
1035
    color: hsl(var(--booking-danger-hue), 80%, 25%) !important;
1036
    border-radius: var(--booking-border-radius-full) !important;
1037
}
1038
1039
/* Hover States with Transparency */
1040
.flatpickr-day.booking-day--hover-lead {
1041
    background-color: hsl(var(--booking-info-hue), 60%, 85%, 0.2) !important;
1042
}
1043
1044
.flatpickr-day.booking-day--hover-trail {
1045
    background-color: hsl(
1046
        var(--booking-warning-hue),
1047
        100%,
1048
        70%,
1049
        0.2
1050
    ) !important;
1051
}
1052
</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 (+233 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
    return results;
233
}
(-)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/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 (+1412 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
            // Cumulative pool walk for "any item" mode:
558
            // Ensures at least one item can cover the ENTIRE booking range [start, end].
559
            // Unlike the point-in-time check above, this catches cases where items are
560
            // individually available on separate days but no single item spans the full range.
561
            // Example: Items A (booked days 3-7) and B (booked days 8-12), start=day 1:
562
            //   Point-in-time: day 10 → A available → allows it (WRONG: no item covers 1-10)
563
            //   Pool walk: A removed day 3, B removed day 8 → pool empty → disabled (CORRECT)
564
            if (!selectedItem) {
565
                const startConflicts = intervalTree
566
                    .query(start.valueOf(), null)
567
                    .filter(
568
                        c =>
569
                            !editBookingId ||
570
                            c.metadata.booking_id != editBookingId
571
                    )
572
                    .filter(
573
                        c => c.type === "booking" || c.type === "checkout"
574
                    );
575
                const startUnavailable = new Set(
576
                    startConflicts.map(c => c.itemId)
577
                );
578
                const pool = allItemIds.filter(
579
                    id => !startUnavailable.has(id)
580
                );
581
582
                if (pool.length === 0) return true;
583
584
                // Check if any item in the pool can cover [start, end] with no conflicts
585
                const hasItemCoveringRange = pool.some(itemId => {
586
                    const rangeConflicts = intervalTree
587
                        .queryRange(
588
                            start.valueOf(),
589
                            dayjs_date.valueOf(),
590
                            itemId
591
                        )
592
                        .filter(
593
                            c =>
594
                                !editBookingId ||
595
                                c.metadata.booking_id != editBookingId
596
                        )
597
                        .filter(
598
                            c =>
599
                                c.type === "booking" || c.type === "checkout"
600
                        );
601
                    return rangeConflicts.length === 0;
602
                });
603
604
                if (!hasItemCoveringRange) {
605
                    logger.debug(
606
                        `End date ${dayjs_date.format(
607
                            "YYYY-MM-DD"
608
                        )} blocked - no single item covers start→end range`
609
                    );
610
                    return true;
611
                }
612
            }
613
614
            // Optimized trail period validation using range queries
615
            if (
616
                validateTrailPeriodOptimized(
617
                    dayjs_date,
618
                    trailDays,
619
                    intervalTree,
620
                    selectedItem,
621
                    editBookingId,
622
                    allItemIds
623
                )
624
            ) {
625
                logger.debug(
626
                    `End date ${dayjs_date.format(
627
                        "YYYY-MM-DD"
628
                    )} blocked - trail period conflict (optimized check)`
629
                );
630
                return true;
631
            }
632
        }
633
634
        return false;
635
    };
636
}
637
638
/**
639
 * Logs comprehensive debug information for OPAC booking selection debugging
640
 * @param {Array} bookings - Array of booking objects
641
 * @param {Array} checkouts - Array of checkout objects
642
 * @param {Array} bookableItems - Array of bookable items
643
 * @param {string|null} selectedItem - Selected item ID
644
 * @param {Object} circulationRules - Circulation rules
645
 */
646
function logBookingDebugInfo(
647
    bookings,
648
    checkouts,
649
    bookableItems,
650
    selectedItem,
651
    circulationRules
652
) {
653
    logger.debug("OPAC Selection Debug:", {
654
        selectedItem: selectedItem,
655
        selectedItemType:
656
            selectedItem === null
657
                ? SELECTION_ANY_AVAILABLE
658
                : SELECTION_SPECIFIC_ITEM,
659
        bookableItems: bookableItems.map(item => ({
660
            item_id: item.item_id,
661
            title: item.title,
662
            item_type_id: item.item_type_id,
663
            holding_library: item.holding_library,
664
            available_pickup_locations: item.available_pickup_locations,
665
        })),
666
        circulationRules: {
667
            booking_constraint_mode: circulationRules?.booking_constraint_mode,
668
            maxPeriod: circulationRules?.maxPeriod,
669
            bookings_lead_period: circulationRules?.bookings_lead_period,
670
            bookings_trail_period: circulationRules?.bookings_trail_period,
671
        },
672
        bookings: bookings.map(b => ({
673
            booking_id: b.booking_id,
674
            item_id: b.item_id,
675
            start_date: b.start_date,
676
            end_date: b.end_date,
677
            patron_id: b.patron_id,
678
        })),
679
        checkouts: checkouts.map(c => ({
680
            item_id: c.item_id,
681
            checkout_date: c.checkout_date,
682
            due_date: c.due_date,
683
            patron_id: c.patron_id,
684
        })),
685
    });
686
}
687
688
/**
689
 * Pure function for Flatpickr's `disable` option.
690
 * Disables dates that overlap with existing bookings or checkouts for the selected item, or when not enough items are available.
691
 * Also handles end_date_only constraint mode by disabling intermediate dates.
692
 *
693
 * @param {Array} bookings - Array of booking objects ({ booking_id, item_id, start_date, end_date })
694
 * @param {Array} checkouts - Array of checkout objects ({ item_id, due_date, ... })
695
 * @param {Array} bookableItems - Array of all bookable item objects (must have item_id)
696
 * @param {number|string|null} selectedItem - The currently selected item (item_id or null for 'any')
697
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
698
 * @param {Array} selectedDates - Array of currently selected dates in Flatpickr (can be empty, or [start], or [start, end])
699
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, booking_constraint_mode, etc.)
700
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
701
 * @param {Object} options - Additional options for optimization
702
 * @returns {import('../../types/bookings').AvailabilityResult}
703
 */
704
export function calculateDisabledDates(
705
    bookings,
706
    checkouts,
707
    bookableItems,
708
    selectedItem,
709
    editBookingId,
710
    selectedDates = [],
711
    circulationRules = {},
712
    todayArg = undefined,
713
    options = {}
714
) {
715
    logger.time("calculateDisabledDates");
716
    const normalizedSelectedItem =
717
        selectedItem != null ? String(selectedItem) : null;
718
    logger.debug("calculateDisabledDates called", {
719
        bookingsCount: bookings.length,
720
        checkoutsCount: checkouts.length,
721
        itemsCount: bookableItems.length,
722
        normalizedSelectedItem,
723
        editBookingId,
724
        selectedDates,
725
        circulationRules,
726
    });
727
728
    // Log comprehensive debug information for OPAC debugging
729
    logBookingDebugInfo(
730
        bookings,
731
        checkouts,
732
        bookableItems,
733
        normalizedSelectedItem,
734
        circulationRules
735
    );
736
737
    // Build IntervalTree with all booking/checkout data
738
    const intervalTree = buildIntervalTree(
739
        bookings,
740
        checkouts,
741
        circulationRules
742
    );
743
744
    // Extract and validate configuration
745
    const config = extractBookingConfiguration(circulationRules, todayArg);
746
    const allItemIds = bookableItems.map(i => String(i.item_id));
747
748
    // Create optimized disable function using extracted helper
749
    const normalizedEditBookingId =
750
        editBookingId != null ? Number(editBookingId) : null;
751
    const disableFunction = createDisableFunction(
752
        intervalTree,
753
        config,
754
        bookableItems,
755
        normalizedSelectedItem,
756
        normalizedEditBookingId,
757
        selectedDates
758
    );
759
760
    // Build unavailableByDate for backward compatibility and markers
761
    // Pass options for performance optimization
762
763
    const unavailableByDate = buildUnavailableByDateMap(
764
        intervalTree,
765
        config.today,
766
        allItemIds,
767
        normalizedEditBookingId,
768
        options
769
    );
770
771
    logger.debug("IntervalTree-based availability calculated", {
772
        treeSize: intervalTree.size,
773
    });
774
    logger.timeEnd("calculateDisabledDates");
775
776
    return {
777
        disable: disableFunction,
778
        unavailableByDate: unavailableByDate,
779
    };
780
}
781
782
/**
783
 * Derive effective circulation rules with constraint options applied.
784
 * - Applies maxPeriod only for constraining modes
785
 * - Strips caps for unconstrained mode
786
 * @param {import('../../types/bookings').CirculationRule} [baseRules={}]
787
 * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
788
 * @returns {import('../../types/bookings').CirculationRule}
789
 */
790
export function deriveEffectiveRules(baseRules = {}, constraintOptions = {}) {
791
    const effectiveRules = { ...baseRules };
792
    const mode = constraintOptions.dateRangeConstraint;
793
    if (mode === "issuelength" || mode === "issuelength_with_renewals") {
794
        if (constraintOptions.maxBookingPeriod) {
795
            effectiveRules.maxPeriod = constraintOptions.maxBookingPeriod;
796
        }
797
    } else {
798
        if ("maxPeriod" in effectiveRules) delete effectiveRules.maxPeriod;
799
        if ("issuelength" in effectiveRules) delete effectiveRules.issuelength;
800
    }
801
    return effectiveRules;
802
}
803
804
/**
805
 * Convenience: take full circulationRules array and constraint options,
806
 * return effective rules applying maxPeriod logic.
807
 * @param {import('../../types/bookings').CirculationRule[]} circulationRules
808
 * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
809
 * @returns {import('../../types/bookings').CirculationRule}
810
 */
811
export function toEffectiveRules(circulationRules, constraintOptions = {}) {
812
    const baseRules = circulationRules?.[0] || {};
813
    return deriveEffectiveRules(baseRules, constraintOptions);
814
}
815
816
/**
817
 * Calculate maximum booking period from circulation rules and constraint mode.
818
 */
819
export function calculateMaxBookingPeriod(
820
    circulationRules,
821
    dateRangeConstraint,
822
    customDateRangeFormula = null
823
) {
824
    if (!dateRangeConstraint) return null;
825
    const rules = circulationRules?.[0];
826
    if (!rules) return null;
827
    const issuelength = parseInt(rules.issuelength) || 0;
828
    switch (dateRangeConstraint) {
829
        case "issuelength":
830
            return issuelength;
831
        case "issuelength_with_renewals":
832
            const renewalperiod = parseInt(rules.renewalperiod) || 0;
833
            const renewalsallowed = parseInt(rules.renewalsallowed) || 0;
834
            return issuelength + renewalperiod * renewalsallowed;
835
        case "custom":
836
            return typeof customDateRangeFormula === "function"
837
                ? customDateRangeFormula(rules)
838
                : null;
839
        default:
840
            return null;
841
    }
842
}
843
844
/**
845
 * Convenience wrapper to calculate availability (disable fn + map) given a dateRange.
846
 * Accepts ISO strings for dateRange and returns the result of calculateDisabledDates.
847
 * @returns {import('../../types/bookings').AvailabilityResult}
848
 */
849
export function calculateAvailabilityData(dateRange, storeData, options = {}) {
850
    const {
851
        bookings,
852
        checkouts,
853
        bookableItems,
854
        circulationRules,
855
        bookingItemId,
856
        bookingId,
857
    } = storeData;
858
859
    if (!bookings || !checkouts || !bookableItems) {
860
        return { disable: () => false, unavailableByDate: {} };
861
    }
862
863
    const baseRules = circulationRules?.[0] || {};
864
    const maxBookingPeriod = calculateMaxBookingPeriod(
865
        circulationRules,
866
        options.dateRangeConstraint,
867
        options.customDateRangeFormula
868
    );
869
    const effectiveRules = deriveEffectiveRules(baseRules, {
870
        dateRangeConstraint: options.dateRangeConstraint,
871
        maxBookingPeriod,
872
    });
873
874
    let selectedDatesArray = [];
875
    if (Array.isArray(dateRange)) {
876
        selectedDatesArray = isoArrayToDates(dateRange);
877
    } else if (typeof dateRange === "string") {
878
        throw new TypeError(
879
            "calculateAvailabilityData expects an array of ISO/date values for dateRange"
880
        );
881
    }
882
883
    return calculateDisabledDates(
884
        bookings,
885
        checkouts,
886
        bookableItems,
887
        bookingItemId,
888
        bookingId,
889
        selectedDatesArray,
890
        effectiveRules
891
    );
892
}
893
894
/**
895
 * Pure function to handle Flatpickr's onChange event logic for booking period selection.
896
 * Determines the valid end date range, applies circulation rules, and returns validation info.
897
 *
898
 * @param {Array} selectedDates - Array of currently selected dates ([start], or [start, end])
899
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, etc.)
900
 * @param {Array} bookings - Array of bookings
901
 * @param {Array} checkouts - Array of checkouts
902
 * @param {Array} bookableItems - Array of all bookable items
903
 * @param {number|string|null} selectedItem - The currently selected item
904
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
905
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
906
 * @returns {Object} - { valid: boolean, errors: Array<string>, newMaxEndDate: Date|null, newMinEndDate: Date|null }
907
 */
908
export function handleBookingDateChange(
909
    selectedDates,
910
    circulationRules,
911
    bookings,
912
    checkouts,
913
    bookableItems,
914
    selectedItem,
915
    editBookingId,
916
    todayArg = undefined,
917
    options = {}
918
) {
919
    logger.time("handleBookingDateChange");
920
    logger.debug("handleBookingDateChange called", {
921
        selectedDates,
922
        circulationRules,
923
        selectedItem,
924
        editBookingId,
925
    });
926
    const dayjsStart = selectedDates[0]
927
        ? toDayjs(selectedDates[0]).startOf("day")
928
        : null;
929
    const dayjsEnd = selectedDates[1]
930
        ? toDayjs(selectedDates[1]).endOf("day")
931
        : null;
932
    const errors = [];
933
    let valid = true;
934
    let newMaxEndDate = null;
935
    let newMinEndDate = null; // Declare and initialize here
936
937
    // Validate: ensure start date is present
938
    if (!dayjsStart) {
939
        errors.push(String($__("Start date is required.")));
940
        valid = false;
941
    } else {
942
        // Apply circulation rules: leadDays, trailDays, maxPeriod (in days)
943
        const leadDays = circulationRules?.leadDays || 0;
944
        const _trailDays = circulationRules?.trailDays || 0; // Still needed for start date check
945
        const maxPeriod =
946
            Number(circulationRules?.maxPeriod) ||
947
            Number(circulationRules?.issuelength) ||
948
            0;
949
950
        // Calculate min end date; max end date only when constrained
951
        newMinEndDate = dayjsStart.add(1, "day").startOf("day");
952
        if (maxPeriod > 0) {
953
            newMaxEndDate = calculateMaxEndDate(dayjsStart, maxPeriod).startOf("day");
954
        } else {
955
            newMaxEndDate = null;
956
        }
957
958
        // Validate: start must be after today + leadDays
959
        const today = todayArg
960
            ? toDayjs(todayArg).startOf("day")
961
            : dayjs().startOf("day");
962
        if (dayjsStart.isBefore(today.add(leadDays, "day"))) {
963
            errors.push(
964
                String($__("Start date is too soon (lead time required)"))
965
            );
966
            valid = false;
967
        }
968
969
        // Validate: end must not be before start (only if end date exists)
970
        if (dayjsEnd && dayjsEnd.isBefore(dayjsStart)) {
971
            errors.push(String($__("End date is before start date")));
972
            valid = false;
973
        }
974
975
        // Validate: period must not exceed maxPeriod unless overridden in end_date_only by backend due date
976
        if (dayjsEnd) {
977
            const isEndDateOnly =
978
                circulationRules?.booking_constraint_mode ===
979
                CONSTRAINT_MODE_END_DATE_ONLY;
980
            const dueStr = circulationRules?.calculated_due_date;
981
            const hasBackendDue = Boolean(dueStr);
982
            if (!isEndDateOnly || !hasBackendDue) {
983
                if (
984
                    maxPeriod > 0 &&
985
                    dayjsEnd.diff(dayjsStart, "day") + 1 > maxPeriod
986
                ) {
987
                    errors.push(
988
                        String($__("Booking period exceeds maximum allowed"))
989
                    );
990
                    valid = false;
991
                }
992
            }
993
        }
994
995
        // Strategy-specific enforcement for end date (e.g., end_date_only)
996
        const strategy = createConstraintStrategy(
997
            circulationRules?.booking_constraint_mode
998
        );
999
        const enforcement = strategy.enforceEndDateSelection(
1000
            dayjsStart,
1001
            dayjsEnd,
1002
            circulationRules
1003
        );
1004
        if (!enforcement.ok) {
1005
            errors.push(
1006
                String(
1007
                    $__(
1008
                        "In end date only mode, you can only select the calculated end date"
1009
                    )
1010
                )
1011
            );
1012
            valid = false;
1013
        }
1014
1015
        // Validate: check for booking/checkouts overlap using calculateDisabledDates
1016
        // This check is only meaningful if we have at least a start date,
1017
        // and if an end date is also present, we check the whole range.
1018
        // If only start date, effectively checks that single day.
1019
        const endDateForLoop = dayjsEnd || dayjsStart; // If no end date, loop for the start date only
1020
1021
        const disableFnResults = calculateDisabledDates(
1022
            bookings,
1023
            checkouts,
1024
            bookableItems,
1025
            selectedItem,
1026
            editBookingId,
1027
            selectedDates, // Pass selectedDates
1028
            circulationRules, // Pass circulationRules
1029
            todayArg, // Pass todayArg
1030
            options
1031
        );
1032
        for (
1033
            let d = dayjsStart.clone();
1034
            d.isSameOrBefore(endDateForLoop, "day");
1035
            d = d.add(1, "day")
1036
        ) {
1037
            if (disableFnResults.disable(d.toDate())) {
1038
                errors.push(
1039
                    String(
1040
                        $__("Date %s is unavailable.").format(
1041
                            d.format("YYYY-MM-DD")
1042
                        )
1043
                    )
1044
                );
1045
                valid = false;
1046
                break;
1047
            }
1048
        }
1049
    }
1050
1051
    logger.debug("Date change validation result", { valid, errors });
1052
    logger.timeEnd("handleBookingDateChange");
1053
1054
    return {
1055
        valid,
1056
        errors,
1057
        newMaxEndDate: newMaxEndDate ? newMaxEndDate.toDate() : null,
1058
        newMinEndDate: newMinEndDate ? newMinEndDate.toDate() : null,
1059
    };
1060
}
1061
1062
/**
1063
 * Aggregate all booking/checkouts for a given date (for calendar indicators)
1064
 * @param {import('../../types/bookings').UnavailableByDate} unavailableByDate - Map produced by buildUnavailableByDateMap
1065
 * @param {string|Date|import("dayjs").Dayjs} dateStr - date to check (YYYY-MM-DD or Date or dayjs)
1066
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems - Array of all bookable items
1067
 * @returns {import('../../types/bookings').CalendarMarker[]} indicators for that date
1068
 */
1069
export function getBookingMarkersForDate(
1070
    unavailableByDate,
1071
    dateStr,
1072
    bookableItems = []
1073
) {
1074
    // Guard against unavailableByDate itself being undefined or null
1075
    if (!unavailableByDate) {
1076
        return []; // No data, so no markers
1077
    }
1078
1079
    const d =
1080
        typeof dateStr === "string"
1081
            ? dayjs(dateStr).startOf("day")
1082
            : dayjs(dateStr).isValid()
1083
            ? dayjs(dateStr).startOf("day")
1084
            : dayjs().startOf("day");
1085
    const key = d.format("YYYY-MM-DD");
1086
    const markers = [];
1087
1088
    const findItem = item_id => {
1089
        if (item_id == null) return undefined;
1090
        return bookableItems.find(i => idsEqual(i?.item_id, item_id));
1091
    };
1092
1093
    const entry = unavailableByDate[key]; // This was line 496
1094
1095
    // Guard against the specific date key not being in the map
1096
    if (!entry) {
1097
        return []; // No data for this specific date, so no markers
1098
    }
1099
1100
    // Now it's safe to use Object.entries(entry)
1101
    for (const [item_id, reasons] of Object.entries(entry)) {
1102
        const item = findItem(item_id);
1103
        for (const reason of reasons) {
1104
            let type = reason;
1105
            // Map IntervalTree/Sweep reasons to CSS class names
1106
            if (type === "booking") type = "booked";
1107
            if (type === "core") type = "booked";
1108
            if (type === "checkout") type = "checked-out";
1109
            // lead and trail periods keep their original names for CSS
1110
            markers.push({
1111
                /** @type {import('../../types/bookings').MarkerType} */
1112
                type: /** @type {any} */ (type),
1113
                item: String(item_id),
1114
                itemName: item?.title || String(item_id),
1115
                barcode: item?.barcode || item?.external_id || null,
1116
            });
1117
        }
1118
    }
1119
    return markers;
1120
}
1121
1122
/**
1123
 * Constrain pickup locations based on selected itemtype or item
1124
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1125
 *
1126
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1127
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1128
 * @param {string|number|null} bookingItemtypeId
1129
 * @param {string|number|null} bookingItemId
1130
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').PickupLocation>}
1131
 */
1132
export function constrainPickupLocations(
1133
    pickupLocations,
1134
    bookableItems,
1135
    bookingItemtypeId,
1136
    bookingItemId
1137
) {
1138
    logger.debug("constrainPickupLocations called", {
1139
        inputLocations: pickupLocations.length,
1140
        bookingItemtypeId,
1141
        bookingItemId,
1142
        bookableItems: bookableItems.length,
1143
        locationDetails: pickupLocations.map(loc => ({
1144
            library_id: loc.library_id,
1145
            pickup_items: loc.pickup_items?.length || 0,
1146
        })),
1147
    });
1148
1149
    if (!bookingItemtypeId && !bookingItemId) {
1150
        logger.debug(
1151
            "constrainPickupLocations: No constraints, returning all locations"
1152
        );
1153
        return buildConstraintResult(pickupLocations, pickupLocations.length);
1154
    }
1155
    const filtered = pickupLocations.filter(loc => {
1156
        if (bookingItemId) {
1157
            return (
1158
                loc.pickup_items && includesId(loc.pickup_items, bookingItemId)
1159
            );
1160
        }
1161
        if (bookingItemtypeId) {
1162
            return (
1163
                loc.pickup_items &&
1164
                bookableItems.some(
1165
                    item =>
1166
                        idsEqual(item.item_type_id, bookingItemtypeId) &&
1167
                        includesId(loc.pickup_items, item.item_id)
1168
                )
1169
            );
1170
        }
1171
        return true;
1172
    });
1173
    logger.debug("constrainPickupLocations result", {
1174
        inputCount: pickupLocations.length,
1175
        outputCount: filtered.length,
1176
        filteredOutCount: pickupLocations.length - filtered.length,
1177
        constraints: {
1178
            bookingItemtypeId,
1179
            bookingItemId,
1180
        },
1181
    });
1182
1183
    return buildConstraintResult(filtered, pickupLocations.length);
1184
}
1185
1186
/**
1187
 * Constrain bookable items based on selected pickup location and/or itemtype
1188
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1189
 *
1190
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1191
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1192
 * @param {string|null} pickupLibraryId
1193
 * @param {string|number|null} bookingItemtypeId
1194
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').BookableItem>}
1195
 */
1196
export function constrainBookableItems(
1197
    bookableItems,
1198
    pickupLocations,
1199
    pickupLibraryId,
1200
    bookingItemtypeId
1201
) {
1202
    logger.debug("constrainBookableItems called", {
1203
        inputItems: bookableItems.length,
1204
        pickupLibraryId,
1205
        bookingItemtypeId,
1206
        pickupLocations: pickupLocations.length,
1207
        itemDetails: bookableItems.map(item => ({
1208
            item_id: item.item_id,
1209
            item_type_id: item.item_type_id,
1210
            title: item.title,
1211
        })),
1212
    });
1213
1214
    if (!pickupLibraryId && !bookingItemtypeId) {
1215
        logger.debug(
1216
            "constrainBookableItems: No constraints, returning all items"
1217
        );
1218
        return buildConstraintResult(bookableItems, bookableItems.length);
1219
    }
1220
    const filtered = bookableItems.filter(item => {
1221
        if (pickupLibraryId && bookingItemtypeId) {
1222
            const found = pickupLocations.find(
1223
                loc =>
1224
                    idsEqual(loc.library_id, pickupLibraryId) &&
1225
                    loc.pickup_items &&
1226
                    includesId(loc.pickup_items, item.item_id)
1227
            );
1228
            const match =
1229
                idsEqual(item.item_type_id, bookingItemtypeId) && found;
1230
            return match;
1231
        }
1232
        if (pickupLibraryId) {
1233
            const found = pickupLocations.find(
1234
                loc =>
1235
                    idsEqual(loc.library_id, pickupLibraryId) &&
1236
                    loc.pickup_items &&
1237
                    includesId(loc.pickup_items, item.item_id)
1238
            );
1239
            return found;
1240
        }
1241
        if (bookingItemtypeId) {
1242
            return idsEqual(item.item_type_id, bookingItemtypeId);
1243
        }
1244
        return true;
1245
    });
1246
    logger.debug("constrainBookableItems result", {
1247
        inputCount: bookableItems.length,
1248
        outputCount: filtered.length,
1249
        filteredOutCount: bookableItems.length - filtered.length,
1250
        filteredItems: filtered.map(item => ({
1251
            item_id: item.item_id,
1252
            item_type_id: item.item_type_id,
1253
            title: item.title,
1254
        })),
1255
        constraints: {
1256
            pickupLibraryId,
1257
            bookingItemtypeId,
1258
        },
1259
    });
1260
1261
    return buildConstraintResult(filtered, bookableItems.length);
1262
}
1263
1264
/**
1265
 * Constrain item types based on selected pickup location or item
1266
 * Returns { filtered, filteredOutCount, total, constraintApplied }
1267
 * @param {Array<import('../../types/bookings').ItemType>} itemTypes
1268
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
1269
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
1270
 * @param {string|null} pickupLibraryId
1271
 * @param {string|number|null} bookingItemId
1272
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').ItemType>}
1273
 */
1274
export function constrainItemTypes(
1275
    itemTypes,
1276
    bookableItems,
1277
    pickupLocations,
1278
    pickupLibraryId,
1279
    bookingItemId
1280
) {
1281
    if (!pickupLibraryId && !bookingItemId) {
1282
        return buildConstraintResult(itemTypes, itemTypes.length);
1283
    }
1284
    const filtered = itemTypes.filter(type => {
1285
        if (bookingItemId) {
1286
            return bookableItems.some(
1287
                item =>
1288
                    idsEqual(item.item_id, bookingItemId) &&
1289
                    idsEqual(item.item_type_id, type.item_type_id)
1290
            );
1291
        }
1292
        if (pickupLibraryId) {
1293
            return bookableItems.some(
1294
                item =>
1295
                    idsEqual(item.item_type_id, type.item_type_id) &&
1296
                    pickupLocations.find(
1297
                        loc =>
1298
                            idsEqual(loc.library_id, pickupLibraryId) &&
1299
                            loc.pickup_items &&
1300
                            includesId(loc.pickup_items, item.item_id)
1301
                    )
1302
            );
1303
        }
1304
        return true;
1305
    });
1306
    return buildConstraintResult(filtered, itemTypes.length);
1307
}
1308
1309
/**
1310
 * Calculate constraint highlighting data for calendar display
1311
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
1312
 * @param {Object} circulationRules - Circulation rules object
1313
 * @param {Object} constraintOptions - Additional constraint options
1314
 * @returns {import('../../types/bookings').ConstraintHighlighting | null} Constraint highlighting
1315
 */
1316
export function calculateConstraintHighlighting(
1317
    startDate,
1318
    circulationRules,
1319
    constraintOptions = {}
1320
) {
1321
    const strategy = createConstraintStrategy(
1322
        circulationRules?.booking_constraint_mode
1323
    );
1324
    const result = strategy.calculateConstraintHighlighting(
1325
        startDate,
1326
        circulationRules,
1327
        constraintOptions
1328
    );
1329
    logger.debug("Constraint highlighting calculated", result);
1330
    return result;
1331
}
1332
1333
/**
1334
 * Determine if calendar should navigate to show target end date
1335
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
1336
 * @param {Date|import('dayjs').Dayjs} targetEndDate - Calculated target end date
1337
 * @param {import('../../types/bookings').CalendarCurrentView} currentView - Current calendar view info
1338
 * @returns {import('../../types/bookings').CalendarNavigationTarget}
1339
 */
1340
export function getCalendarNavigationTarget(
1341
    startDate,
1342
    targetEndDate,
1343
    currentView = {}
1344
) {
1345
    logger.debug("Checking calendar navigation", {
1346
        startDate,
1347
        targetEndDate,
1348
        currentView,
1349
    });
1350
1351
    const start = toDayjs(startDate);
1352
    const target = toDayjs(targetEndDate);
1353
1354
    // Never navigate backwards if target is before the chosen start
1355
    if (target.isBefore(start, "day")) {
1356
        logger.debug("Target end before start; skip navigation");
1357
        return { shouldNavigate: false };
1358
    }
1359
1360
    // If we know the currently visible range, do not navigate when target is already visible
1361
    if (currentView.visibleStartDate && currentView.visibleEndDate) {
1362
        const visibleStart = toDayjs(currentView.visibleStartDate).startOf(
1363
            "day"
1364
        );
1365
        const visibleEnd = toDayjs(currentView.visibleEndDate).endOf("day");
1366
        const inView =
1367
            target.isSameOrAfter(visibleStart, "day") &&
1368
            target.isSameOrBefore(visibleEnd, "day");
1369
        if (inView) {
1370
            logger.debug("Target end date already visible; no navigation");
1371
            return { shouldNavigate: false };
1372
        }
1373
    }
1374
1375
    // Fallback: navigate when target month differs from start month
1376
    if (start.month() !== target.month() || start.year() !== target.year()) {
1377
        const navigationTarget = {
1378
            shouldNavigate: true,
1379
            targetMonth: target.month(),
1380
            targetYear: target.year(),
1381
            targetDate: target.toDate(),
1382
        };
1383
        logger.debug("Calendar should navigate", navigationTarget);
1384
        return navigationTarget;
1385
    }
1386
1387
    logger.debug("No navigation needed - same month");
1388
    return { shouldNavigate: false };
1389
}
1390
1391
/**
1392
 * Aggregate markers by type for display
1393
 * @param {Array} markers - Array of booking markers
1394
 * @returns {import('../../types/bookings').MarkerAggregation} Aggregated counts by type
1395
 */
1396
export function aggregateMarkersByType(markers) {
1397
    logger.debug("Aggregating markers", { count: markers.length });
1398
1399
    const aggregated = markers.reduce((acc, marker) => {
1400
        // Exclude lead and trail markers from visual display
1401
        if (marker.type !== "lead" && marker.type !== "trail") {
1402
            acc[marker.type] = (acc[marker.type] || 0) + 1;
1403
        }
1404
        return acc;
1405
    }, {});
1406
1407
    logger.debug("Markers aggregated", aggregated);
1408
    return aggregated;
1409
}
1410
1411
// Re-export the new efficient data structure builders
1412
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 (+39 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>
(-)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 96-101 module.exports = [ Link Here
96
                    __dirname,
100
                    __dirname,
97
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
101
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
98
                ),
102
                ),
103
                "@bookingApi": path.resolve(
104
                    __dirname,
105
                    "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js"
106
                ),
99
            },
107
            },
100
        },
108
        },
101
        experiments: {
109
        experiments: {
Lines 167-172 module.exports = [ Link Here
167
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
175
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
168
        },
176
        },
169
    },
177
    },
178
    {
179
        resolve: {
180
            alias: {
181
                "@fetch": path.resolve(
182
                    __dirname,
183
                    "koha-tmpl/intranet-tmpl/prog/js/fetch"
184
                ),
185
                "@bookingApi": path.resolve(
186
                    __dirname,
187
                    "koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js"
188
                ),
189
            },
190
        },
191
        experiments: {
192
            outputModule: true,
193
        },
194
        entry: {
195
            islands: "./koha-tmpl/intranet-tmpl/prog/js/vue/modules/islands.ts",
196
        },
197
        output: {
198
            filename: "[name].esm.js",
199
            path: path.resolve(
200
                __dirname,
201
                "koha-tmpl/opac-tmpl/bootstrap/js/vue/dist/"
202
            ),
203
            chunkFilename: "[name].[contenthash].esm.js",
204
            globalObject: "window",
205
            library: {
206
                type: "module",
207
            },
208
        },
209
        module: {
210
            rules: [
211
                {
212
                    test: /\.vue$/,
213
                    loader: "vue-loader",
214
                    options: {
215
                        experimentalInlineMatchResource: true,
216
                    },
217
                    exclude: [path.resolve(__dirname, "t/cypress/")],
218
                },
219
                {
220
                    test: /\.ts$/,
221
                    loader: "builtin:swc-loader",
222
                    options: {
223
                        jsc: {
224
                            parser: {
225
                                syntax: "typescript",
226
                            },
227
                        },
228
                        appendTsSuffixTo: [/\.vue$/],
229
                    },
230
                    exclude: [
231
                        /node_modules/,
232
                        path.resolve(__dirname, "t/cypress/"),
233
                    ],
234
                    type: "javascript/auto",
235
                },
236
                {
237
                    test: /\.css$/i,
238
                    type: "javascript/auto",
239
                    use: ["style-loader", "css-loader"],
240
                },
241
            ],
242
        },
243
        plugins: [
244
            new VueLoaderPlugin(),
245
            new rspack.DefinePlugin({
246
                __VUE_OPTIONS_API__: true,
247
                __VUE_PROD_DEVTOOLS__: false,
248
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
249
            }),
250
        ],
251
        externals: {
252
            jquery: "jQuery",
253
            "datatables.net": "DataTable",
254
            "datatables.net-buttons": "DataTable",
255
            "datatables.net-buttons/js/buttons.html5": "DataTable",
256
            "datatables.net-buttons/js/buttons.print": "DataTable",
257
            "datatables.net-buttons/js/buttons.colVis": "DataTable",
258
        },
259
    },
170
    {
260
    {
171
        entry: {
261
        entry: {
172
            "api-client.cjs":
262
            "api-client.cjs":
173
- 

Return to bug 41129