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

(-)a/Koha/REST/V1/Libraries.pm (+52 lines)
Lines 19-24 use Modern::Perl; Link Here
19
19
20
use Mojo::Base 'Mojolicious::Controller';
20
use Mojo::Base 'Mojolicious::Controller';
21
use Koha::Libraries;
21
use Koha::Libraries;
22
use Koha::Calendar;
23
use Koha::DateUtils qw( dt_from_string );
22
24
23
use Scalar::Util qw( blessed );
25
use Scalar::Util qw( blessed );
24
26
Lines 209-212 sub list_cash_registers { Link Here
209
    };
211
    };
210
}
212
}
211
213
214
=head3 list_holidays
215
216
Controller function that returns closed days for a library within a date range.
217
Used by booking calendar to disable selection of closed days.
218
219
=cut
220
221
sub list_holidays {
222
    my $c = shift->openapi->valid_input or return;
223
224
    my $library_id = $c->param('library_id');
225
    my $from       = $c->param('from');
226
    my $to         = $c->param('to');
227
228
    my $library = Koha::Libraries->find($library_id);
229
230
    return $c->render_resource_not_found("Library")
231
        unless $library;
232
233
    return try {
234
        my $from_dt = $from ? dt_from_string( $from, 'iso' ) : dt_from_string();
235
        my $to_dt   = $to   ? dt_from_string( $to,   'iso' ) : $from_dt->clone->add( months => 3 );
236
237
        if ( $to_dt->compare($from_dt) < 0 ) {
238
            return $c->render(
239
                status  => 400,
240
                openapi => { error => "'to' date must be after 'from' date" }
241
            );
242
        }
243
244
        my $calendar = Koha::Calendar->new( branchcode => $library_id );
245
        my @holidays;
246
247
        my $current = $from_dt->clone;
248
        while ( $current <= $to_dt ) {
249
            if ( $calendar->is_holiday($current) ) {
250
                push @holidays, $current->ymd;
251
            }
252
            $current->add( days => 1 );
253
        }
254
255
        return $c->render(
256
            status  => 200,
257
            openapi => \@holidays
258
        );
259
    } catch {
260
        $c->unhandled_exception($_);
261
    };
262
}
263
212
1;
264
1;
(-)a/api/v1/swagger/paths/libraries.yaml (+56 lines)
Lines 425-430 Link Here
425
    x-koha-authorization:
425
    x-koha-authorization:
426
      permissions:
426
      permissions:
427
        catalogue: 1
427
        catalogue: 1
428
"/libraries/{library_id}/holidays":
429
  get:
430
    x-mojo-to: Libraries#list_holidays
431
    operationId: listLibraryHolidays
432
    tags:
433
      - libraries
434
    summary: List holidays for a library
435
    description: |
436
      Returns a list of closed days (holidays) for a library within a date range.
437
      Used by booking calendar to disable selection of closed days.
438
    parameters:
439
      - $ref: "../swagger.yaml#/parameters/library_id_pp"
440
      - name: from
441
        in: query
442
        description: Start date for the range (ISO 8601 format, e.g., 2024-01-01). Defaults to today.
443
        required: false
444
        type: string
445
        format: date
446
      - name: to
447
        in: query
448
        description: End date for the range (ISO 8601 format, e.g., 2024-03-31). Defaults to 3 months from 'from'.
449
        required: false
450
        type: string
451
        format: date
452
    produces:
453
      - application/json
454
    responses:
455
      "200":
456
        description: A list of holiday dates in YYYY-MM-DD format
457
        schema:
458
          type: array
459
          items:
460
            type: string
461
            format: date
462
      "400":
463
        description: Bad request
464
        schema:
465
          $ref: "../swagger.yaml#/definitions/error"
466
      "404":
467
        description: Library not found
468
        schema:
469
          $ref: "../swagger.yaml#/definitions/error"
470
      "500":
471
        description: |
472
          Internal server error. Possible `error_code` attribute values:
473
474
          * `internal_server_error`
475
        schema:
476
          $ref: "../swagger.yaml#/definitions/error"
477
      "503":
478
        description: Under maintenance
479
        schema:
480
          $ref: "../swagger.yaml#/definitions/error"
481
    x-koha-authorization:
482
      permissions:
483
        catalogue: "1"
428
/public/libraries:
484
/public/libraries:
429
  get:
485
  get:
430
    x-mojo-to: Libraries#list
486
    x-mojo-to: Libraries#list
(-)a/api/v1/swagger/paths/patrons.yaml (+1 lines)
Lines 505-510 Link Here
505
          enum:
505
          enum:
506
            - +strings
506
            - +strings
507
            - extended_attributes
507
            - extended_attributes
508
            - library
508
        collectionFormat: csv
509
        collectionFormat: csv
509
    produces:
510
    produces:
510
      - application/json
511
      - application/json
(-)a/api/v1/swagger/swagger.yaml (+2 lines)
Lines 485-490 paths: Link Here
485
    $ref: "./paths/libraries.yaml#/~1libraries~1{library_id}~1cash_registers"
485
    $ref: "./paths/libraries.yaml#/~1libraries~1{library_id}~1cash_registers"
486
  "/libraries/{library_id}/desks":
486
  "/libraries/{library_id}/desks":
487
    $ref: "./paths/libraries.yaml#/~1libraries~1{library_id}~1desks"
487
    $ref: "./paths/libraries.yaml#/~1libraries~1{library_id}~1desks"
488
  "/libraries/{library_id}/holidays":
489
    $ref: "./paths/libraries.yaml#/~1libraries~1{library_id}~1holidays"
488
  "/oauth/login/{provider_code}/{interface}":
490
  "/oauth/login/{provider_code}/{interface}":
489
    $ref: ./paths/oauth.yaml#/~1oauth~1login~1{provider_code}~1{interface}
491
    $ref: ./paths/oauth.yaml#/~1oauth~1login~1{provider_code}~1{interface}
490
  /oauth/token:
492
  /oauth/token:
(-)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 | html %]" type="module"></script>
13
<script type="module">
14
    import { hydrate } from "[% islands | html %]";
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/BookingAdditionalFields.vue (+214 lines)
Line 0 Link Here
1
<template>
2
    <fieldset v-if="visible && hasFields" class="step-block">
3
        <legend class="step-header">
4
            {{ stepNumber }}.
5
            {{ $__("Additional Fields") }}
6
        </legend>
7
        <ul
8
            id="booking_extended_attributes"
9
            ref="extendedAttributesContainer"
10
            class="booking-extended-attributes"
11
        ></ul>
12
    </fieldset>
13
</template>
14
15
<script setup lang="ts">
16
import { onMounted, onUnmounted, ref, watch, nextTick } from "vue";
17
import { $__ } from "../../i18n";
18
import { managerLogger } from "./lib/booking/logger.mjs";
19
import { useBookingStore } from "../../stores/bookings";
20
21
interface ExtendedAttribute {
22
    attribute_id?: number;
23
    type?: string;
24
    value?: string;
25
}
26
27
interface AdditionalFieldsInstance {
28
    renderExtendedAttributes: (
29
        types: Record<string, unknown>,
30
        attributes: ExtendedAttribute[],
31
        authorizedValues: Record<string, unknown>
32
    ) => void;
33
    destroy?: () => void;
34
}
35
36
interface AdditionalFieldsModule {
37
    init: (config: {
38
        containerId: string;
39
        resourceType: string;
40
    }) => AdditionalFieldsInstance;
41
}
42
43
declare global {
44
    interface Window {
45
        AdditionalFields?: AdditionalFieldsModule;
46
    }
47
}
48
49
const props = withDefaults(
50
    defineProps<{
51
        visible?: boolean;
52
        stepNumber: number;
53
        hasFields?: boolean;
54
        extendedAttributes?: ExtendedAttribute[];
55
        extendedAttributeTypes?: Record<string, unknown> | null;
56
        authorizedValues?: Record<string, unknown> | null;
57
    }>(),
58
    {
59
        visible: true,
60
        hasFields: false,
61
        extendedAttributes: () => [],
62
        extendedAttributeTypes: null,
63
        authorizedValues: null,
64
    }
65
);
66
67
const emit = defineEmits<{
68
    (e: "fields-ready", instance: AdditionalFieldsInstance): void;
69
    (e: "fields-destroyed"): void;
70
}>();
71
72
const store = useBookingStore();
73
const extendedAttributesContainer = ref<HTMLUListElement | null>(null);
74
const additionalFieldsInstance = ref<AdditionalFieldsInstance | null>(null);
75
76
const initializeAdditionalFields = (): void => {
77
    if (!props.visible || !props.hasFields) return;
78
    if (!props.extendedAttributeTypes || !extendedAttributesContainer.value)
79
        return;
80
81
    try {
82
        if (extendedAttributesContainer.value) {
83
            extendedAttributesContainer.value.innerHTML = "";
84
        }
85
86
        const additionalFieldsModule = window.AdditionalFields;
87
        if (additionalFieldsModule && props.extendedAttributeTypes) {
88
            additionalFieldsInstance.value = additionalFieldsModule.init({
89
                containerId: "booking_extended_attributes",
90
                resourceType: "booking",
91
            });
92
93
            additionalFieldsInstance.value.renderExtendedAttributes(
94
                props.extendedAttributeTypes,
95
                props.extendedAttributes || [],
96
                props.authorizedValues || {}
97
            );
98
99
            emit("fields-ready", additionalFieldsInstance.value);
100
        }
101
    } catch (error) {
102
        console.error("Failed to initialize additional fields:", error);
103
        try {
104
            store.setUiError(
105
                $__("Failed to initialize additional fields"),
106
                "ui"
107
            );
108
        } catch (e) {
109
            managerLogger.warn(
110
                "BookingAdditionalFields",
111
                "Failed to set error in store",
112
                e
113
            );
114
        }
115
    }
116
};
117
118
const destroyAdditionalFields = (): void => {
119
    if (typeof additionalFieldsInstance.value?.destroy === "function") {
120
        try {
121
            additionalFieldsInstance.value.destroy();
122
            emit("fields-destroyed");
123
        } catch (error) {
124
            console.error("Failed to destroy additional fields:", error);
125
            try {
126
                store.setUiError(
127
                    $__("Failed to clean up additional fields"),
128
                    "ui"
129
                );
130
            } catch (e) {
131
                managerLogger.warn(
132
                    "BookingAdditionalFields",
133
                    "Failed to set error in store",
134
                    e
135
                );
136
            }
137
        }
138
    }
139
    additionalFieldsInstance.value = null;
140
};
141
142
watch(
143
    () => [props.hasFields, props.extendedAttributeTypes, props.visible],
144
    () => {
145
        destroyAdditionalFields();
146
        if (props.visible && props.hasFields) {
147
            nextTick(initializeAdditionalFields);
148
        }
149
    },
150
    { immediate: false }
151
);
152
153
onMounted(() => {
154
    if (props.visible && props.hasFields) {
155
        initializeAdditionalFields();
156
    }
157
});
158
159
onUnmounted(() => {
160
    destroyAdditionalFields();
161
});
162
</script>
163
164
<style scoped>
165
.booking-extended-attributes {
166
    list-style: none;
167
    padding: 0;
168
    margin: 0;
169
}
170
171
.booking-extended-attributes :deep(.form-group) {
172
    margin-bottom: var(--booking-space-lg);
173
}
174
175
.booking-extended-attributes :deep(label) {
176
    font-weight: 500;
177
    margin-bottom: var(--booking-space-md);
178
    display: block;
179
}
180
181
.booking-extended-attributes :deep(.form-control) {
182
    width: 100%;
183
    min-width: var(--booking-input-min-width);
184
    padding: calc(var(--booking-space-sm) * 1.5)
185
        calc(var(--booking-space-sm) * 3);
186
    font-size: var(--booking-text-base);
187
    line-height: 1.5;
188
    color: var(--booking-neutral-600);
189
    background-color: #fff;
190
    background-clip: padding-box;
191
    border: var(--booking-border-width) solid var(--booking-neutral-300);
192
    border-radius: var(--booking-border-radius-sm);
193
    transition:
194
        border-color var(--booking-transition-fast),
195
        box-shadow var(--booking-transition-fast);
196
}
197
198
.booking-extended-attributes :deep(.form-control:focus) {
199
    color: var(--booking-neutral-600);
200
    background-color: #fff;
201
    border-color: hsl(var(--booking-info-hue), 70%, 60%);
202
    outline: 0;
203
    box-shadow: 0 0 0 0.2rem hsla(var(--booking-info-hue), 70%, 60%, 0.25);
204
}
205
206
.booking-extended-attributes :deep(select.form-control) {
207
    cursor: pointer;
208
}
209
210
.booking-extended-attributes :deep(textarea.form-control) {
211
    min-height: calc(var(--booking-space-2xl) * 2.5);
212
    resize: vertical;
213
}
214
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingDetailsStep.vue (+216 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">{{ $__("Pickup location") }}</label>
19
            <v-select
20
                v-model="selectedPickupLibraryId"
21
                :placeholder="$__('Select a pickup location')"
22
                :options="constrainedPickupLocations"
23
                label="name"
24
                :reduce="(l: PickupLocation) => l.library_id"
25
                :loading="loading.pickupLocations"
26
                :clearable="true"
27
                :disabled="selectsDisabled"
28
                :input-id="'pickup_library_id'"
29
            >
30
                <template #no-options>
31
                    {{ $__("No pickup locations available.") }}
32
                </template>
33
                <template #spinner>
34
                    <span class="sr-only">{{ $__("Loading...") }}</span>
35
                </template>
36
            </v-select>
37
            <span
38
                v-if="
39
                    constrainedFlags.pickupLocations &&
40
                    (showPickupLocationSelect || showItemDetailsSelects)
41
                "
42
                class="badge badge-warning ml-2"
43
            >
44
                {{ $__("Options updated") }}
45
                <span class="ml-1"
46
                    >({{ pickupLocationsTotal - pickupLocationsFilteredOut }}/{{
47
                        pickupLocationsTotal
48
                    }})</span
49
                >
50
            </span>
51
        </div>
52
53
        <div v-if="showItemDetailsSelects" class="form-group">
54
            <label for="booking_itemtype">{{ $__("Item type") }}</label>
55
            <v-select
56
                v-model="selectedItemtypeId"
57
                :options="constrainedItemTypes"
58
                label="description"
59
                :reduce="(t: ItemType) => t.item_type_id"
60
                :clearable="true"
61
                :disabled="selectsDisabled"
62
                :input-id="'booking_itemtype'"
63
            >
64
                <template #no-options>
65
                    {{ $__("No item types available.") }}
66
                </template>
67
            </v-select>
68
            <span
69
                v-if="constrainedFlags.itemTypes"
70
                class="badge badge-warning ml-2"
71
                >{{ $__("Options updated") }}</span
72
            >
73
        </div>
74
75
        <div v-if="showItemDetailsSelects" class="form-group">
76
            <label for="booking_item_id">{{ $__("Item") }}</label>
77
            <v-select
78
                v-model="selectedItemId"
79
                :placeholder="$__('Any item')"
80
                :options="constrainedBookableItems"
81
                label="external_id"
82
                :reduce="(i: BookableItem) => i.item_id"
83
                :clearable="true"
84
                :loading="loading.bookableItems"
85
                :disabled="selectsDisabled"
86
                :input-id="'booking_item_id'"
87
            >
88
                <template #no-options>
89
                    {{ $__("No items available.") }}
90
                </template>
91
                <template #spinner>
92
                    <span class="sr-only">{{ $__("Loading...") }}</span>
93
                </template>
94
            </v-select>
95
            <span
96
                v-if="constrainedFlags.bookableItems"
97
                class="badge badge-warning ml-2"
98
            >
99
                {{ $__("Options updated") }}
100
                <span class="ml-1"
101
                    >({{ bookableItemsTotal - bookableItemsFilteredOut }}/{{
102
                        bookableItemsTotal
103
                    }})</span
104
                >
105
            </span>
106
        </div>
107
    </fieldset>
108
</template>
109
110
<script setup lang="ts">
111
import { computed } from "vue";
112
import vSelect from "vue-select";
113
import { useBookingStore } from "../../stores/bookings";
114
import { storeToRefs } from "pinia";
115
import type {
116
    BookableItem,
117
    PickupLocation,
118
    PatronOption,
119
    Id,
120
    ItemType,
121
} from "./types/bookings";
122
123
interface ConstrainedFlags {
124
    pickupLocations: boolean;
125
    itemTypes: boolean;
126
    bookableItems: boolean;
127
}
128
129
const props = withDefaults(
130
    defineProps<{
131
        stepNumber: number;
132
        showItemDetailsSelects?: boolean;
133
        showPickupLocationSelect?: boolean;
134
        selectedPatron?: PatronOption | null;
135
        patronRequired?: boolean;
136
        detailsEnabled?: boolean;
137
        pickupLibraryId?: string | null;
138
        itemtypeId?: Id | null;
139
        itemId?: Id | null;
140
        constrainedPickupLocations?: PickupLocation[];
141
        constrainedItemTypes?: ItemType[];
142
        constrainedBookableItems?: BookableItem[];
143
        constrainedFlags?: ConstrainedFlags;
144
        pickupLocationsTotal?: number;
145
        pickupLocationsFilteredOut?: number;
146
        bookableItemsTotal?: number;
147
        bookableItemsFilteredOut?: number;
148
    }>(),
149
    {
150
        showItemDetailsSelects: false,
151
        showPickupLocationSelect: false,
152
        selectedPatron: null,
153
        patronRequired: false,
154
        detailsEnabled: true,
155
        pickupLibraryId: null,
156
        itemtypeId: null,
157
        itemId: null,
158
        constrainedPickupLocations: () => [],
159
        constrainedItemTypes: () => [],
160
        constrainedBookableItems: () => [],
161
        constrainedFlags: () => ({
162
            pickupLocations: false,
163
            itemTypes: false,
164
            bookableItems: false,
165
        }),
166
        pickupLocationsTotal: 0,
167
        pickupLocationsFilteredOut: 0,
168
        bookableItemsTotal: 0,
169
        bookableItemsFilteredOut: 0,
170
    }
171
);
172
173
const emit = defineEmits<{
174
    (e: "update:pickup-library-id", value: string | null): void;
175
    (e: "update:itemtype-id", value: Id | null): void;
176
    (e: "update:item-id", value: Id | null): void;
177
}>();
178
179
const store = useBookingStore();
180
const { loading } = storeToRefs(store);
181
182
const selectedPickupLibraryId = computed({
183
    get: () => props.pickupLibraryId,
184
    set: (value: string | null) => emit("update:pickup-library-id", value),
185
});
186
187
const selectedItemtypeId = computed({
188
    get: () => props.itemtypeId,
189
    set: (value: Id | null) => emit("update:itemtype-id", value),
190
});
191
192
const selectedItemId = computed({
193
    get: () => props.itemId,
194
    set: (value: Id | null) => emit("update:item-id", value),
195
});
196
197
const selectsDisabled = computed(
198
    () =>
199
        !props.detailsEnabled || (!props.selectedPatron && props.patronRequired)
200
);
201
</script>
202
203
<style scoped>
204
.form-group {
205
    margin-bottom: var(--booking-space-lg);
206
}
207
208
.badge {
209
    font-size: var(--booking-text-xs);
210
}
211
212
.badge-warning {
213
    background-color: var(--booking-warning-bg);
214
    color: var(--booking-neutral-600);
215
}
216
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingModal.vue (+1124 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 booking-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
                        />
41
                        <hr
42
                            v-if="
43
                                showPatronSelect ||
44
                                showItemDetailsSelects ||
45
                                showPickupLocationSelect
46
                            "
47
                        />
48
                        <BookingDetailsStep
49
                            v-if="
50
                                showItemDetailsSelects ||
51
                                showPickupLocationSelect
52
                            "
53
                            :step-number="stepNumber.details"
54
                            :details-enabled="readiness.dataReady"
55
                            :show-item-details-selects="showItemDetailsSelects"
56
                            :show-pickup-location-select="
57
                                showPickupLocationSelect
58
                            "
59
                            :selected-patron="bookingPatron"
60
                            :patron-required="showPatronSelect"
61
                            v-model:pickup-library-id="pickupLibraryId"
62
                            v-model:itemtype-id="bookingItemtypeId"
63
                            v-model:item-id="bookingItemId"
64
                            :constrained-pickup-locations="
65
                                constrainedPickupLocations
66
                            "
67
                            :constrained-item-types="constrainedItemTypes"
68
                            :constrained-bookable-items="
69
                                constrainedBookableItems
70
                            "
71
                            :constrained-flags="constrainedFlags"
72
                            :pickup-locations-total="pickupLocationsTotal"
73
                            :pickup-locations-filtered-out="
74
                                pickupLocationsFilteredOut
75
                            "
76
                            :bookable-items-total="bookableItemsTotal"
77
                            :bookable-items-filtered-out="
78
                                bookableItemsFilteredOut
79
                            "
80
                        />
81
                        <hr
82
                            v-if="
83
                                showItemDetailsSelects ||
84
                                showPickupLocationSelect
85
                            "
86
                        />
87
                        <BookingPeriodStep
88
                            :step-number="stepNumber.period"
89
                            :calendar-enabled="readiness.isCalendarReady"
90
                            :constraint-options="constraintOptions"
91
                            :date-range-constraint="dateRangeConstraint"
92
                            :max-booking-period="maxBookingPeriod"
93
                            :error-message="store.uiError.message"
94
                            :has-selected-dates="selectedDateRange?.length > 0"
95
                            @clear-dates="clearDateRange"
96
                        />
97
                        <hr
98
                            v-if="
99
                                showAdditionalFields &&
100
                                modalState.hasAdditionalFields
101
                            "
102
                        />
103
                        <BookingAdditionalFields
104
                            v-if="showAdditionalFields"
105
                            :step-number="stepNumber.additionalFields"
106
                            :has-fields="modalState.hasAdditionalFields"
107
                            :extended-attributes="extendedAttributes"
108
                            :extended-attribute-types="extendedAttributeTypes"
109
                            :authorized-values="authorizedValues"
110
                            @fields-ready="onAdditionalFieldsReady"
111
                            @fields-destroyed="onAdditionalFieldsDestroyed"
112
                        />
113
                    </form>
114
                </div>
115
                <div class="modal-footer">
116
                    <div class="d-flex gap-2">
117
                        <button
118
                            class="btn btn-primary"
119
                            :disabled="loading.submit || !isSubmitReady"
120
                            type="submit"
121
                            form="form-booking"
122
                        >
123
                            {{ submitLabel }}
124
                        </button>
125
                        <button
126
                            class="btn btn-secondary ml-2"
127
                            @click.prevent="handleClose"
128
                        >
129
                            {{ $__("Cancel") }}
130
                        </button>
131
                    </div>
132
                </div>
133
            </div>
134
        </div>
135
    </div>
136
</template>
137
138
<script setup lang="ts">
139
import { BookingDate } from "./lib/booking/BookingDate.mjs";
140
import {
141
    computed,
142
    ref,
143
    reactive,
144
    watch,
145
    nextTick,
146
    onMounted,
147
    onUnmounted,
148
} from "vue";
149
import BookingPatronStep from "./BookingPatronStep.vue";
150
import BookingDetailsStep from "./BookingDetailsStep.vue";
151
import BookingPeriodStep from "./BookingPeriodStep.vue";
152
import BookingAdditionalFields from "./BookingAdditionalFields.vue";
153
import { $__ } from "../../i18n";
154
import { processApiError } from "../../utils/apiErrors.js";
155
import {
156
    constrainBookableItems,
157
    constrainItemTypes,
158
    constrainPickupLocations,
159
} from "./lib/booking/constraints.mjs";
160
import { useBookingStore } from "../../stores/bookings";
161
import { storeToRefs } from "pinia";
162
import { updateExternalDependents } from "./lib/adapters/external-dependents.mjs";
163
import { Modal } from "bootstrap";
164
import { appendHiddenInputs } from "./lib/adapters/form.mjs";
165
import { calculateStepNumbers } from "./lib/ui/steps.mjs";
166
import { useBookingValidation } from "./composables/useBookingValidation.mjs";
167
import { calculateMaxBookingPeriod, getAvailableItemsForPeriod } from "./lib/booking/availability.mjs";
168
import { useFormDefaults } from "./composables/useFormDefaults.mjs";
169
import { buildNoItemsAvailableMessage } from "./lib/ui/selection-message.mjs";
170
import { useRulesFetcher } from "./composables/useRulesFetcher.mjs";
171
import { normalizeIdType } from "./lib/booking/id-utils.mjs";
172
import { useCapacityGuard } from "./composables/useCapacityGuard.mjs";
173
import KohaAlert from "../KohaAlert.vue";
174
import type { Id, CirculationRule } from "./types/bookings";
175
176
interface ExtendedAttribute {
177
    attribute_id?: number;
178
    type?: string;
179
    value?: string;
180
}
181
182
interface AdditionalFieldsInstance {
183
    renderExtendedAttributes: (
184
        types: Record<string, unknown> | unknown[],
185
        attributes: ExtendedAttribute[],
186
        authorizedValues: Record<string, unknown> | null
187
    ) => void;
188
    fetchExtendedAttributes?: (type: string) => Promise<unknown[]>;
189
    getValues?: () => ExtendedAttribute[];
190
    clear?: () => void;
191
    destroy?: () => void;
192
}
193
194
type DateRangeConstraintType = "issuelength" | "issuelength_with_renewals" | "custom" | null;
195
type SubmitType = "api" | "form-submission";
196
197
const props = withDefaults(
198
    defineProps<{
199
        open?: boolean;
200
        size?: string;
201
        title?: string;
202
        biblionumber: string | number;
203
        bookingId?: Id | null;
204
        itemId?: Id | null;
205
        patronId?: Id | null;
206
        pickupLibraryId?: string | null;
207
        startDate?: string | null;
208
        endDate?: string | null;
209
        itemtypeId?: Id | null;
210
        showPatronSelect?: boolean;
211
        showItemDetailsSelects?: boolean;
212
        showPickupLocationSelect?: boolean;
213
        submitType?: SubmitType;
214
        submitUrl?: string;
215
        extendedAttributes?: ExtendedAttribute[];
216
        extendedAttributeTypes?: Record<string, unknown> | null;
217
        authorizedValues?: Record<string, unknown> | null;
218
        showAdditionalFields?: boolean;
219
        dateRangeConstraint?: DateRangeConstraintType;
220
        customDateRangeFormula?: ((rules: CirculationRule) => number | null) | null;
221
        opacDefaultBookingLibraryEnabled?: boolean | string | null;
222
        opacDefaultBookingLibrary?: string | null;
223
    }>(),
224
    {
225
        open: false,
226
        size: "lg",
227
        title: "",
228
        bookingId: null,
229
        itemId: null,
230
        patronId: null,
231
        pickupLibraryId: null,
232
        startDate: null,
233
        endDate: null,
234
        itemtypeId: null,
235
        showPatronSelect: false,
236
        showItemDetailsSelects: false,
237
        showPickupLocationSelect: false,
238
        submitType: "api",
239
        submitUrl: "",
240
        extendedAttributes: () => [],
241
        extendedAttributeTypes: null,
242
        authorizedValues: null,
243
        showAdditionalFields: false,
244
        dateRangeConstraint: null,
245
        customDateRangeFormula: null,
246
        opacDefaultBookingLibraryEnabled: null,
247
        opacDefaultBookingLibrary: null,
248
    }
249
);
250
251
const emit = defineEmits<{
252
    (e: "close"): void;
253
}>();
254
255
const store = useBookingStore();
256
257
const modalElement = ref<HTMLElement | null>(null);
258
let bsModal: InstanceType<typeof Modal> | null = null;
259
260
const {
261
    bookingId,
262
    bookingItemId,
263
    bookingPatron,
264
    bookingItemtypeId,
265
    pickupLibraryId,
266
    selectedDateRange,
267
    bookableItems,
268
    pickupLocations,
269
    itemTypes,
270
    circulationRules,
271
    circulationRulesContext,
272
    loading,
273
} = storeToRefs(store);
274
275
const { canSubmit: canSubmitReactive } = useBookingValidation(store);
276
277
const modalState = reactive({
278
    isOpen: props.open,
279
    step: 1,
280
    hasAdditionalFields: false,
281
});
282
283
const additionalFieldsInstance = ref<AdditionalFieldsInstance | null>(null);
284
285
const modalTitle = computed(
286
    () =>
287
        props.title ||
288
        (bookingId.value ? $__("Edit booking") : $__("Place booking"))
289
);
290
291
const showPickupLocationSelect = computed(() => {
292
    if (props.opacDefaultBookingLibraryEnabled !== null) {
293
        const enabled = props.opacDefaultBookingLibraryEnabled === true ||
294
            String(props.opacDefaultBookingLibraryEnabled) === "1";
295
        return !enabled;
296
    }
297
    return props.showPickupLocationSelect;
298
});
299
300
const stepNumber = computed(() => {
301
    return calculateStepNumbers(
302
        props.showPatronSelect,
303
        props.showItemDetailsSelects,
304
        showPickupLocationSelect.value,
305
        props.showAdditionalFields,
306
        modalState.hasAdditionalFields
307
    );
308
});
309
310
const submitLabel = computed(() =>
311
    bookingId.value ? $__("Update booking") : $__("Place booking")
312
);
313
314
const isFormSubmission = computed(
315
    () => props.submitType === "form-submission"
316
);
317
318
const constraints = computed(() => {
319
    const pickup = constrainPickupLocations(
320
        pickupLocations.value,
321
        bookableItems.value,
322
        bookingItemtypeId.value,
323
        bookingItemId.value
324
    );
325
    const items = constrainBookableItems(
326
        bookableItems.value,
327
        pickupLocations.value,
328
        pickupLibraryId.value,
329
        bookingItemtypeId.value
330
    );
331
    const types = constrainItemTypes(
332
        itemTypes.value,
333
        bookableItems.value,
334
        pickupLocations.value,
335
        pickupLibraryId.value,
336
        bookingItemId.value
337
    );
338
339
    return {
340
        pickupLocations: pickup,
341
        bookableItems: items,
342
        itemTypes: types,
343
        flags: {
344
            pickupLocations: pickup.constraintApplied,
345
            bookableItems: items.constraintApplied,
346
            itemTypes: types.constraintApplied,
347
        },
348
    };
349
});
350
351
const constrainedFlags = computed(() => constraints.value.flags);
352
const constrainedPickupLocations = computed(() => constraints.value.pickupLocations.filtered);
353
const constrainedBookableItems = computed(() => constraints.value.bookableItems.filtered);
354
const constrainedItemTypes = computed(() => constraints.value.itemTypes.filtered);
355
const pickupLocationsFilteredOut = computed(() => constraints.value.pickupLocations.filteredOutCount);
356
const pickupLocationsTotal = computed(() => constraints.value.pickupLocations.total);
357
const bookableItemsFilteredOut = computed(() => constraints.value.bookableItems.filteredOutCount);
358
const bookableItemsTotal = computed(() => constraints.value.bookableItems.total);
359
360
const maxBookingPeriod = computed(() =>
361
    calculateMaxBookingPeriod(
362
        circulationRules.value,
363
        props.dateRangeConstraint,
364
        props.customDateRangeFormula
365
    )
366
);
367
368
const constraintOptions = computed(() => ({
369
    dateRangeConstraint: props.dateRangeConstraint,
370
    maxBookingPeriod: maxBookingPeriod.value,
371
}));
372
373
const { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning } =
374
    useCapacityGuard({
375
        circulationRules,
376
        circulationRulesContext,
377
        loading,
378
        bookableItems,
379
        bookingPatron,
380
        bookingItemId,
381
        bookingItemtypeId,
382
        pickupLibraryId,
383
        showPatronSelect: props.showPatronSelect,
384
        showItemDetailsSelects: props.showItemDetailsSelects,
385
        showPickupLocationSelect: showPickupLocationSelect.value,
386
        dateRangeConstraint: props.dateRangeConstraint,
387
    });
388
389
const dataReady = computed(
390
    () =>
391
        !loading.value.bookableItems &&
392
        !loading.value.bookings &&
393
        !loading.value.checkouts &&
394
        (bookableItems.value?.length ?? 0) > 0
395
);
396
const formPrefilterValid = computed(() => {
397
    const requireTypeOrItem = !!props.showItemDetailsSelects;
398
    const hasTypeOrItem =
399
        !!bookingItemId.value || !!bookingItemtypeId.value;
400
    const patronOk = !props.showPatronSelect || !!bookingPatron.value;
401
    return patronOk && (requireTypeOrItem ? hasTypeOrItem : true);
402
});
403
const hasAvailableItems = computed(
404
    () => constrainedBookableItems.value.length > 0
405
);
406
407
const isCalendarReady = computed(() => {
408
    const basicReady = dataReady.value &&
409
        formPrefilterValid.value &&
410
        hasAvailableItems.value;
411
    if (!basicReady) return false;
412
    if (loading.value.circulationRules) return true;
413
414
    return hasPositiveCapacity.value;
415
});
416
417
const isSubmitReady = computed(
418
    () => isCalendarReady.value && canSubmitReactive.value
419
);
420
421
const readiness = computed(() => ({
422
    dataReady: dataReady.value,
423
    formPrefilterValid: formPrefilterValid.value,
424
    hasAvailableItems: hasAvailableItems.value,
425
    isCalendarReady: isCalendarReady.value,
426
    canSubmit: isSubmitReady.value,
427
}));
428
429
watch(
430
    () => props.open,
431
    val => {
432
        if (val) {
433
            resetModalState();
434
            bsModal?.show();
435
        }
436
    }
437
);
438
439
watch(
440
    () => modalState.isOpen,
441
    async open => {
442
        if (!open) {
443
            return;
444
        }
445
446
        modalState.step = 1;
447
        const biblionumber = props.biblionumber;
448
        if (!biblionumber) return;
449
450
        bookingId.value = props.bookingId;
451
452
        try {
453
            await Promise.all([
454
                store.fetchBookableItems(biblionumber),
455
                store.fetchBookings(biblionumber),
456
                store.fetchCheckouts(biblionumber),
457
            ]);
458
459
            const additionalFieldsModule = window["AdditionalFields"];
460
            if (additionalFieldsModule) {
461
                await renderExtendedAttributes(additionalFieldsModule);
462
            } else {
463
                modalState.hasAdditionalFields = false;
464
            }
465
466
            store.deriveItemTypesFromBookableItems();
467
468
            if (props.patronId) {
469
                const patron = await store.fetchPatron(props.patronId);
470
                await store.fetchPickupLocations(
471
                    biblionumber,
472
                    props.patronId
473
                );
474
475
                bookingPatron.value = patron;
476
            }
477
478
            bookingItemId.value = (props.itemId != null) ? normalizeIdType(bookableItems.value?.[0]?.item_id, props.itemId) : null;
479
            if (props.itemtypeId) {
480
                bookingItemtypeId.value = props.itemtypeId;
481
            }
482
483
            if (props.startDate && props.endDate) {
484
                selectedDateRange.value = [
485
                    BookingDate.from(props.startDate).toISO(),
486
                    BookingDate.from(props.endDate).toISO(),
487
                ];
488
            }
489
        } catch (error) {
490
            console.error("Error initializing booking modal:", error);
491
            store.setUiError(processApiError(error), "api");
492
        }
493
    }
494
);
495
496
useRulesFetcher({
497
    store,
498
    bookingPatron,
499
    bookingPickupLibraryId: pickupLibraryId,
500
    bookingItemtypeId,
501
    constrainedItemTypes,
502
    selectedDateRange,
503
    biblionumber: String(props.biblionumber),
504
});
505
506
useFormDefaults({
507
    bookingPickupLibraryId: pickupLibraryId,
508
    bookingPatron,
509
    pickupLocations,
510
    bookableItems,
511
    bookingItemtypeId,
512
    bookingItemId,
513
    constrainedItemTypes,
514
    opacDefaultBookingLibraryEnabled: props.opacDefaultBookingLibraryEnabled,
515
    opacDefaultBookingLibrary: props.opacDefaultBookingLibrary,
516
});
517
518
watch(
519
    () => ({
520
        patron: bookingPatron.value?.patron_id,
521
        pickup: pickupLibraryId.value,
522
        itemtype: bookingItemtypeId.value,
523
        item: bookingItemId.value,
524
        d0: selectedDateRange.value?.[0],
525
        d1: selectedDateRange.value?.[1],
526
    }),
527
    (curr, prev) => {
528
        const inputsChanged =
529
            !prev ||
530
            curr.patron !== prev.patron ||
531
            curr.pickup !== prev.pickup ||
532
            curr.itemtype !== prev.itemtype ||
533
            curr.item !== prev.item ||
534
            curr.d0 !== prev.d0 ||
535
            curr.d1 !== prev.d1;
536
        if (inputsChanged) clearErrors();
537
    }
538
);
539
540
watch(
541
    [
542
        constrainedBookableItems,
543
        () => bookingPatron.value,
544
        () => pickupLibraryId.value,
545
        () => bookingItemtypeId.value,
546
        dataReady,
547
        () => loading.value.circulationRules,
548
        () => loading.value.pickupLocations,
549
    ],
550
    ([availableItems, patron, pickupLibrary, itemtypeId, isDataReady]) => {
551
        const pickupLocationsReady = !pickupLibrary || (!loading.value.pickupLocations && pickupLocations.value.length > 0);
552
        const circulationRulesReady = !loading.value.circulationRules;
553
554
        if (
555
            isDataReady &&
556
            pickupLocationsReady &&
557
            circulationRulesReady &&
558
            patron &&
559
            (pickupLibrary || itemtypeId) &&
560
            availableItems.length === 0
561
        ) {
562
            const msg = buildNoItemsAvailableMessage(
563
                pickupLocations.value,
564
                itemTypes.value,
565
                pickupLibrary,
566
                itemtypeId
567
            );
568
            store.setUiError(msg, "no_items");
569
        } else if (store.uiError.code === "no_items") {
570
            clearErrors();
571
        }
572
    },
573
    { immediate: true }
574
);
575
576
/**
577
 * Handle additional fields initialization
578
 */
579
async function renderExtendedAttributes(additionalFieldsModule) {
580
    try {
581
        additionalFieldsInstance.value = additionalFieldsModule.init({
582
            containerId: "booking_extended_attributes",
583
            resourceType: "booking",
584
        });
585
586
        const additionalFieldTypes =
587
            props.extendedAttributeTypes ??
588
            (await additionalFieldsInstance.value.fetchExtendedAttributes(
589
                "booking"
590
            ));
591
        if (!additionalFieldTypes?.length) {
592
            modalState.hasAdditionalFields = false;
593
            return;
594
        }
595
596
        modalState.hasAdditionalFields = true;
597
598
        nextTick(() => {
599
            additionalFieldsInstance.value.renderExtendedAttributes(
600
                additionalFieldTypes,
601
                props.extendedAttributes,
602
                props.authorizedValues
603
            );
604
        });
605
    } catch (error) {
606
        console.error("Failed to render extended attributes:", error);
607
        modalState.hasAdditionalFields = false;
608
    }
609
}
610
611
function onAdditionalFieldsReady(instance) {
612
    additionalFieldsInstance.value = instance;
613
}
614
615
function onAdditionalFieldsDestroyed() {
616
    additionalFieldsInstance.value = null;
617
}
618
619
function clearErrors() {
620
    store.clearUiError();
621
    store.resetErrors();
622
}
623
624
625
function resetModalState() {
626
    bookingPatron.value = null;
627
    pickupLibraryId.value = null;
628
    bookingItemtypeId.value = null;
629
    bookingItemId.value = null;
630
    selectedDateRange.value = [];
631
    modalState.step = 1;
632
    clearErrors();
633
    additionalFieldsInstance.value?.clear?.();
634
    modalState.hasAdditionalFields = false;
635
}
636
637
function clearDateRange() {
638
    selectedDateRange.value = [];
639
    clearErrors();
640
}
641
642
function handleClose() {
643
    if (document.activeElement instanceof HTMLElement) {
644
        document.activeElement.blur();
645
    }
646
    bsModal?.hide();
647
}
648
649
async function handleSubmit(event) {
650
    const selectedDates = selectedDateRange.value;
651
652
    if (!selectedDates || selectedDates.length === 0) {
653
        store.setUiError($__("Please select a valid date range"), "invalid_date_range");
654
        return;
655
    }
656
657
    // Match upstream behavior: start date at start of day, end date at end of day
658
    const start = selectedDates[0];
659
    const endDateRaw =
660
        selectedDates.length >= 2 ? selectedDates[1] : selectedDates[0];
661
    // Apply endOf("day") to end date to match upstream storage format (23:59:59)
662
    const end = BookingDate.from(endDateRaw, { preserveTime: true }).toDayjs().endOf("day").toISOString();
663
    const bookingData: Record<string, unknown> = {
664
        booking_id: props.bookingId ?? undefined,
665
        start_date: start,
666
        end_date: end,
667
        pickup_library_id: pickupLibraryId.value,
668
        biblio_id: props.biblionumber,
669
        patron_id: bookingPatron.value?.patron_id,
670
        extended_attributes: additionalFieldsInstance.value
671
            ? additionalFieldsInstance.value.getValues()
672
            : [],
673
    };
674
675
    // 3-way payload logic matching upstream behavior:
676
    // Specific item → send item_id
677
    // Any item, 1 available → auto-assign item_id
678
    // Any item, 2+ available → send itemtype_id for server-side assignment
679
    // Any item, 0 available → error
680
    if (bookingItemId.value) {
681
        bookingData.item_id = bookingItemId.value;
682
    } else {
683
        const available = getAvailableItemsForPeriod(
684
            start,
685
            end,
686
            constrainedBookableItems.value,
687
            store.bookings,
688
            store.checkouts,
689
            circulationRules.value?.[0] || {},
690
            props.bookingId
691
        );
692
693
        if (available.length === 0) {
694
            store.setUiError(
695
                $__("No items available for the selected period"),
696
                "no_available_items"
697
            );
698
            return;
699
        } else if (available.length === 1) {
700
            bookingData.item_id = available[0].item_id;
701
        } else {
702
            bookingData.itemtype_id = bookingItemtypeId.value;
703
        }
704
    }
705
706
    if (isFormSubmission.value) {
707
        const form = event.target as HTMLFormElement;
708
        const csrfToken = document.querySelector('[name="csrf_token"]') as HTMLInputElement | null;
709
710
        const dataToSubmit: Record<string, unknown> = { ...bookingData };
711
        if (dataToSubmit.extended_attributes) {
712
            dataToSubmit.extended_attributes = JSON.stringify(
713
                dataToSubmit.extended_attributes
714
            );
715
        }
716
717
        appendHiddenInputs(
718
            form,
719
            [
720
                ...Object.entries(dataToSubmit),
721
                [csrfToken?.name, csrfToken?.value],
722
                ['op', 'cud-add'],
723
            ]
724
        );
725
        form.submit();
726
        return;
727
    }
728
729
    try {
730
        // Remove extended_attributes before API call — not yet supported upstream
731
        const { extended_attributes, ...apiData } = bookingData;
732
        const result = await store.saveOrUpdateBooking(apiData)
733
        updateExternalDependents(result, bookingPatron.value, !!props.bookingId);
734
        handleClose();
735
    } catch (errorObj) {
736
        store.setUiError(processApiError(errorObj), "api");
737
    }
738
}
739
740
onMounted(() => {
741
    if (modalElement.value) {
742
        bsModal = new Modal(modalElement.value, {
743
            backdrop: "static",
744
            keyboard: false,
745
        });
746
        modalElement.value.addEventListener("hidden.bs.modal", () => {
747
            emit("close");
748
            resetModalState();
749
        });
750
        modalElement.value.addEventListener("shown.bs.modal", () => {
751
            modalState.isOpen = true;
752
        });
753
    }
754
});
755
756
onUnmounted(() => {
757
    if (typeof additionalFieldsInstance.value?.destroy === "function") {
758
        additionalFieldsInstance.value.destroy();
759
    }
760
    bsModal?.dispose();
761
});
762
763
</script>
764
765
<style>
766
/* Global variables for external libraries (flatpickr) and cross-block usage */
767
:root {
768
    /* Success colors for constraint highlighting */
769
    --booking-success-hue: 134;
770
    --booking-success-bg: hsl(var(--booking-success-hue), 40%, 90%);
771
    --booking-success-bg-hover: hsl(var(--booking-success-hue), 35%, 85%);
772
    --booking-success-border: hsl(var(--booking-success-hue), 70%, 40%);
773
    --booking-success-border-hover: hsl(var(--booking-success-hue), 75%, 30%);
774
    --booking-success-text: hsl(var(--booking-success-hue), 80%, 20%);
775
776
    /* Border width used by flatpickr */
777
    --booking-border-width: 1px;
778
779
    /* Variables used by second style block (booking markers, calendar states) */
780
    --booking-marker-size: max(4px, 0.25em);
781
    --booking-marker-grid-gap: 0.25rem;
782
    --booking-marker-grid-offset: -0.75rem;
783
784
    /* Color hues used in second style block */
785
    --booking-warning-hue: 45;
786
    --booking-danger-hue: 354;
787
    --booking-info-hue: 195;
788
    --booking-neutral-hue: 210;
789
    --booking-holiday-hue: 0;
790
791
    /* Colors derived from hues (used in second style block) */
792
    --booking-warning-bg: hsl(var(--booking-warning-hue), 100%, 85%);
793
    --booking-neutral-600: hsl(var(--booking-neutral-hue), 10%, 45%);
794
    --booking-holiday-bg: hsl(var(--booking-holiday-hue), 0%, 85%);
795
    --booking-holiday-text: hsl(var(--booking-holiday-hue), 0%, 40%);
796
797
    /* Spacing used in second style block */
798
    --booking-space-xs: 0.125rem;
799
800
    /* Typography used in second style block */
801
    --booking-text-xs: 0.7rem;
802
803
    /* Border radius used in second style block and other components */
804
    --booking-border-radius-sm: 0.25rem;
805
    --booking-border-radius-md: 0.5rem;
806
    --booking-border-radius-full: 50%;
807
}
808
809
/* Design System: CSS Custom Properties (First Style Block Only) */
810
:root {
811
    /* Colors not used in second style block */
812
    --booking-warning-bg-hover: hsl(var(--booking-warning-hue), 100%, 70%);
813
    --booking-neutral-100: hsl(var(--booking-neutral-hue), 15%, 92%);
814
    --booking-neutral-300: hsl(var(--booking-neutral-hue), 15%, 75%);
815
    --booking-neutral-500: hsl(var(--booking-neutral-hue), 10%, 55%);
816
817
    /* Spacing Scale (first block only) */
818
    --booking-space-sm: 0.25rem; /* 4px */
819
    --booking-space-md: 0.5rem; /* 8px */
820
    --booking-space-lg: 1rem; /* 16px */
821
    --booking-space-xl: 1.5rem; /* 24px */
822
    --booking-space-2xl: 2rem; /* 32px */
823
824
    /* Typography Scale (first block only) */
825
    --booking-text-sm: 0.8125rem;
826
    --booking-text-base: 1rem;
827
    --booking-text-lg: 1.1rem;
828
    --booking-text-xl: 1.3rem;
829
    --booking-text-2xl: 2rem;
830
831
    /* Layout */
832
    --booking-modal-max-height: calc(100vh - var(--booking-space-2xl));
833
    --booking-input-min-width: 15rem;
834
835
    /* Animation */
836
    --booking-transition-fast: 0.15s ease-in-out;
837
}
838
839
/* Constraint Highlighting Component */
840
.flatpickr-calendar .booking-constrained-range-marker {
841
    background-color: var(--booking-success-bg) !important;
842
    border: var(--booking-border-width) solid var(--booking-success-border) !important;
843
    color: var(--booking-success-text) !important;
844
}
845
846
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker {
847
    background-color: var(--booking-success-bg) !important;
848
    border-color: var(--booking-success-border) !important;
849
    color: var(--booking-success-text) !important;
850
}
851
852
.flatpickr-calendar .flatpickr-day.booking-constrained-range-marker:hover {
853
    background-color: var(--booking-success-bg-hover) !important;
854
    border-color: var(--booking-success-border-hover) !important;
855
}
856
857
/* End Date Only Mode - Blocked Intermediate Dates */
858
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked {
859
    background-color: hsl(var(--booking-success-hue), 40%, 90%) !important;
860
    border-color: hsl(var(--booking-success-hue), 40%, 70%) !important;
861
    color: hsl(var(--booking-success-hue), 40%, 50%) !important;
862
    cursor: not-allowed !important;
863
    opacity: 0.7 !important;
864
}
865
866
/* Bold styling for end of loan and renewal period boundaries */
867
.flatpickr-calendar .flatpickr-day.booking-loan-boundary {
868
    font-weight: 700 !important;
869
}
870
.flatpickr-calendar .flatpickr-day.booking-intermediate-blocked:hover {
871
    background-color: hsl(var(--booking-success-hue), 40%, 85%) !important;
872
    border-color: hsl(var(--booking-success-hue), 40%, 60%) !important;
873
}
874
875
.booking-modal-body {
876
    padding: var(--booking-space-xl);
877
    overflow-y: auto;
878
    flex: 1 1 auto;
879
}
880
881
/* Form & Layout Components */
882
.booking-extended-attributes {
883
    list-style: none;
884
    padding: 0;
885
    margin: 0;
886
}
887
888
.step-block {
889
    margin-bottom: var(--booking-space-lg);
890
}
891
892
.step-header {
893
    font-weight: 600;
894
    font-size: var(--booking-text-lg);
895
    margin-bottom: calc(var(--booking-space-lg) * 0.75);
896
    color: var(--booking-neutral-600);
897
}
898
899
hr {
900
    border: none;
901
    border-top: var(--booking-border-width) solid var(--booking-neutral-100);
902
    margin: var(--booking-space-2xl) 0;
903
}
904
905
/* Input Components */
906
.booking-flatpickr-input,
907
.flatpickr-input.booking-flatpickr-input {
908
    min-width: var(--booking-input-min-width);
909
    padding: calc(var(--booking-space-md) - var(--booking-space-xs))
910
        calc(var(--booking-space-md) + var(--booking-space-sm));
911
    border: var(--booking-border-width) solid var(--booking-neutral-300);
912
    border-radius: var(--booking-border-radius-sm);
913
    font-size: var(--booking-text-base);
914
    transition: border-color var(--booking-transition-fast),
915
        box-shadow var(--booking-transition-fast);
916
}
917
918
/* Calendar Legend Component */
919
.calendar-legend {
920
    margin-top: var(--booking-space-lg);
921
    margin-bottom: var(--booking-space-lg);
922
    font-size: var(--booking-text-sm);
923
    display: flex;
924
    align-items: center;
925
}
926
927
.calendar-legend .booking-marker-dot {
928
    width: calc(var(--booking-marker-size) * 2) !important;
929
    height: calc(var(--booking-marker-size) * 2) !important;
930
    margin-right: calc(var(--booking-space-sm) * 1.5);
931
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
932
}
933
934
.calendar-legend .ml-3 {
935
    margin-left: var(--booking-space-lg);
936
}
937
938
/* Legend colors match actual calendar markers exactly */
939
.calendar-legend .booking-marker-dot--booked {
940
    background: var(--booking-warning-bg) !important;
941
}
942
943
.calendar-legend .booking-marker-dot--checked-out {
944
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
945
}
946
947
.calendar-legend .booking-marker-dot--lead {
948
    background: hsl(var(--booking-info-hue), 60%, 85%) !important;
949
}
950
951
.calendar-legend .booking-marker-dot--trail {
952
    background: var(--booking-warning-bg) !important;
953
}
954
</style>
955
956
<style>
957
.booking-date-picker {
958
    display: flex;
959
    align-items: stretch;
960
    width: 100%;
961
}
962
963
.booking-date-picker > .form-control {
964
    flex: 1 1 auto;
965
    min-width: 0;
966
    margin-bottom: 0;
967
}
968
969
.booking-date-picker-append {
970
    display: flex;
971
    margin-left: -1px;
972
}
973
974
.booking-date-picker-append .btn {
975
    border-top-left-radius: 0;
976
    border-bottom-left-radius: 0;
977
}
978
979
.booking-date-picker > .form-control:not(:last-child) {
980
    border-top-right-radius: 0;
981
    border-bottom-right-radius: 0;
982
}
983
984
/* External Library Integration */
985
.booking-modal-body .vs__selected {
986
    font-size: var(--vs-font-size);
987
    line-height: var(--vs-line-height);
988
}
989
990
.booking-constraint-info {
991
    margin-top: var(--booking-space-lg);
992
    margin-bottom: var(--booking-space-lg);
993
}
994
995
/* Booking Status Marker System */
996
.booking-marker-grid {
997
    position: relative;
998
    top: var(--booking-marker-grid-offset);
999
    display: flex;
1000
    flex-wrap: wrap;
1001
    justify-content: center;
1002
    gap: var(--booking-marker-grid-gap);
1003
    width: fit-content;
1004
    max-width: 90%;
1005
    margin-left: auto;
1006
    margin-right: auto;
1007
    line-height: normal;
1008
}
1009
1010
.booking-marker-item {
1011
    display: inline-flex;
1012
    align-items: center;
1013
}
1014
1015
.booking-marker-dot {
1016
    display: inline-block;
1017
    width: var(--booking-marker-size);
1018
    height: var(--booking-marker-size);
1019
    border-radius: var(--booking-border-radius-full);
1020
    vertical-align: middle;
1021
}
1022
1023
.booking-marker-count {
1024
    font-size: var(--booking-text-xs);
1025
    margin-left: var(--booking-space-xs);
1026
    line-height: 1;
1027
    font-weight: normal;
1028
    color: var(--booking-neutral-600);
1029
}
1030
1031
/* Status Indicator Colors */
1032
.booking-marker-dot--booked {
1033
    background: var(--booking-warning-bg);
1034
}
1035
1036
.booking-marker-dot--checked-out {
1037
    background: hsl(var(--booking-danger-hue), 60%, 85%);
1038
}
1039
1040
.booking-marker-dot--lead {
1041
    background: hsl(var(--booking-info-hue), 60%, 85%);
1042
}
1043
1044
.booking-marker-dot--trail {
1045
    background: var(--booking-warning-bg);
1046
}
1047
1048
.booking-marker-dot--holiday {
1049
    background: var(--booking-holiday-bg);
1050
}
1051
1052
/* Calendar Day States */
1053
.booked {
1054
    background: var(--booking-warning-bg) !important;
1055
    color: hsl(var(--booking-warning-hue), 80%, 25%) !important;
1056
    border-radius: var(--booking-border-radius-full) !important;
1057
}
1058
1059
.checked-out {
1060
    background: hsl(var(--booking-danger-hue), 60%, 85%) !important;
1061
    color: hsl(var(--booking-danger-hue), 80%, 25%) !important;
1062
    border-radius: var(--booking-border-radius-full) !important;
1063
}
1064
1065
/* Holiday/Closed day state */
1066
.holiday {
1067
    background: var(--booking-holiday-bg) !important;
1068
    color: var(--booking-holiday-text) !important;
1069
    border-radius: var(--booking-border-radius-full) !important;
1070
}
1071
1072
/* Hover States with Transparency */
1073
.flatpickr-day.booking-day--hover-lead {
1074
    background-color: hsl(var(--booking-info-hue), 60%, 85%, 0.2) !important;
1075
}
1076
1077
.flatpickr-day.booking-day--hover-trail {
1078
    background-color: hsl(
1079
        var(--booking-warning-hue),
1080
        100%,
1081
        70%,
1082
        0.2
1083
    ) !important;
1084
}
1085
1086
/* Hover feedback status bar (inside flatpickr calendarContainer) */
1087
.booking-hover-feedback {
1088
    padding: 0 0.75rem;
1089
    max-height: 0;
1090
    min-height: 0;
1091
    opacity: 0;
1092
    overflow: hidden;
1093
    margin-top: 0;
1094
    margin-bottom: 0;
1095
    border-radius: 0 0 var(--booking-border-radius-sm) var(--booking-border-radius-sm);
1096
    font-size: var(--booking-text-sm);
1097
    text-align: center;
1098
    transition: max-height 100ms ease, opacity 100ms ease, padding 100ms ease,
1099
        margin-top 100ms ease, background-color 100ms ease, color 100ms ease;
1100
}
1101
1102
.booking-hover-feedback--visible {
1103
    padding: 0.5rem 0.75rem;
1104
    margin-top: 0.5rem;
1105
    min-height: 1.25rem;
1106
    max-height: 10em;
1107
    opacity: 1;
1108
}
1109
1110
.booking-hover-feedback--info {
1111
    color: hsl(var(--booking-info-hue), 80%, 20%);
1112
    background-color: hsl(var(--booking-info-hue), 40%, 93%);
1113
}
1114
1115
.booking-hover-feedback--danger {
1116
    color: hsl(var(--booking-danger-hue), 80%, 20%);
1117
    background-color: hsl(var(--booking-danger-hue), 40%, 93%);
1118
}
1119
1120
.booking-hover-feedback--warning {
1121
    color: hsl(var(--booking-warning-hue), 80%, 20%);
1122
    background-color: hsl(var(--booking-warning-hue), 100%, 93%);
1123
}
1124
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPatronStep.vue (+44 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="$__('Patron')"
10
            :placeholder="$__('Search for a patron')"
11
        >
12
            <template #no-options="{ hasSearched }">
13
                {{
14
                    hasSearched
15
                        ? $__("No patrons found.")
16
                        : $__("Type to search for patrons.")
17
                }}
18
            </template>
19
            <template #spinner>
20
                <span class="sr-only">{{ $__("Searching...") }}</span>
21
            </template>
22
        </PatronSearchSelect>
23
    </fieldset>
24
</template>
25
26
<script setup lang="ts">
27
import { computed } from "vue";
28
import PatronSearchSelect from "./PatronSearchSelect.vue";
29
import type { PatronOption } from "./types/bookings";
30
31
const props = defineProps<{
32
    stepNumber: number;
33
    modelValue: PatronOption | null;
34
}>();
35
36
const emit = defineEmits<{
37
    (e: "update:modelValue", value: PatronOption | null): void;
38
}>();
39
40
const selectedPatron = computed({
41
    get: () => props.modelValue,
42
    set: (value: PatronOption | null) => emit("update:modelValue", value),
43
});
44
</script>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingPeriodStep.vue (+352 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="$__('Clear selected dates')"
25
                        @click="clearDateRange"
26
                    >
27
                        <i class="fa fa-times" aria-hidden="true"></i>
28
                        <span class="sr-only">{{
29
                            $__("Clear selected dates")
30
                        }}</span>
31
                    </button>
32
                </div>
33
            </div>
34
        </div>
35
36
        <KohaAlert
37
            v-if="
38
                dateRangeConstraint &&
39
                (maxBookingPeriod === null || maxBookingPeriod > 0)
40
            "
41
            variant="info"
42
            extra-class="booking-constraint-info"
43
        >
44
            <small>
45
                <strong>{{ $__("Booking constraint active:") }}</strong>
46
                {{ constraintHelpText }}
47
            </small>
48
        </KohaAlert>
49
50
        <div class="calendar-legend">
51
            <span class="booking-marker-dot booking-marker-dot--booked"></span>
52
            {{ $__("Booked") }}
53
            <span
54
                class="booking-marker-dot booking-marker-dot--lead ml-3"
55
            ></span>
56
            {{ $__("Lead Period") }}
57
            <span
58
                class="booking-marker-dot booking-marker-dot--trail ml-3"
59
            ></span>
60
            {{ $__("Trail Period") }}
61
            <span
62
                class="booking-marker-dot booking-marker-dot--checked-out ml-3"
63
            ></span>
64
            {{ $__("Checked Out") }}
65
            <span
66
                class="booking-marker-dot booking-marker-dot--holiday ml-3"
67
            ></span>
68
            {{ $__("Closed") }}
69
            <span
70
                v-if="dateRangeConstraint && hasSelectedDates"
71
                class="booking-marker-dot ml-3"
72
                style="background-color: #28a745"
73
            ></span>
74
            <span v-if="dateRangeConstraint && hasSelectedDates" class="ml-1">
75
                {{ $__("Required end date") }}
76
            </span>
77
        </div>
78
79
        <div v-if="errorMessage" class="alert alert-danger mt-2">
80
            {{ errorMessage }}
81
        </div>
82
    </fieldset>
83
    <BookingTooltip
84
        :markers="tooltip.markers"
85
        :x="tooltip.x"
86
        :y="tooltip.y"
87
        :visible="tooltip.visible"
88
    />
89
</template>
90
91
<script setup lang="ts">
92
import { computed, reactive, ref, toRef, watch } from "vue";
93
import KohaAlert from "../KohaAlert.vue";
94
import { useFlatpickr } from "./composables/useFlatpickr.mjs";
95
import { useBookingStore } from "../../stores/bookings";
96
import { storeToRefs } from "pinia";
97
import { useAvailability } from "./composables/useAvailability.mjs";
98
import BookingTooltip from "./BookingTooltip.vue";
99
import { $__ } from "../../i18n";
100
import { debounce } from "../../utils/functions.mjs";
101
import { HOLIDAY_EXTENSION_DEBOUNCE_MS } from "./lib/booking/constants.mjs";
102
import { managerLogger } from "./lib/booking/logger.mjs";
103
import type { ConstraintOptions, CalendarMarker } from "./types/bookings";
104
105
interface TooltipState {
106
    markers: CalendarMarker[];
107
    visible: boolean;
108
    x: number;
109
    y: number;
110
}
111
112
interface VisibleRange {
113
    visibleStartDate: Date | null;
114
    visibleEndDate: Date | null;
115
}
116
117
const props = withDefaults(
118
    defineProps<{
119
        stepNumber: number;
120
        calendarEnabled?: boolean;
121
        constraintOptions: ConstraintOptions;
122
        dateRangeConstraint?: string | null;
123
        maxBookingPeriod?: number | null;
124
        errorMessage?: string;
125
        hasSelectedDates?: boolean;
126
    }>(),
127
    {
128
        calendarEnabled: true,
129
        dateRangeConstraint: null,
130
        maxBookingPeriod: null,
131
        errorMessage: "",
132
        hasSelectedDates: false,
133
    }
134
);
135
136
const emit = defineEmits<{
137
    (e: "clear-dates"): void;
138
}>();
139
140
const store = useBookingStore();
141
const {
142
    bookings,
143
    checkouts,
144
    bookableItems,
145
    bookingItemId,
146
    bookingId,
147
    selectedDateRange,
148
    circulationRules,
149
    holidays,
150
    pickupLibraryId,
151
} = storeToRefs(store);
152
153
const inputEl = ref<HTMLInputElement | null>(null);
154
155
const constraintHelpText = computed((): string => {
156
    if (!props.dateRangeConstraint) return "";
157
158
    const baseMessages: Record<string, string> = {
159
        issuelength: props.maxBookingPeriod
160
            ? $__("Booking period limited to checkout length (%s days)").format(
161
                  props.maxBookingPeriod
162
              )
163
            : $__("Booking period limited to checkout length"),
164
        issuelength_with_renewals: props.maxBookingPeriod
165
            ? $__(
166
                  "Booking period limited to checkout length with renewals (%s days)"
167
              ).format(props.maxBookingPeriod)
168
            : $__("Booking period limited to checkout length with renewals"),
169
        default: props.maxBookingPeriod
170
            ? $__("Booking period limited by circulation rules (%s days)").format(
171
                  props.maxBookingPeriod
172
              )
173
            : $__("Booking period limited by circulation rules"),
174
    };
175
176
    return baseMessages[props.dateRangeConstraint] || baseMessages.default;
177
});
178
179
const visibleRangeRef = ref<VisibleRange>({
180
    visibleStartDate: null,
181
    visibleEndDate: null,
182
});
183
184
const availabilityOptionsRef = computed(() => ({
185
    ...(props.constraintOptions || {}),
186
    ...(visibleRangeRef.value || {}),
187
}));
188
189
const { availability, disableFnRef } = useAvailability(
190
    {
191
        bookings,
192
        checkouts,
193
        bookableItems,
194
        bookingItemId,
195
        bookingId,
196
        selectedDateRange,
197
        circulationRules,
198
        holidays,
199
    },
200
    availabilityOptionsRef
201
);
202
203
const tooltip = reactive<TooltipState>({
204
    markers: [],
205
    visible: false,
206
    x: 0,
207
    y: 0,
208
});
209
210
const setErrorForFlatpickr = (msg: string): void => {
211
    store.setUiError(msg);
212
};
213
214
const { clear } = useFlatpickr(inputEl as { value: HTMLInputElement | null }, {
215
    store,
216
    disableFnRef,
217
    constraintOptionsRef: toRef(props, "constraintOptions"),
218
    setError: setErrorForFlatpickr,
219
    tooltip,
220
    visibleRangeRef,
221
});
222
223
watch(
224
    () => availability.value?.unavailableByDate,
225
    (map) => {
226
        try {
227
            store.setUnavailableByDate(map ?? {});
228
        } catch (e) {
229
            managerLogger.warn(
230
                "BookingPeriodStep",
231
                "Failed to sync unavailableByDate to store",
232
                e
233
            );
234
        }
235
    },
236
    { immediate: true }
237
);
238
239
const debouncedExtendHolidays = debounce(
240
    (libraryId: string, visibleStart: Date, visibleEnd: Date) => {
241
        store.extendHolidaysIfNeeded(libraryId, visibleStart, visibleEnd);
242
    },
243
    HOLIDAY_EXTENSION_DEBOUNCE_MS
244
);
245
246
watch(
247
    () => visibleRangeRef.value,
248
    (newRange) => {
249
        const libraryId = pickupLibraryId.value;
250
        if (
251
            !libraryId ||
252
            !newRange?.visibleStartDate ||
253
            !newRange?.visibleEndDate
254
        ) {
255
            return;
256
        }
257
        debouncedExtendHolidays(
258
            libraryId,
259
            newRange.visibleStartDate,
260
            newRange.visibleEndDate
261
        );
262
    },
263
    { deep: true }
264
);
265
266
const clearDateRange = (): void => {
267
    clear();
268
    emit("clear-dates");
269
};
270
</script>
271
272
<style scoped>
273
.form-group {
274
    margin-bottom: var(--booking-space-lg);
275
}
276
277
.booking-date-picker {
278
    display: flex;
279
    align-items: center;
280
}
281
282
.booking-flatpickr-input {
283
    flex: 1;
284
    margin-right: var(--booking-space-md);
285
}
286
287
.booking-date-picker-append {
288
    flex-shrink: 0;
289
}
290
291
.booking-constraint-info {
292
    margin-top: var(--booking-space-md);
293
    margin-bottom: var(--booking-space-lg);
294
}
295
296
.calendar-legend {
297
    display: flex;
298
    flex-wrap: wrap;
299
    align-items: center;
300
    gap: var(--booking-space-md);
301
    font-size: var(--booking-text-sm);
302
    margin-top: var(--booking-space-lg);
303
}
304
305
.booking-marker-dot {
306
    display: inline-block;
307
    width: calc(var(--booking-marker-size) * 3);
308
    height: calc(var(--booking-marker-size) * 3);
309
    border-radius: var(--booking-border-radius-full);
310
    margin-right: var(--booking-space-sm);
311
    border: var(--booking-border-width) solid hsla(0, 0%, 0%, 0.15);
312
}
313
314
.booking-marker-dot--booked {
315
    background-color: var(--booking-warning-bg);
316
}
317
318
.booking-marker-dot--lead {
319
    background-color: hsl(var(--booking-info-hue), 60%, 85%);
320
}
321
322
.booking-marker-dot--trail {
323
    background-color: var(--booking-warning-bg);
324
}
325
326
.booking-marker-dot--checked-out {
327
    background-color: hsl(var(--booking-danger-hue), 60%, 85%);
328
}
329
330
.booking-marker-dot--holiday {
331
    background-color: var(--booking-holiday-bg);
332
}
333
334
.alert {
335
    padding: calc(var(--booking-space-lg) * 0.75) var(--booking-space-lg);
336
    border: var(--booking-border-width) solid transparent;
337
    border-radius: var(--booking-border-radius-sm);
338
}
339
340
.alert-info {
341
    color: hsl(var(--booking-info-hue), 80%, 20%);
342
    background-color: hsl(var(--booking-info-hue), 40%, 90%);
343
    border-color: hsl(var(--booking-info-hue), 40%, 70%);
344
}
345
346
.alert-danger {
347
    color: hsl(var(--booking-danger-hue), 80%, 20%);
348
    background-color: hsl(var(--booking-danger-hue), 40%, 90%);
349
    border-color: hsl(var(--booking-danger-hue), 40%, 70%);
350
}
351
352
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/BookingTooltip.vue (+95 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
                transform: 'translateY(-50%)',
13
            }"
14
            role="tooltip"
15
        >
16
            <div
17
                v-for="marker in markers"
18
                :key="marker.type + ':' + (marker.barcode || marker.item)"
19
            >
20
                <span
21
                    :class="[
22
                        'booking-marker-dot',
23
                        `booking-marker-dot--${marker.type}`,
24
                    ]"
25
                />
26
                {{ getMarkerTypeLabel(marker.type) }} ({{ $__("Barcode") }}:
27
                {{ marker.barcode || marker.item || "N/A" }})
28
            </div>
29
        </div>
30
    </Teleport>
31
</template>
32
33
<script setup lang="ts">
34
import { defineProps, withDefaults } from "vue";
35
import { $__ } from "../../i18n";
36
import { getMarkerTypeLabel } from "./lib/ui/marker-labels.mjs";
37
import type { CalendarMarker } from "./types/bookings";
38
39
withDefaults(
40
    defineProps<{
41
        markers: CalendarMarker[];
42
        x: number;
43
        y: number;
44
        visible: boolean;
45
    }>(),
46
    {
47
        markers: () => [],
48
        x: 0,
49
        y: 0,
50
        visible: false,
51
    }
52
);
53
</script>
54
55
<style scoped>
56
.booking-tooltip {
57
    background: hsl(var(--booking-warning-hue), 100%, 95%);
58
    color: hsl(var(--booking-neutral-hue), 20%, 20%);
59
    border: var(--booking-border-width) solid hsl(var(--booking-neutral-hue), 15%, 75%);
60
    border-radius: var(--booking-border-radius-md);
61
    box-shadow: 0 0.125rem 0.5rem hsla(var(--booking-neutral-hue), 10%, 0%, 0.08);
62
    padding: calc(var(--booking-space-xs) * 3) calc(var(--booking-space-xs) * 5);
63
    font-size: var(--booking-text-lg);
64
    pointer-events: none;
65
}
66
67
.booking-marker-dot {
68
    display: inline-block;
69
    width: calc(var(--booking-marker-size) * 1.25);
70
    height: calc(var(--booking-marker-size) * 1.25);
71
    border-radius: var(--booking-border-radius-full);
72
    margin: 0 var(--booking-space-xs) 0 0;
73
    vertical-align: middle;
74
}
75
76
.booking-marker-dot--booked {
77
    background: var(--booking-warning-bg);
78
}
79
80
.booking-marker-dot--checked-out {
81
    background: hsl(var(--booking-danger-hue), 60%, 85%);
82
}
83
84
.booking-marker-dot--lead {
85
    background: hsl(var(--booking-info-hue), 60%, 85%);
86
}
87
88
.booking-marker-dot--trail {
89
    background: var(--booking-warning-bg);
90
}
91
92
.booking-marker-dot--holiday {
93
    background: var(--booking-holiday-bg);
94
}
95
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/PatronSearchSelect.vue (+135 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.patrons"
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 #option="option">
18
                <span>{{ option.label }}</span>
19
                <small
20
                    v-if="option._age != null || option._libraryName"
21
                    class="patron-option-meta"
22
                >
23
                    <span v-if="option._age != null" class="age_years">
24
                        {{ option._age }} {{ $__("years") }}
25
                    </span>
26
                    <span v-if="option._libraryName" class="ac-library">
27
                        {{ option._libraryName }}
28
                    </span>
29
                </small>
30
            </template>
31
            <template #no-options>
32
                <slot name="no-options" :has-searched="hasSearched"
33
                    >Sorry, no matching options.</slot
34
                >
35
            </template>
36
            <template #spinner>
37
                <slot name="spinner">Loading...</slot>
38
            </template>
39
        </v-select>
40
    </div>
41
</template>
42
43
<script setup lang="ts">
44
import { computed, ref } from "vue";
45
import vSelect from "vue-select";
46
import "vue-select/dist/vue-select.css";
47
import { processApiError } from "../../utils/apiErrors.js";
48
import { useBookingStore } from "../../stores/bookings";
49
import { storeToRefs } from "pinia";
50
import { debounce } from "./lib/adapters/external-dependents.mjs";
51
import { PATRON_SEARCH_DEBOUNCE_MS } from "./lib/booking/constants.mjs";
52
import { managerLogger } from "./lib/booking/logger.mjs";
53
import { $__ } from "../../i18n";
54
import type { PatronOption } from "./types/bookings";
55
56
const props = withDefaults(
57
    defineProps<{
58
        modelValue: PatronOption | null;
59
        label: string;
60
        placeholder?: string;
61
    }>(),
62
    {
63
        modelValue: null,
64
        placeholder: "",
65
    }
66
);
67
68
const emit = defineEmits<{
69
    (e: "update:modelValue", value: PatronOption | null): void;
70
}>();
71
72
const store = useBookingStore();
73
const { loading } = storeToRefs(store);
74
const patronOptions = ref<PatronOption[]>([]);
75
const hasSearched = ref(false);
76
77
const selectedPatron = computed({
78
    get: () => props.modelValue,
79
    set: (value: PatronOption | null) => emit("update:modelValue", value),
80
});
81
82
const onPatronSearch = async (search: string): Promise<void> => {
83
    if (!search || search.length < 3) {
84
        hasSearched.value = false;
85
        patronOptions.value = [];
86
        return;
87
    }
88
89
    hasSearched.value = true;
90
    try {
91
        const data = await store.fetchPatrons(search);
92
        patronOptions.value = data as PatronOption[];
93
    } catch (error) {
94
        const msg = processApiError(error);
95
        console.error("Error searching patrons:", msg);
96
        try {
97
            store.setUiError(msg, "api");
98
        } catch (e) {
99
            managerLogger.warn(
100
                "PatronSearchSelect",
101
                "Failed to set error in store",
102
                e
103
            );
104
        }
105
        patronOptions.value = [];
106
    }
107
};
108
109
const debouncedPatronSearch = debounce(
110
    onPatronSearch,
111
    PATRON_SEARCH_DEBOUNCE_MS
112
);
113
114
defineExpose({
115
    selectedPatron,
116
    patronOptions,
117
    loading: computed(() => loading.value.patrons),
118
    hasSearched,
119
    debouncedPatronSearch,
120
});
121
</script>
122
123
<style scoped>
124
.patron-option-meta {
125
    margin-left: var(--booking-space-md);
126
    opacity: 0.75;
127
}
128
129
.patron-option-meta .ac-library {
130
    margin-left: var(--booking-space-sm);
131
    padding: var(--booking-space-xs) var(--booking-space-md);
132
    border-radius: var(--booking-border-radius-sm);
133
    background-color: var(--booking-neutral-100);
134
}
135
</style>
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useAvailability.mjs (+95 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
import { isoArrayToDates } from "../lib/booking/BookingDate.mjs";
3
import {
4
    calculateDisabledDates,
5
    toEffectiveRules,
6
} from "../lib/booking/availability.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
 *  holidays: import('../types/bookings').RefLike<string[]>
24
 * }} storeRefs
25
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} optionsRef
26
 * @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> }}
27
 */
28
export function useAvailability(storeRefs, optionsRef) {
29
    const {
30
        bookings,
31
        checkouts,
32
        bookableItems,
33
        bookingItemId,
34
        bookingId,
35
        selectedDateRange,
36
        circulationRules,
37
        holidays,
38
    } = storeRefs;
39
40
    const inputsReady = computed(
41
        () =>
42
            Array.isArray(bookings.value) &&
43
            Array.isArray(checkouts.value) &&
44
            Array.isArray(bookableItems.value) &&
45
            (bookableItems.value?.length ?? 0) > 0
46
    );
47
48
    const availability = computed(() => {
49
        if (!inputsReady.value)
50
            return { disable: () => true, unavailableByDate: {} };
51
52
        const effectiveRules = toEffectiveRules(
53
            circulationRules.value,
54
            optionsRef.value || {}
55
        );
56
57
        const selectedDatesArray = isoArrayToDates(
58
            selectedDateRange.value || []
59
        );
60
61
        // Support on-demand unavailable map for current calendar view
62
        let calcOptions = {
63
            holidays: holidays?.value || [],
64
        };
65
        if (optionsRef && optionsRef.value) {
66
            const { visibleStartDate, visibleEndDate } = optionsRef.value;
67
            if (visibleStartDate && visibleEndDate) {
68
                calcOptions.onDemand = true;
69
                calcOptions.visibleStartDate = visibleStartDate;
70
                calcOptions.visibleEndDate = visibleEndDate;
71
            }
72
        }
73
74
        return calculateDisabledDates(
75
            bookings.value,
76
            checkouts.value,
77
            bookableItems.value,
78
            bookingItemId.value,
79
            bookingId.value,
80
            selectedDatesArray,
81
            effectiveRules,
82
            undefined,
83
            calcOptions
84
        );
85
    });
86
87
    const disableFnRef = computed(
88
        () => availability.value.disable || (() => false)
89
    );
90
    const unavailableByDateRef = computed(
91
        () => availability.value.unavailableByDate || {}
92
    );
93
94
    return { availability, disableFnRef, unavailableByDateRef };
95
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useBookingValidation.mjs (+80 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
} from "../lib/booking/validation.mjs";
12
import { handleBookingDateChange } from "../lib/booking/availability.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
    const {
21
        bookingPatron,
22
        pickupLibraryId,
23
        bookingItemtypeId,
24
        itemTypes,
25
        bookingItemId,
26
        bookableItems,
27
        selectedDateRange,
28
        bookings,
29
        checkouts,
30
        circulationRules,
31
        bookingId,
32
    } = storeToRefs(store);
33
34
    const canProceedToStep3Computed = computed(() => {
35
        return canProceedToStep3({
36
            showPatronSelect: store.showPatronSelect,
37
            bookingPatron: bookingPatron.value,
38
            showItemDetailsSelects: store.showItemDetailsSelects,
39
            showPickupLocationSelect: store.showPickupLocationSelect,
40
            pickupLibraryId: pickupLibraryId.value,
41
            bookingItemtypeId: bookingItemtypeId.value,
42
            itemtypeOptions: itemTypes.value,
43
            bookingItemId: bookingItemId.value,
44
            bookableItems: bookableItems.value,
45
        });
46
    });
47
48
    const canSubmitComputed = computed(() => {
49
        const validationData = {
50
            showPatronSelect: store.showPatronSelect,
51
            bookingPatron: bookingPatron.value,
52
            showItemDetailsSelects: store.showItemDetailsSelects,
53
            showPickupLocationSelect: store.showPickupLocationSelect,
54
            pickupLibraryId: pickupLibraryId.value,
55
            bookingItemtypeId: bookingItemtypeId.value,
56
            itemtypeOptions: itemTypes.value,
57
            bookingItemId: bookingItemId.value,
58
            bookableItems: bookableItems.value,
59
        };
60
        return canSubmitBooking(validationData, selectedDateRange.value);
61
    });
62
63
    const validateDates = selectedDates => {
64
        return handleBookingDateChange(
65
            selectedDates,
66
            circulationRules.value,
67
            bookings.value,
68
            checkouts.value,
69
            bookableItems.value,
70
            bookingItemId.value,
71
            bookingId.value
72
        );
73
    };
74
75
    return {
76
        canProceedToStep3: canProceedToStep3Computed,
77
        canSubmit: canSubmitComputed,
78
        validateDates,
79
    };
80
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useCapacityGuard.mjs (+152 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
3
const $__ = globalThis.$__ || (str => str);
4
5
/**
6
 * Centralized capacity guard for booking period availability.
7
 * Determines whether circulation rules yield a positive booking period,
8
 * derives a context-aware message, and drives a global warning state.
9
 *
10
 * @param {Object} options
11
 * @param {import('vue').Ref<Array<import('../types/bookings').CirculationRule>>} options.circulationRules
12
 * @param {import('vue').Ref<{patron_category_id: string|null, item_type_id: string|null, library_id: string|null}|null>} options.circulationRulesContext
13
 * @param {import('vue').Ref<{ bookings: boolean; checkouts: boolean; bookableItems: boolean; circulationRules: boolean }>} options.loading
14
 * @param {import('vue').Ref<Array<import('../types/bookings').BookableItem>>} options.bookableItems
15
 * @param {import('vue').Ref<import('../types/bookings').PatronLike|null>} options.bookingPatron
16
 * @param {import('vue').Ref<string|number|null>} options.bookingItemId
17
 * @param {import('vue').Ref<string|number|null>} options.bookingItemtypeId
18
 * @param {import('vue').Ref<string|null>} options.pickupLibraryId
19
 * @param {boolean} options.showPatronSelect
20
 * @param {boolean} options.showItemDetailsSelects
21
 * @param {boolean} options.showPickupLocationSelect
22
 * @param {string|null} options.dateRangeConstraint
23
 */
24
export function useCapacityGuard(options) {
25
    const {
26
        circulationRules,
27
        circulationRulesContext,
28
        loading,
29
        bookableItems,
30
        showPatronSelect,
31
        showItemDetailsSelects,
32
        showPickupLocationSelect,
33
        dateRangeConstraint,
34
    } = options;
35
36
    const hasPositiveCapacity = computed(() => {
37
        const rules = circulationRules.value?.[0] || {};
38
        const issuelength = Number(rules.issuelength) || 0;
39
        const renewalperiod = Number(rules.renewalperiod) || 0;
40
        const renewalsallowed = Number(rules.renewalsallowed) || 0;
41
        const withRenewals = issuelength + renewalperiod * renewalsallowed;
42
43
        const calculatedDays =
44
            rules.calculated_period_days != null
45
                ? Number(rules.calculated_period_days) || 0
46
                : null;
47
48
        if (dateRangeConstraint === "issuelength") return issuelength > 0;
49
        if (dateRangeConstraint === "issuelength_with_renewals")
50
            return withRenewals > 0;
51
52
        if (calculatedDays != null) return calculatedDays > 0;
53
        return issuelength > 0 || withRenewals > 0;
54
    });
55
56
    const zeroCapacityMessage = computed(() => {
57
        const rules = circulationRules.value?.[0] || {};
58
        const issuelength = rules.issuelength;
59
        const hasExplicitZero =
60
            issuelength != null && Number(issuelength) === 0;
61
        const hasNull = issuelength === null || issuelength === undefined;
62
63
        if (hasExplicitZero) {
64
            if (
65
                showPatronSelect &&
66
                showItemDetailsSelects &&
67
                showPickupLocationSelect
68
            ) {
69
                return $__(
70
                    "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."
71
                );
72
            }
73
            if (showItemDetailsSelects && showPickupLocationSelect) {
74
                return $__(
75
                    "Bookings are not permitted for this item type at the selected pickup location. The circulation rules set the booking period to zero days."
76
                );
77
            }
78
            if (showItemDetailsSelects) {
79
                return $__(
80
                    "Bookings are not permitted for this item type. The circulation rules set the booking period to zero days."
81
                );
82
            }
83
            return $__(
84
                "Bookings are not permitted for this item. The circulation rules set the booking period to zero days."
85
            );
86
        }
87
88
        if (hasNull) {
89
            const suggestions = [];
90
            if (showItemDetailsSelects) suggestions.push($__("item type"));
91
            if (showPickupLocationSelect)
92
                suggestions.push($__("pickup location"));
93
            if (showPatronSelect) suggestions.push($__("patron"));
94
95
            if (suggestions.length > 0) {
96
                const suggestionText = suggestions.join($__(" or "));
97
                return $__(
98
                    "No circulation rule is defined for this combination. Try a different %s."
99
                ).replace("%s", suggestionText);
100
            }
101
        }
102
103
        const both = showItemDetailsSelects && showPickupLocationSelect;
104
        if (both) {
105
            return $__(
106
                "No valid booking period is available with the current selection. Try a different item type or pickup location."
107
            );
108
        }
109
        if (showItemDetailsSelects) {
110
            return $__(
111
                "No valid booking period is available with the current selection. Try a different item type."
112
            );
113
        }
114
        if (showPickupLocationSelect) {
115
            return $__(
116
                "No valid booking period is available with the current selection. Try a different pickup location."
117
            );
118
        }
119
        return $__(
120
            "No valid booking period is available for this record with your current settings. Please try again later or contact your library."
121
        );
122
    });
123
124
    const showCapacityWarning = computed(() => {
125
        const dataReady =
126
            !loading.value?.bookings &&
127
            !loading.value?.checkouts &&
128
            !loading.value?.bookableItems;
129
        const hasItems = (bookableItems.value?.length ?? 0) > 0;
130
        const hasRules = (circulationRules.value?.length ?? 0) > 0;
131
132
        const context = circulationRulesContext.value;
133
        const hasCompleteContext =
134
            context &&
135
            context.patron_category_id != null &&
136
            context.item_type_id != null &&
137
            context.library_id != null;
138
139
        const rulesReady = !loading.value?.circulationRules;
140
141
        return (
142
            dataReady &&
143
            rulesReady &&
144
            hasItems &&
145
            hasRules &&
146
            hasCompleteContext &&
147
            !hasPositiveCapacity.value
148
        );
149
    });
150
151
    return { hasPositiveCapacity, zeroCapacityMessage, showCapacityWarning };
152
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useConstraintHighlighting.mjs (+93 lines)
Line 0 Link Here
1
import { computed } from "vue";
2
import { BookingDate } from "../lib/booking/BookingDate.mjs";
3
import {
4
    toEffectiveRules,
5
    findFirstBlockingDate,
6
} from "../lib/booking/availability.mjs";
7
import { calculateConstraintHighlighting } from "../lib/booking/highlighting.mjs";
8
import { subDays } from "../lib/booking/BookingDate.mjs";
9
10
/**
11
 * Provides reactive constraint highlighting data for the calendar based on
12
 * selected start date, circulation rules, and constraint options.
13
 *
14
 * This composable also clamps the highlighting range to respect actual
15
 * availability - if all items become unavailable before the theoretical
16
 * end date, the highlighting stops at the last available date.
17
 *
18
 * @param {import('../types/bookings').BookingStoreLike} store
19
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>|undefined} constraintOptionsRef
20
 * @returns {{
21
 *   highlightingData: import('vue').ComputedRef<null | import('../types/bookings').ConstraintHighlighting>
22
 * }}
23
 */
24
export function useConstraintHighlighting(store, constraintOptionsRef) {
25
    const highlightingData = computed(() => {
26
        const startISO = store.selectedDateRange?.[0];
27
        if (!startISO) return null;
28
        const opts = constraintOptionsRef?.value ?? {};
29
        const effectiveRules = toEffectiveRules(store.circulationRules, opts);
30
        const baseHighlighting = calculateConstraintHighlighting(
31
            BookingDate.from(startISO).toDate(),
32
            effectiveRules,
33
            opts
34
        );
35
        if (!baseHighlighting) return null;
36
37
        const holidays = store.holidays || [];
38
39
        // Check if there's a blocking date that should clamp the highlighting range
40
        const hasRequiredData =
41
            Array.isArray(store.bookings) &&
42
            Array.isArray(store.checkouts) &&
43
            Array.isArray(store.bookableItems) &&
44
            store.bookableItems.length > 0;
45
46
        if (!hasRequiredData) {
47
            return {
48
                ...baseHighlighting,
49
                holidays,
50
            };
51
        }
52
53
        const { firstBlockingDate } = findFirstBlockingDate(
54
            baseHighlighting.startDate,
55
            baseHighlighting.targetEndDate,
56
            store.bookings,
57
            store.checkouts,
58
            store.bookableItems,
59
            store.bookingItemId,
60
            store.bookingId,
61
            effectiveRules
62
        );
63
64
        // If a blocking date was found within the range, clamp targetEndDate
65
        if (firstBlockingDate) {
66
            const clampedEndDate = subDays(firstBlockingDate, 1).toDate();
67
68
            // Only clamp if it's actually earlier than the theoretical end
69
            if (clampedEndDate < baseHighlighting.targetEndDate) {
70
                // Filter blocked intermediate dates to only include those within the new range
71
                const clampedBlockedDates =
72
                    baseHighlighting.blockedIntermediateDates.filter(
73
                        date => date <= clampedEndDate
74
                    );
75
76
                return {
77
                    ...baseHighlighting,
78
                    targetEndDate: clampedEndDate,
79
                    blockedIntermediateDates: clampedBlockedDates,
80
                    holidays,
81
                    _clampedDueToAvailability: true,
82
                };
83
            }
84
        }
85
86
        return {
87
            ...baseHighlighting,
88
            holidays,
89
        };
90
    });
91
92
    return { highlightingData };
93
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFlatpickr.mjs (+307 lines)
Line 0 Link Here
1
import { onMounted, onUnmounted, watch } from "vue";
2
import flatpickr from "flatpickr";
3
import { isoArrayToDates } from "../lib/booking/BookingDate.mjs";
4
import { useBookingStore } from "../../../stores/bookings.js";
5
import {
6
    applyCalendarHighlighting,
7
    clearCalendarHighlighting,
8
} from "../lib/adapters/calendar/highlighting.mjs";
9
import {
10
    createOnDayCreate,
11
    createOnClose,
12
    createOnChange,
13
} from "../lib/adapters/calendar/events.mjs";
14
import { getVisibleCalendarDates } from "../lib/adapters/calendar/visibility.mjs";
15
import { buildMarkerGrid } from "../lib/adapters/calendar/markers.mjs";
16
import {
17
    getCurrentLanguageCode,
18
    preloadFlatpickrLocale,
19
} from "../lib/adapters/calendar/locale.mjs";
20
import {
21
    CLASS_FLATPICKR_DAY,
22
    CLASS_BOOKING_MARKER_GRID,
23
} from "../lib/booking/constants.mjs";
24
import {
25
    getBookingMarkersForDate,
26
    aggregateMarkersByType,
27
} from "../lib/booking/markers.mjs";
28
import { useConstraintHighlighting } from "./useConstraintHighlighting.mjs";
29
import { win } from "../lib/adapters/globals.mjs";
30
import { calendarLogger } from "../lib/booking/logger.mjs";
31
32
/**
33
 * Creates a ref-like accessor for tooltip properties.
34
 * Supports both consolidated tooltip object and legacy individual refs.
35
 * @param {Object} tooltip - Consolidated tooltip reactive object
36
 * @param {string} prop - Property name (markers, visible, x, y)
37
 * @param {Object} legacyRef - Legacy individual ref (fallback)
38
 * @param {*} defaultValue - Default value if neither is provided
39
 */
40
function createTooltipAccessor(tooltip, prop, legacyRef, defaultValue) {
41
    if (tooltip) {
42
        return {
43
            get value() {
44
                return tooltip[prop];
45
            },
46
            set value(v) {
47
                tooltip[prop] = v;
48
            },
49
        };
50
    }
51
    if (legacyRef) return legacyRef;
52
    return { value: defaultValue };
53
}
54
55
/**
56
 * Flatpickr integration for the bookings calendar.
57
 *
58
 * Date type policy:
59
 * - Store holds ISO strings in selectedDateRange (single source of truth)
60
 * - Flatpickr works with Date objects; we convert at the boundary
61
 * - API receives ISO strings
62
 *
63
 * @param {{ value: HTMLInputElement|null }} elRef - ref to the input element
64
 * @param {Object} options
65
 * @param {import('../types/bookings').BookingStoreLike} [options.store] - booking store (defaults to pinia store)
66
 * @param {import('../types/bookings').RefLike<import('../types/bookings').DisableFn>} options.disableFnRef - ref to disable fn
67
 * @param {import('../types/bookings').RefLike<import('../types/bookings').ConstraintOptions>} options.constraintOptionsRef
68
 * @param {(msg: string) => void} options.setError - set error message callback
69
 * @param {import('vue').Ref<{visibleStartDate?: Date|null, visibleEndDate?: Date|null}>} [options.visibleRangeRef]
70
 * @param {{markers: Array, visible: boolean, x: number, y: number}} [options.tooltip] - Consolidated tooltip state (preferred)
71
 * @param {import('../types/bookings').RefLike<import('../types/bookings').CalendarMarker[]>} [options.tooltipMarkersRef] - Legacy: individual markers ref
72
 * @param {import('../types/bookings').RefLike<boolean>} [options.tooltipVisibleRef] - Legacy: individual visible ref
73
 * @param {import('../types/bookings').RefLike<number>} [options.tooltipXRef] - Legacy: individual x ref
74
 * @param {import('../types/bookings').RefLike<number>} [options.tooltipYRef] - Legacy: individual y ref
75
 * @returns {{ clear: () => void, getInstance: () => import('../types/bookings').FlatpickrInstanceWithHighlighting | null }}
76
 */
77
export function useFlatpickr(elRef, options) {
78
    const store = options.store || useBookingStore();
79
80
    const disableFnRef = options.disableFnRef;
81
    const constraintOptionsRef = options.constraintOptionsRef;
82
    const setError = options.setError;
83
    const visibleRangeRef = options.visibleRangeRef;
84
85
    const tooltip = options.tooltip;
86
    const tooltipMarkersRef = createTooltipAccessor(
87
        tooltip,
88
        "markers",
89
        options.tooltipMarkersRef,
90
        []
91
    );
92
    const tooltipVisibleRef = createTooltipAccessor(
93
        tooltip,
94
        "visible",
95
        options.tooltipVisibleRef,
96
        false
97
    );
98
    const tooltipXRef = createTooltipAccessor(
99
        tooltip,
100
        "x",
101
        options.tooltipXRef,
102
        0
103
    );
104
    const tooltipYRef = createTooltipAccessor(
105
        tooltip,
106
        "y",
107
        options.tooltipYRef,
108
        0
109
    );
110
111
    let fp = null;
112
113
    /**
114
     * Creates a handler that updates visibleRangeRef when calendar view changes.
115
     * Used for onReady, onMonthChange, and onYearChange events.
116
     * @returns {import('flatpickr/dist/types/options').Hook}
117
     */
118
    function createVisibleRangeHandler() {
119
        return function (_selectedDates, _dateStr, instance) {
120
            if (!visibleRangeRef || !instance) return;
121
            try {
122
                const visible = getVisibleCalendarDates(instance);
123
                if (visible?.length > 0) {
124
                    visibleRangeRef.value = {
125
                        visibleStartDate: visible[0],
126
                        visibleEndDate: visible[visible.length - 1],
127
                    };
128
                }
129
            } catch (e) {
130
                calendarLogger.warn("useFlatpickr", "Failed to update visible range", e);
131
            }
132
        };
133
    }
134
135
    function toDateArrayFromStore() {
136
        return isoArrayToDates(store.selectedDateRange || []);
137
    }
138
139
    function setDisableOnInstance() {
140
        if (!fp) return;
141
        const disableFn = disableFnRef?.value;
142
        fp.set("disable", [
143
            typeof disableFn === "function" ? disableFn : () => false,
144
        ]);
145
    }
146
147
    function syncInstanceDatesFromStore() {
148
        if (!fp) return;
149
        try {
150
            const dates = toDateArrayFromStore();
151
            if (dates.length > 0) {
152
                fp.setDate(dates, false);
153
                if (dates[0] && fp.jumpToDate) fp.jumpToDate(dates[0]);
154
            } else {
155
                fp.clear();
156
            }
157
        } catch (e) {
158
            calendarLogger.warn("useFlatpickr", "Failed to sync dates from store", e);
159
        }
160
    }
161
162
    onMounted(async () => {
163
        if (!elRef?.value) return;
164
165
        await preloadFlatpickrLocale();
166
167
        const dateFormat =
168
            typeof win("flatpickr_dateformat_string") === "string"
169
                ? /** @type {string} */ (win("flatpickr_dateformat_string"))
170
                : "d.m.Y";
171
172
        const langCode = getCurrentLanguageCode();
173
        const locale =
174
            langCode !== "en"
175
                ? win("flatpickr")?.["l10ns"]?.[langCode]
176
                : undefined;
177
178
        /** @type {Partial<import('flatpickr/dist/types/options').Options>} */
179
        const baseConfig = {
180
            mode: "range",
181
            minDate: new Date().fp_incr(1),
182
            disable: [() => false],
183
            clickOpens: true,
184
            dateFormat,
185
            ...(locale && { locale }),
186
            allowInput: false,
187
            onChange: createOnChange(store, {
188
                setError,
189
                tooltipVisibleRef: tooltipVisibleRef || { value: false },
190
                constraintOptionsRef,
191
            }),
192
            onClose: createOnClose(
193
                tooltipMarkersRef || { value: [] },
194
                tooltipVisibleRef || { value: false }
195
            ),
196
            onDayCreate: createOnDayCreate(
197
                store,
198
                tooltipMarkersRef || { value: [] },
199
                tooltipVisibleRef || { value: false },
200
                tooltipXRef || { value: 0 },
201
                tooltipYRef || { value: 0 }
202
            ),
203
        };
204
205
        const updateVisibleRange = createVisibleRangeHandler();
206
207
        fp = flatpickr(elRef.value, {
208
            ...baseConfig,
209
            onReady: [updateVisibleRange],
210
            onMonthChange: [updateVisibleRange],
211
            onYearChange: [updateVisibleRange],
212
        });
213
214
        setDisableOnInstance();
215
        syncInstanceDatesFromStore();
216
    });
217
218
    if (disableFnRef) {
219
        watch(disableFnRef, () => {
220
            setDisableOnInstance();
221
        });
222
    }
223
224
    if (constraintOptionsRef) {
225
        const { highlightingData } = useConstraintHighlighting(
226
            store,
227
            constraintOptionsRef
228
        );
229
        watch(
230
            () => highlightingData.value,
231
            data => {
232
                if (!fp) return;
233
                if (!data) {
234
                    const instWithCache =
235
                        /** @type {import('../types/bookings').FlatpickrInstanceWithHighlighting} */ (
236
                            fp
237
                        );
238
                    instWithCache._constraintHighlighting = null;
239
                    clearCalendarHighlighting(fp);
240
                    return;
241
                }
242
                applyCalendarHighlighting(fp, data);
243
            }
244
        );
245
    }
246
247
    watch(
248
        () => store.unavailableByDate,
249
        () => {
250
            if (!fp || !fp.calendarContainer) return;
251
            try {
252
                const dayElements = fp.calendarContainer.querySelectorAll(
253
                    `.${CLASS_FLATPICKR_DAY}`
254
                );
255
                dayElements.forEach(dayElem => {
256
                    const existingGrids = dayElem.querySelectorAll(
257
                        `.${CLASS_BOOKING_MARKER_GRID}`
258
                    );
259
                    existingGrids.forEach(grid => grid.remove());
260
261
                    /** @type {import('flatpickr/dist/types/instance').DayElement} */
262
                    const el =
263
                        /** @type {import('flatpickr/dist/types/instance').DayElement} */ (
264
                            dayElem
265
                        );
266
                    if (!el.dateObj) return;
267
                    const markersForDots = getBookingMarkersForDate(
268
                        store.unavailableByDate,
269
                        el.dateObj,
270
                        store.bookableItems
271
                    );
272
                    if (markersForDots.length > 0) {
273
                        const aggregated =
274
                            aggregateMarkersByType(markersForDots);
275
                        const grid = buildMarkerGrid(aggregated);
276
                        if (grid.hasChildNodes()) dayElem.appendChild(grid);
277
                    }
278
                });
279
            } catch (e) {
280
                calendarLogger.warn("useFlatpickr", "Failed to update marker grids", e);
281
            }
282
        },
283
        { deep: true }
284
    );
285
286
    watch(
287
        () => store.selectedDateRange,
288
        () => {
289
            syncInstanceDatesFromStore();
290
        },
291
        { deep: true }
292
    );
293
294
    onUnmounted(() => {
295
        if (fp?.destroy) fp.destroy();
296
        fp = null;
297
    });
298
299
    return {
300
        clear() {
301
            if (fp?.clear) fp.clear();
302
        },
303
        getInstance() {
304
            return fp;
305
        },
306
    };
307
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useFormDefaults.mjs (+107 lines)
Line 0 Link Here
1
import { watch } from "vue";
2
import { idsEqual } from "../lib/booking/id-utils.mjs";
3
4
/**
5
 * Combined form defaults composable that handles auto-populating form fields.
6
 *
7
 * Responsibilities:
8
 * - Set default pickup library based on OPAC settings, patron, or first item
9
 * - Auto-derive item type from constrained types or selected item
10
 *
11
 * @param {Object} options
12
 * @param {import('vue').Ref<string|null>} options.bookingPickupLibraryId - Pickup library ref
13
 * @param {import('vue').Ref<Object|null>} options.bookingPatron - Selected patron ref
14
 * @param {import('vue').Ref<Array>} options.pickupLocations - Available pickup locations ref
15
 * @param {import('vue').Ref<Array>} options.bookableItems - Available bookable items ref
16
 * @param {import('vue').Ref<string|number|null>} options.bookingItemtypeId - Selected item type ref
17
 * @param {import('vue').Ref<string|number|null>} options.bookingItemId - Selected item ref
18
 * @param {import('vue').ComputedRef<Array>} options.constrainedItemTypes - Constrained item types computed
19
 * @param {boolean|string|null} [options.opacDefaultBookingLibraryEnabled] - OPAC default library setting
20
 * @param {string|null} [options.opacDefaultBookingLibrary] - OPAC default library value
21
 * @returns {{ stopDefaultPickup: import('vue').WatchStopHandle, stopDerivedItemType: import('vue').WatchStopHandle }}
22
 */
23
export function useFormDefaults(options) {
24
    const {
25
        bookingPickupLibraryId,
26
        bookingPatron,
27
        pickupLocations,
28
        bookableItems,
29
        bookingItemtypeId,
30
        bookingItemId,
31
        constrainedItemTypes,
32
        opacDefaultBookingLibraryEnabled = null,
33
        opacDefaultBookingLibrary = null,
34
    } = options;
35
36
    const stopDefaultPickup = watch(
37
        [() => bookingPatron.value, () => pickupLocations.value],
38
        ([patron, locations]) => {
39
            if (bookingPickupLibraryId.value) return;
40
            const list = Array.isArray(locations) ? locations : [];
41
42
            const enabled =
43
                opacDefaultBookingLibraryEnabled === true ||
44
                String(opacDefaultBookingLibraryEnabled) === "1";
45
            const def = opacDefaultBookingLibrary ?? "";
46
            if (enabled && def && list.some(l => idsEqual(l.library_id, def))) {
47
                bookingPickupLibraryId.value = def;
48
                return;
49
            }
50
51
            if (patron && list.length > 0) {
52
                const patronLib = patron.library_id;
53
                if (list.some(l => idsEqual(l.library_id, patronLib))) {
54
                    bookingPickupLibraryId.value = patronLib;
55
                    return;
56
                }
57
            }
58
59
            const items = Array.isArray(bookableItems.value)
60
                ? bookableItems.value
61
                : [];
62
            if (items.length > 0 && list.length > 0) {
63
                const homeLib = items[0]?.home_library_id;
64
                if (list.some(l => idsEqual(l.library_id, homeLib))) {
65
                    bookingPickupLibraryId.value = homeLib;
66
                }
67
            }
68
        },
69
        { immediate: true }
70
    );
71
72
    const stopDerivedItemType = watch(
73
        [
74
            constrainedItemTypes,
75
            () => bookingItemId.value,
76
            () => bookableItems.value,
77
        ],
78
        ([types, itemId, items]) => {
79
            if (
80
                !bookingItemtypeId.value &&
81
                Array.isArray(types) &&
82
                types.length === 1
83
            ) {
84
                bookingItemtypeId.value = types[0].item_type_id;
85
                return;
86
            }
87
88
            if (!bookingItemtypeId.value && itemId) {
89
                const item = (items || []).find(i =>
90
                    idsEqual(i.item_id, itemId)
91
                );
92
                if (item) {
93
                    bookingItemtypeId.value =
94
                        item.effective_item_type_id ||
95
                        item.item_type_id ||
96
                        null;
97
                }
98
            }
99
        },
100
        { immediate: true }
101
    );
102
103
    return {
104
        stopDefaultPickup,
105
        stopDerivedItemType,
106
    };
107
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/composables/useRulesFetcher.mjs (+105 lines)
Line 0 Link Here
1
import { watchEffect, ref, watch } from "vue";
2
import { formatYMD, addMonths } from "../lib/booking/BookingDate.mjs";
3
4
/**
5
 * Watch core selections and fetch pickup locations, circulation rules, and holidays.
6
 * De-duplicates rules fetches by building a stable key from inputs.
7
 *
8
 * @param {Object} options
9
 * @param {import('../types/bookings').StoreWithActions} options.store
10
 * @param {import('../types/bookings').RefLike<import('../types/bookings').PatronLike|null>} options.bookingPatron
11
 * @param {import('../types/bookings').RefLike<string|null>} options.bookingPickupLibraryId
12
 * @param {import('../types/bookings').RefLike<string|number|null>} options.bookingItemtypeId
13
 * @param {import('../types/bookings').RefLike<Array<import('../types/bookings').ItemType>>} options.constrainedItemTypes
14
 * @param {import('../types/bookings').RefLike<Array<string>>} options.selectedDateRange
15
 * @param {string|import('../types/bookings').RefLike<string>} options.biblionumber
16
 * @returns {{ lastRulesKey: import('vue').Ref<string|null> }}
17
 */
18
export function useRulesFetcher(options) {
19
    const {
20
        store,
21
        bookingPatron,
22
        bookingPickupLibraryId,
23
        bookingItemtypeId,
24
        constrainedItemTypes,
25
        selectedDateRange,
26
        biblionumber,
27
    } = options;
28
29
    const lastRulesKey = ref(null);
30
    const lastHolidaysLibrary = ref(null);
31
32
    watchEffect(
33
        () => {
34
            const patronId = bookingPatron.value?.patron_id;
35
            const biblio =
36
                typeof biblionumber === "object"
37
                    ? biblionumber.value
38
                    : biblionumber;
39
40
            if (patronId && biblio) {
41
                store.fetchPickupLocations(biblio, patronId);
42
            }
43
44
            const patron = bookingPatron.value;
45
            const derivedItemTypeId =
46
                bookingItemtypeId.value ??
47
                (Array.isArray(constrainedItemTypes.value) &&
48
                constrainedItemTypes.value.length === 1
49
                    ? constrainedItemTypes.value[0].item_type_id
50
                    : undefined);
51
52
            const rulesParams = {
53
                patron_category_id: patron?.category_id,
54
                item_type_id: derivedItemTypeId,
55
                library_id: bookingPickupLibraryId.value,
56
            };
57
            const key = buildRulesKey(rulesParams);
58
            if (lastRulesKey.value !== key) {
59
                lastRulesKey.value = key;
60
                store.invalidateCalculatedDue();
61
                store.fetchCirculationRules(rulesParams);
62
            }
63
        },
64
        { flush: "post" }
65
    );
66
67
    watch(
68
        () => bookingPickupLibraryId.value,
69
        libraryId => {
70
            if (libraryId === lastHolidaysLibrary.value) {
71
                return;
72
            }
73
            lastHolidaysLibrary.value = libraryId;
74
75
            const today = new Date();
76
            const oneYearLater = addMonths(today, 12);
77
            store.fetchHolidays(
78
                libraryId,
79
                formatYMD(today),
80
                formatYMD(oneYearLater)
81
            );
82
        },
83
        { immediate: true }
84
    );
85
86
    return { lastRulesKey };
87
}
88
89
/**
90
 * Stable, explicit, order-preserving key builder to avoid JSON quirks
91
 *
92
 * @param {import('../types/bookings').RulesParams} params
93
 * @returns {string}
94
 * @exported for testability
95
 */
96
export function buildRulesKey(params) {
97
    return [
98
        ["pc", params.patron_category_id],
99
        ["it", params.item_type_id],
100
        ["lib", params.library_id],
101
    ]
102
        .filter(([, v]) => v ?? v === 0)
103
        .map(([k, v]) => `${k}=${String(v)}`)
104
        .join("|");
105
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/opac.js (+272 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
 * ## Stub Functions
7
 *
8
 * Some functions are stubs that exist only for API compatibility with the
9
 * staff interface booking module:
10
 *
11
 * - `fetchPatrons()` - Returns empty array. Patron search is not needed in OPAC
12
 *   because the logged-in patron is automatically used.
13
 *
14
 * These stubs allow the booking components to use the same store actions
15
 * regardless of whether they're running in staff or OPAC context.
16
 *
17
 * ## Relationship with staff-interface.js
18
 *
19
 * This module mirrors the API of staff-interface.js but uses public API endpoints.
20
 * The two files share ~60% similar code. If modifying one, check if the same
21
 * change is needed in the other.
22
 */
23
24
import { bookingValidation } from "../../booking/validation-messages.js";
25
26
/**
27
 * Fetches bookable items for a given biblionumber
28
 * @param {number|string} biblionumber - The biblionumber to fetch items for
29
 * @returns {Promise<Array<Object>>} Array of bookable items
30
 * @throws {Error} If the request fails or returns a non-OK status
31
 */
32
export async function fetchBookableItems(biblionumber) {
33
    if (!biblionumber) {
34
        throw bookingValidation.validationError("biblionumber_required");
35
    }
36
37
    const response = await fetch(
38
        `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/items`,
39
        {
40
            headers: {
41
                "x-koha-embed": "+strings",
42
            },
43
        }
44
    );
45
46
    if (!response.ok) {
47
        throw bookingValidation.validationError("fetch_bookable_items_failed", {
48
            status: response.status,
49
            statusText: response.statusText,
50
        });
51
    }
52
53
    return await response.json();
54
}
55
56
/**
57
 * Fetches bookings for a given biblionumber
58
 * @param {number|string} biblionumber - The biblionumber to fetch bookings for
59
 * @returns {Promise<Array<Object>>} Array of bookings
60
 * @throws {Error} If the request fails or returns a non-OK status
61
 */
62
export async function fetchBookings(biblionumber) {
63
    if (!biblionumber) {
64
        throw bookingValidation.validationError("biblionumber_required");
65
    }
66
67
    const response = await fetch(
68
        `/api/v1/public/biblios/${encodeURIComponent(
69
            biblionumber
70
        )}/bookings?_per_page=-1&q={"status":{"-in":["new","pending","active"]}}`
71
    );
72
73
    if (!response.ok) {
74
        throw bookingValidation.validationError("fetch_bookings_failed", {
75
            status: response.status,
76
            statusText: response.statusText,
77
        });
78
    }
79
80
    return await response.json();
81
}
82
83
/**
84
 * Fetches checkouts for a given biblionumber
85
 * @param {number|string} biblionumber - The biblionumber to fetch checkouts for
86
 * @returns {Promise<Array<Object>>} Array of checkouts
87
 * @throws {Error} If the request fails or returns a non-OK status
88
 */
89
export async function fetchCheckouts(biblionumber) {
90
    if (!biblionumber) {
91
        throw bookingValidation.validationError("biblionumber_required");
92
    }
93
94
    const response = await fetch(
95
        `/api/v1/public/biblios/${encodeURIComponent(biblionumber)}/checkouts`
96
    );
97
98
    if (!response.ok) {
99
        throw bookingValidation.validationError("fetch_checkouts_failed", {
100
            status: response.status,
101
            statusText: response.statusText,
102
        });
103
    }
104
105
    return await response.json();
106
}
107
108
/**
109
 * Fetches a single patron by ID
110
 * @param {number|string} patronId - The ID of the patron to fetch
111
 * @returns {Promise<Object>} The patron object
112
 * @throws {Error} If the request fails or returns a non-OK status
113
 */
114
export async function fetchPatron(patronId) {
115
    const response = await fetch(`/api/v1/public/patrons/${patronId}`, {
116
        headers: { "x-koha-embed": "library" },
117
    });
118
119
    if (!response.ok) {
120
        throw bookingValidation.validationError("fetch_patron_failed", {
121
            status: response.status,
122
            statusText: response.statusText,
123
        });
124
    }
125
126
    return await response.json();
127
}
128
129
/**
130
 * Searches for patrons - not used in OPAC
131
 * @returns {Promise<Array>}
132
 */
133
export async function fetchPatrons() {
134
    return [];
135
}
136
137
/**
138
 * Fetches pickup locations for a biblionumber
139
 * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for
140
 * @returns {Promise<Array<Object>>} Array of pickup location objects
141
 * @throws {Error} If the request fails or returns a non-OK status
142
 */
143
export async function fetchPickupLocations(biblionumber, patronId) {
144
    if (!biblionumber) {
145
        throw bookingValidation.validationError("biblionumber_required");
146
    }
147
148
    const params = new URLSearchParams({
149
        _order_by: "name",
150
        _per_page: "-1",
151
    });
152
153
    if (patronId) {
154
        params.append("patron_id", patronId);
155
    }
156
157
    const response = await fetch(
158
        `/api/v1/public/biblios/${encodeURIComponent(
159
            biblionumber
160
        )}/pickup_locations?${params.toString()}`
161
    );
162
163
    if (!response.ok) {
164
        throw bookingValidation.validationError(
165
            "fetch_pickup_locations_failed",
166
            {
167
                status: response.status,
168
                statusText: response.statusText,
169
            }
170
        );
171
    }
172
173
    return await response.json();
174
}
175
176
/**
177
 * Fetches circulation rules for booking constraints
178
 * Now uses the enhanced circulation_rules endpoint with date calculation capabilities
179
 * @param {Object} params - Parameters for circulation rules query
180
 * @param {string|number} [params.patron_category_id] - Patron category ID
181
 * @param {string|number} [params.item_type_id] - Item type ID
182
 * @param {string|number} [params.library_id] - Library ID
183
 * @param {string} [params.start_date] - Start date for calculations (ISO format)
184
 * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules)
185
 * @param {boolean} [params.calculate_dates] - Whether to calculate dates (defaults to true for bookings)
186
 * @returns {Promise<Object>} Object containing circulation rules with calculated dates
187
 * @throws {Error} If the request fails or returns a non-OK status
188
 */
189
export async function fetchCirculationRules(params = {}) {
190
    const filteredParams = {};
191
    for (const key in params) {
192
        if (
193
            params[key] !== null &&
194
            params[key] !== undefined &&
195
            params[key] !== ""
196
        ) {
197
            filteredParams[key] = params[key];
198
        }
199
    }
200
201
    if (filteredParams.calculate_dates === undefined) {
202
        filteredParams.calculate_dates = true;
203
    }
204
205
    if (!filteredParams.rules) {
206
        filteredParams.rules =
207
            "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod";
208
    }
209
210
    const urlParams = new URLSearchParams();
211
    Object.entries(filteredParams).forEach(([k, v]) => {
212
        if (v === undefined || v === null) return;
213
        urlParams.set(k, String(v));
214
    });
215
216
    const response = await fetch(
217
        `/api/v1/public/circulation_rules?${urlParams.toString()}`
218
    );
219
220
    if (!response.ok) {
221
        throw bookingValidation.validationError(
222
            "fetch_circulation_rules_failed",
223
            {
224
                status: response.status,
225
                statusText: response.statusText,
226
            }
227
        );
228
    }
229
230
    return await response.json();
231
}
232
233
/**
234
 * Fetches holidays (closed days) for a library
235
 * @param {string} libraryId - The library branchcode
236
 * @param {string} [from] - Start date (ISO format), defaults to today
237
 * @param {string} [to] - End date (ISO format), defaults to 3 months from start
238
 * @returns {Promise<string[]>} Array of holiday dates in YYYY-MM-DD format
239
 * @throws {Error} If the request fails or returns a non-OK status
240
 */
241
export async function fetchHolidays(libraryId, from, to) {
242
    if (!libraryId) {
243
        return [];
244
    }
245
246
    const params = new URLSearchParams();
247
    if (from) params.set("from", from);
248
    if (to) params.set("to", to);
249
250
    const url = `/api/v1/public/libraries/${encodeURIComponent(
251
        libraryId
252
    )}/holidays${params.toString() ? `?${params.toString()}` : ""}`;
253
254
    const response = await fetch(url);
255
256
    if (!response.ok) {
257
        throw bookingValidation.validationError("fetch_holidays_failed", {
258
            status: response.status,
259
            statusText: response.statusText,
260
        });
261
    }
262
263
    return await response.json();
264
}
265
266
export async function createBooking() {
267
    return {};
268
}
269
270
export async function updateBooking() {
271
    return {};
272
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/api/staff-interface.js (+417 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
import { buildPatronSearchQuery } from "../patron.mjs";
9
10
/**
11
 * Fetches bookable items for a given biblionumber
12
 * @param {number|string} biblionumber - The biblionumber to fetch items for
13
 * @returns {Promise<Array<Object>>} Array of bookable items
14
 * @throws {Error} If the request fails or returns a non-OK status
15
 */
16
export async function fetchBookableItems(biblionumber) {
17
    if (!biblionumber) {
18
        throw bookingValidation.validationError("biblionumber_required");
19
    }
20
21
    const response = await fetch(
22
        `/api/v1/biblios/${encodeURIComponent(biblionumber)}/items?bookable=1`,
23
        {
24
            headers: {
25
                "x-koha-embed": "+strings,item_type",
26
            },
27
        }
28
    );
29
30
    if (!response.ok) {
31
        throw bookingValidation.validationError("fetch_bookable_items_failed", {
32
            status: response.status,
33
            statusText: response.statusText,
34
        });
35
    }
36
37
    return await response.json();
38
}
39
40
/**
41
 * Fetches bookings for a given biblionumber
42
 * @param {number|string} biblionumber - The biblionumber to fetch bookings for
43
 * @returns {Promise<Array<Object>>} Array of bookings
44
 * @throws {Error} If the request fails or returns a non-OK status
45
 */
46
export async function fetchBookings(biblionumber) {
47
    if (!biblionumber) {
48
        throw bookingValidation.validationError("biblionumber_required");
49
    }
50
51
    const response = await fetch(
52
        `/api/v1/biblios/${encodeURIComponent(
53
            biblionumber
54
        )}/bookings?_per_page=-1&q={"status":{"-in":["new","pending","active"]}}`
55
    );
56
57
    if (!response.ok) {
58
        throw bookingValidation.validationError("fetch_bookings_failed", {
59
            status: response.status,
60
            statusText: response.statusText,
61
        });
62
    }
63
64
    return await response.json();
65
}
66
67
/**
68
 * Fetches checkouts for a given biblionumber
69
 * @param {number|string} biblionumber - The biblionumber to fetch checkouts for
70
 * @returns {Promise<Array<Object>>} Array of checkouts
71
 * @throws {Error} If the request fails or returns a non-OK status
72
 */
73
export async function fetchCheckouts(biblionumber) {
74
    if (!biblionumber) {
75
        throw bookingValidation.validationError("biblionumber_required");
76
    }
77
78
    const response = await fetch(
79
        `/api/v1/biblios/${encodeURIComponent(biblionumber)}/checkouts`
80
    );
81
82
    if (!response.ok) {
83
        throw bookingValidation.validationError("fetch_checkouts_failed", {
84
            status: response.status,
85
            statusText: response.statusText,
86
        });
87
    }
88
89
    return await response.json();
90
}
91
92
/**
93
 * Fetches a single patron by ID
94
 * @param {number|string} patronId - The ID of the patron to fetch
95
 * @returns {Promise<Object>} The patron object
96
 * @throws {Error} If the request fails or returns a non-OK status
97
 */
98
export async function fetchPatron(patronId) {
99
    if (!patronId) {
100
        throw bookingValidation.validationError("patron_id_required");
101
    }
102
103
    const response = await fetch(
104
        `/api/v1/patrons/${encodeURIComponent(patronId)}`,
105
        {
106
            headers: { "x-koha-embed": "library" },
107
        }
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
/**
121
 * Searches for patrons matching a search term
122
 * @param {string} term - The search term to match against patron names, cardnumbers, etc.
123
 * @param {number} [page=1] - The page number for pagination
124
 * @returns {Promise<Object>} Object containing patron search results
125
 * @throws {Error} If the request fails or returns a non-OK status
126
 */
127
export async function fetchPatrons(term, page = 1) {
128
    if (!term) {
129
        return { results: [] };
130
    }
131
132
    const query = buildPatronSearchQuery(term, {
133
        search_type: "contains",
134
    });
135
136
    const params = new URLSearchParams({
137
        q: JSON.stringify(query),
138
        _page: String(page),
139
        _per_page: "10",
140
        _order_by: "surname,firstname",
141
    });
142
143
    const response = await fetch(`/api/v1/patrons?${params.toString()}`, {
144
        headers: {
145
            "x-koha-embed": "library",
146
            Accept: "application/json",
147
        },
148
    });
149
150
    if (!response.ok) {
151
        const error = bookingValidation.validationError(
152
            "fetch_patrons_failed",
153
            {
154
                status: response.status,
155
                statusText: response.statusText,
156
            }
157
        );
158
159
        try {
160
            const errorData = await response.json();
161
            if (errorData.error) {
162
                error.message += ` - ${errorData.error}`;
163
            }
164
        } catch (e) {}
165
166
        throw error;
167
    }
168
169
    return await response.json();
170
}
171
172
/**
173
 * Fetches pickup locations for a biblionumber, optionally filtered by patron
174
 * @param {number|string} biblionumber - The biblionumber to fetch pickup locations for
175
 * @param {number|string|null} [patronId] - Optional patron ID to filter pickup locations
176
 * @returns {Promise<Array<Object>>} Array of pickup location objects
177
 * @throws {Error} If the request fails or returns a non-OK status
178
 */
179
export async function fetchPickupLocations(biblionumber, patronId) {
180
    if (!biblionumber) {
181
        throw bookingValidation.validationError("biblionumber_required");
182
    }
183
184
    const params = new URLSearchParams({
185
        _order_by: "name",
186
        _per_page: "-1",
187
    });
188
189
    if (patronId) {
190
        params.append("patron_id", String(patronId));
191
    }
192
193
    const response = await fetch(
194
        `/api/v1/biblios/${encodeURIComponent(
195
            biblionumber
196
        )}/pickup_locations?${params.toString()}`
197
    );
198
199
    if (!response.ok) {
200
        throw bookingValidation.validationError(
201
            "fetch_pickup_locations_failed",
202
            {
203
                status: response.status,
204
                statusText: response.statusText,
205
            }
206
        );
207
    }
208
209
    return await response.json();
210
}
211
212
/**
213
 * Fetches circulation rules based on the provided context parameters
214
 * Now uses the enhanced circulation_rules endpoint with date calculation capabilities
215
 * @param {Object} [params={}] - Context parameters for circulation rules
216
 * @param {string|number} [params.patron_category_id] - Patron category ID
217
 * @param {string|number} [params.item_type_id] - Item type ID
218
 * @param {string|number} [params.library_id] - Library ID
219
 * @param {string} [params.start_date] - Start date for calculations (ISO format)
220
 * @param {string} [params.rules] - Comma-separated list of rule kinds (defaults to booking rules)
221
 * @param {boolean} [params.calculate_dates] - Whether to calculate dates (defaults to true for bookings)
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
    const filteredParams = {};
227
    for (const key in params) {
228
        if (
229
            params[key] !== null &&
230
            params[key] !== undefined &&
231
            params[key] !== ""
232
        ) {
233
            filteredParams[key] = params[key];
234
        }
235
    }
236
237
    if (!filteredParams.rules) {
238
        filteredParams.rules =
239
            "bookings_lead_period,bookings_trail_period,issuelength,renewalsallowed,renewalperiod";
240
    }
241
242
    const urlParams = new URLSearchParams();
243
    Object.entries(filteredParams).forEach(([k, v]) => {
244
        if (v === undefined || v === null) return;
245
        urlParams.set(k, String(v));
246
    });
247
248
    const response = await fetch(
249
        `/api/v1/circulation_rules?${urlParams.toString()}`
250
    );
251
252
    if (!response.ok) {
253
        throw bookingValidation.validationError(
254
            "fetch_circulation_rules_failed",
255
            {
256
                status: response.status,
257
                statusText: response.statusText,
258
            }
259
        );
260
    }
261
262
    return await response.json();
263
}
264
265
/**
266
 * Fetches holidays (closed days) for a library within a date range
267
 * @param {string} libraryId - The library ID (branchcode)
268
 * @param {string} [from] - Start date for the range (ISO format, e.g., 2024-01-01). Defaults to today.
269
 * @param {string} [to] - End date for the range (ISO format, e.g., 2024-03-31). Defaults to 3 months from 'from'.
270
 * @returns {Promise<string[]>} Array of holiday dates in YYYY-MM-DD format
271
 * @throws {Error} If the request fails or returns a non-OK status
272
 */
273
export async function fetchHolidays(libraryId, from, to) {
274
    if (!libraryId) {
275
        return [];
276
    }
277
278
    const params = new URLSearchParams();
279
    if (from) params.set("from", from);
280
    if (to) params.set("to", to);
281
282
    const url = `/api/v1/libraries/${encodeURIComponent(libraryId)}/holidays${
283
        params.toString() ? `?${params.toString()}` : ""
284
    }`;
285
286
    const response = await fetch(url);
287
288
    if (!response.ok) {
289
        throw bookingValidation.validationError("fetch_holidays_failed", {
290
            status: response.status,
291
            statusText: response.statusText,
292
        });
293
    }
294
295
    return await response.json();
296
}
297
298
/**
299
 * Creates a new booking
300
 * @param {Object} bookingData - The booking data to create
301
 * @param {string} bookingData.start_date - Start date of the booking (ISO 8601 format)
302
 * @param {string} bookingData.end_date - End date of the booking (ISO 8601 format)
303
 * @param {number|string} bookingData.biblio_id - Biblionumber for the booking
304
 * @param {number|string} [bookingData.item_id] - Optional item ID for the booking
305
 * @param {number|string} bookingData.patron_id - Patron ID for the booking
306
 * @param {number|string} bookingData.pickup_library_id - Pickup library ID
307
 * @returns {Promise<Object>} The created booking object
308
 * @throws {Error} If the request fails or returns a non-OK status
309
 */
310
export async function createBooking(bookingData) {
311
    if (!bookingData) {
312
        throw bookingValidation.validationError("booking_data_required");
313
    }
314
315
    const validationError = bookingValidation.validateRequiredFields(
316
        bookingData,
317
        [
318
            "start_date",
319
            "end_date",
320
            "biblio_id",
321
            "patron_id",
322
            "pickup_library_id",
323
        ]
324
    );
325
326
    if (validationError) {
327
        throw validationError;
328
    }
329
330
    const response = await fetch("/api/v1/bookings", {
331
        method: "POST",
332
        headers: {
333
            "Content-Type": "application/json",
334
            Accept: "application/json",
335
        },
336
        body: JSON.stringify(bookingData),
337
    });
338
339
    if (!response.ok) {
340
        let errorMessage = bookingValidation.validationError(
341
            "create_booking_failed",
342
            {
343
                status: response.status,
344
                statusText: response.statusText,
345
            }
346
        ).message;
347
        try {
348
            const errorData = await response.json();
349
            if (errorData.error) {
350
                errorMessage += ` - ${errorData.error}`;
351
            }
352
        } catch (e) {}
353
        /** @type {Error & { status?: number }} */
354
        const error = Object.assign(new Error(errorMessage), {
355
            status: response.status,
356
        });
357
        throw error;
358
    }
359
360
    return await response.json();
361
}
362
363
/**
364
 * Updates an existing booking
365
 * @param {number|string} bookingId - The ID of the booking to update
366
 * @param {Object} bookingData - The updated booking data
367
 * @param {string} [bookingData.start_date] - New start date (ISO 8601 format)
368
 * @param {string} [bookingData.end_date] - New end date (ISO 8601 format)
369
 * @param {number|string} [bookingData.pickup_library_id] - New pickup library ID
370
 * @param {number|string} [bookingData.item_id] - New item ID (if changing the item)
371
 * @returns {Promise<Object>} The updated booking object
372
 * @throws {Error} If the request fails or returns a non-OK status
373
 */
374
export async function updateBooking(bookingId, bookingData) {
375
    if (!bookingId) {
376
        throw bookingValidation.validationError("booking_id_required");
377
    }
378
379
    if (!bookingData || Object.keys(bookingData).length === 0) {
380
        throw bookingValidation.validationError("no_update_data");
381
    }
382
383
    const response = await fetch(
384
        `/api/v1/bookings/${encodeURIComponent(bookingId)}`,
385
        {
386
            method: "PUT",
387
            headers: {
388
                "Content-Type": "application/json",
389
                Accept: "application/json",
390
            },
391
            body: JSON.stringify({ ...bookingData, booking_id: bookingId }),
392
        }
393
    );
394
395
    if (!response.ok) {
396
        let errorMessage = bookingValidation.validationError(
397
            "update_booking_failed",
398
            {
399
                status: response.status,
400
                statusText: response.statusText,
401
            }
402
        ).message;
403
        try {
404
            const errorData = await response.json();
405
            if (errorData.error) {
406
                errorMessage += ` - ${errorData.error}`;
407
            }
408
        } catch (e) {}
409
        /** @type {Error & { status?: number }} */
410
        const error = Object.assign(new Error(errorMessage), {
411
            status: response.status,
412
        });
413
        throw error;
414
    }
415
416
    return await response.json();
417
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/events.mjs (+608 lines)
Line 0 Link Here
1
/**
2
 * Flatpickr event handler factories.
3
 * @module calendar/events
4
 *
5
 * ## Instance Cache Mutation Pattern
6
 *
7
 * This module mutates the Flatpickr instance directly by attaching custom
8
 * properties to persist state across event callbacks:
9
 *
10
 * - `instance._loanBoundaryTimes` - Map of timestamps marking loan period boundaries
11
 * - `instance._constraintHighlighting` - Cached constraint highlighting data for reapplication
12
 *
13
 * This pattern is used because:
14
 * 1. Flatpickr callbacks don't share closure state between different hooks
15
 * 2. State must persist across month navigation (onMonthChange triggers re-render)
16
 * 3. The instance is the only stable reference available in all callbacks
17
 *
18
 * Cleanup: Properties are deleted when dates are cleared or the instance is destroyed.
19
 */
20
21
import {
22
    handleBookingDateChange,
23
    deriveEffectiveRules,
24
    findFirstBlockingDate,
25
} from "../../booking/availability.mjs";
26
import {
27
    getBookingMarkersForDate,
28
    aggregateMarkersByType,
29
} from "../../booking/markers.mjs";
30
import {
31
    calculateConstraintHighlighting,
32
    getCalendarNavigationTarget,
33
} from "../../booking/highlighting.mjs";
34
import {
35
    toISO,
36
    formatYMD,
37
    toDayjs,
38
    subDays,
39
} from "../../booking/BookingDate.mjs";
40
import { calendarLogger as logger } from "../../booking/logger.mjs";
41
import {
42
    CLASS_BOOKING_DAY_HOVER_LEAD,
43
    CLASS_BOOKING_DAY_HOVER_TRAIL,
44
    CLASS_BOOKING_MARKER_GRID,
45
    CLASS_FLATPICKR_DISABLED,
46
    CALENDAR_NAVIGATION_DELAY_MS,
47
} from "../../booking/constants.mjs";
48
import { getDateFeedbackMessage } from "../../ui/hover-feedback.mjs";
49
import {
50
    applyCalendarHighlighting,
51
    clearCalendarHighlighting,
52
} from "./highlighting.mjs";
53
import { getVisibleCalendarDates } from "./visibility.mjs";
54
import { buildMarkerGrid } from "./markers.mjs";
55
56
// ============================================================================
57
// Helper functions for createOnChange (extracted for clarity)
58
// ============================================================================
59
60
/**
61
 * Filter valid Date objects from selected dates array.
62
 * @param {Date[]} selectedDates
63
 * @returns {Date[]}
64
 */
65
function filterValidDates(selectedDates) {
66
    return (selectedDates || []).filter(
67
        d => d instanceof Date && !Number.isNaN(d.getTime())
68
    );
69
}
70
71
/**
72
 * Clear loan boundary cache from instance.
73
 * @param {import('flatpickr/dist/types/instance').Instance} instance
74
 */
75
function clearLoanBoundaryCache(instance) {
76
    if (instance) {
77
        const instWithCache = /** @type {any} */ (instance);
78
        delete instWithCache._loanBoundaryTimes;
79
    }
80
}
81
82
/**
83
 * Sync selected dates to store if changed.
84
 * @param {object} store
85
 * @param {Date[]} validDates
86
 * @returns {string[]} ISO date range
87
 */
88
function syncStoreDates(store, validDates) {
89
    const isoDateRange = validDates.map(d => toISO(d));
90
    const current = store.selectedDateRange || [];
91
    const same =
92
        current.length === isoDateRange.length &&
93
        current.every((v, i) => v === isoDateRange[i]);
94
    if (!same) store.selectedDateRange = isoDateRange;
95
    return isoDateRange;
96
}
97
98
/**
99
 * Compute and cache loan boundary times on the flatpickr instance.
100
 * @param {import('flatpickr/dist/types/instance').Instance} instance
101
 * @param {Date[]} validDates
102
 * @param {object} baseRules
103
 */
104
function computeLoanBoundaries(instance, validDates, baseRules) {
105
    if (!instance || validDates.length === 0) return;
106
    try {
107
        const instWithCache = /** @type {any} */ (instance);
108
        const startDate = toDayjs(validDates[0]).startOf("day");
109
        const issuelength = parseInt(baseRules?.issuelength) || 0;
110
        const renewalperiod = parseInt(baseRules?.renewalperiod) || 0;
111
        const renewalsallowed = parseInt(baseRules?.renewalsallowed) || 0;
112
        const times = new Set();
113
        // Start date is always a boundary
114
        times.add(startDate.toDate().getTime());
115
        if (issuelength > 0) {
116
            const initialEnd = startDate
117
                .add(issuelength, "day")
118
                .toDate()
119
                .getTime();
120
            times.add(initialEnd);
121
            if (renewalperiod > 0 && renewalsallowed > 0) {
122
                for (let k = 1; k <= renewalsallowed; k++) {
123
                    const t = startDate
124
                        .add(issuelength + k * renewalperiod, "day")
125
                        .toDate()
126
                        .getTime();
127
                    times.add(t);
128
                }
129
            }
130
        }
131
        instWithCache._loanBoundaryTimes = times;
132
    } catch (e) {
133
        // non-fatal: boundary decoration best-effort
134
    }
135
}
136
137
/**
138
 * Handle validation result and set error message.
139
 * @param {object} result
140
 * @param {Function|null} setError
141
 */
142
function handleValidationResult(result, setError) {
143
    if (typeof setError !== "function") return;
144
145
    const isValid =
146
        (result && Object.prototype.hasOwnProperty.call(result, "valid")
147
            ? result.valid
148
            : result?.isValid) ?? true;
149
150
    let message = "";
151
    if (!isValid) {
152
        if (Array.isArray(result?.errors)) {
153
            message = result.errors.join(", ");
154
        } else if (typeof result?.errorMessage === "string") {
155
            message = result.errorMessage;
156
        } else if (result?.errorMessage != null) {
157
            message = String(result.errorMessage);
158
        } else if (result?.errors != null) {
159
            message = String(result.errors);
160
        }
161
    }
162
    setError(message);
163
}
164
165
/**
166
 * Navigate calendar to show the target end date if needed.
167
 * @param {import('flatpickr/dist/types/instance').Instance} instance
168
 * @param {object} highlightingData
169
 * @param {Function} _getVisibleCalendarDates
170
 * @param {Function} _getCalendarNavigationTarget
171
 */
172
function navigateCalendarIfNeeded(
173
    instance,
174
    highlightingData,
175
    _getVisibleCalendarDates,
176
    _getCalendarNavigationTarget
177
) {
178
    const visible = _getVisibleCalendarDates(instance);
179
    const currentView =
180
        visible?.length > 0
181
            ? {
182
                  visibleStartDate: visible[0],
183
                  visibleEndDate: visible[visible.length - 1],
184
              }
185
            : {};
186
    const nav = _getCalendarNavigationTarget(
187
        highlightingData.startDate,
188
        highlightingData.targetEndDate,
189
        currentView
190
    );
191
    if (nav.shouldNavigate && nav.targetDate) {
192
        setTimeout(() => {
193
            if (instance.jumpToDate) {
194
                instance.jumpToDate(nav.targetDate);
195
            } else if (instance.changeMonth) {
196
                if (
197
                    typeof instance.changeYear === "function" &&
198
                    typeof nav.targetYear === "number" &&
199
                    instance.currentYear !== nav.targetYear
200
                ) {
201
                    instance.changeYear(nav.targetYear);
202
                }
203
                const offset =
204
                    typeof instance.currentMonth === "number"
205
                        ? nav.targetMonth - instance.currentMonth
206
                        : 0;
207
                instance.changeMonth(offset, false);
208
            }
209
        }, CALENDAR_NAVIGATION_DELAY_MS);
210
    }
211
}
212
213
/**
214
 * Clamp highlighting range to actual availability by finding first blocking date.
215
 * @param {object} highlightingData
216
 * @param {object} store
217
 * @param {object} effectiveRules
218
 * @returns {object} Clamped highlighting data
219
 */
220
function clampHighlightingToAvailability(
221
    highlightingData,
222
    store,
223
    effectiveRules
224
) {
225
    if (
226
        !highlightingData ||
227
        !Array.isArray(store.bookings) ||
228
        !Array.isArray(store.checkouts) ||
229
        !Array.isArray(store.bookableItems) ||
230
        store.bookableItems.length === 0
231
    ) {
232
        return highlightingData;
233
    }
234
235
    const { firstBlockingDate } = findFirstBlockingDate(
236
        highlightingData.startDate,
237
        highlightingData.targetEndDate,
238
        store.bookings,
239
        store.checkouts,
240
        store.bookableItems,
241
        store.bookingItemId,
242
        store.bookingId,
243
        effectiveRules
244
    );
245
246
    if (!firstBlockingDate) return highlightingData;
247
248
    const clampedEndDate = subDays(firstBlockingDate, 1).toDate();
249
    if (clampedEndDate >= highlightingData.targetEndDate)
250
        return highlightingData;
251
252
    return {
253
        ...highlightingData,
254
        targetEndDate: clampedEndDate,
255
        blockedIntermediateDates:
256
            highlightingData.blockedIntermediateDates.filter(
257
                date => date <= clampedEndDate
258
            ),
259
    };
260
}
261
262
// ============================================================================
263
// Main event handler factories
264
// ============================================================================
265
266
/**
267
 * Create a Flatpickr `onChange` handler bound to the booking store.
268
 *
269
 * @param {object} store - Booking Pinia store (or compatible shape)
270
 * @param {import('../../../types/bookings').OnChangeOptions} options
271
 * @param {object} [deps] - Optional dependency injection for testing
272
 * @param {Function} [deps.getVisibleCalendarDates] - Override for getVisibleCalendarDates
273
 * @param {Function} [deps.calculateConstraintHighlighting] - Override for calculateConstraintHighlighting
274
 * @param {Function} [deps.handleBookingDateChange] - Override for handleBookingDateChange
275
 * @param {Function} [deps.getCalendarNavigationTarget] - Override for getCalendarNavigationTarget
276
 */
277
export function createOnChange(
278
    store,
279
    {
280
        setError = null,
281
        tooltipVisibleRef = null,
282
        constraintOptionsRef = null,
283
    } = {},
284
    deps = {}
285
) {
286
    // Use injected dependencies or defaults (clean DI pattern for testing)
287
    const _getVisibleCalendarDates =
288
        deps.getVisibleCalendarDates || getVisibleCalendarDates;
289
    const _calculateConstraintHighlighting =
290
        deps.calculateConstraintHighlighting || calculateConstraintHighlighting;
291
    const _handleBookingDateChange =
292
        deps.handleBookingDateChange || handleBookingDateChange;
293
    const _getCalendarNavigationTarget =
294
        deps.getCalendarNavigationTarget || getCalendarNavigationTarget;
295
296
    return function (selectedDates, _dateStr, instance) {
297
        logger.debug("handleDateChange triggered", { selectedDates });
298
299
        const constraintOptions = constraintOptionsRef?.value ?? {};
300
        const validDates = filterValidDates(selectedDates);
301
302
        if ((selectedDates || []).length === 0) {
303
            clearLoanBoundaryCache(instance);
304
            if (
305
                Array.isArray(store.selectedDateRange) &&
306
                store.selectedDateRange.length
307
            ) {
308
                store.selectedDateRange = [];
309
            }
310
            if (typeof setError === "function") setError("");
311
            return;
312
        }
313
314
        if ((selectedDates || []).length > 0 && validDates.length === 0) {
315
            logger.warn(
316
                "All dates invalid, skipping processing to preserve state"
317
            );
318
            return;
319
        }
320
321
        syncStoreDates(store, validDates);
322
323
        const baseRules =
324
            (store.circulationRules && store.circulationRules[0]) || {};
325
        const effectiveRules = deriveEffectiveRules(
326
            baseRules,
327
            constraintOptions
328
        );
329
        computeLoanBoundaries(instance, validDates, baseRules);
330
331
        let calcOptions = {};
332
        if (instance) {
333
            const visible = _getVisibleCalendarDates(instance);
334
            if (visible?.length > 0) {
335
                calcOptions = {
336
                    onDemand: true,
337
                    visibleStartDate: visible[0],
338
                    visibleEndDate: visible[visible.length - 1],
339
                };
340
            }
341
        }
342
343
        const result = _handleBookingDateChange(
344
            selectedDates,
345
            effectiveRules,
346
            store.bookings,
347
            store.checkouts,
348
            store.bookableItems,
349
            store.bookingItemId,
350
            store.bookingId,
351
            undefined,
352
            calcOptions
353
        );
354
355
        handleValidationResult(result, setError);
356
        if (tooltipVisibleRef && "value" in tooltipVisibleRef) {
357
            tooltipVisibleRef.value = false;
358
        }
359
360
        if (instance && selectedDates.length === 1) {
361
            let highlightingData = _calculateConstraintHighlighting(
362
                selectedDates[0],
363
                effectiveRules,
364
                constraintOptions
365
            );
366
367
            // Clamp to actual availability
368
            highlightingData = clampHighlightingToAvailability(
369
                highlightingData,
370
                store,
371
                effectiveRules
372
            );
373
374
            if (highlightingData) {
375
                applyCalendarHighlighting(instance, highlightingData);
376
                navigateCalendarIfNeeded(
377
                    instance,
378
                    highlightingData,
379
                    _getVisibleCalendarDates,
380
                    _getCalendarNavigationTarget
381
                );
382
            }
383
        }
384
385
        if (instance && selectedDates.length === 0) {
386
            const instWithCache =
387
                /** @type {import('../../../types/bookings').FlatpickrInstanceWithHighlighting} */ (
388
                    instance
389
                );
390
            instWithCache._constraintHighlighting = null;
391
            clearCalendarHighlighting(instance);
392
        }
393
    };
394
}
395
396
/**
397
 * Ensure the feedback bar element exists inside the flatpickr calendar
398
 * container. Creates it on first call and reuses it thereafter.
399
 *
400
 * @param {import('flatpickr/dist/types/instance').Instance} fp
401
 * @returns {HTMLDivElement}
402
 */
403
function ensureFeedbackBar(fp) {
404
    const container = fp.calendarContainer;
405
    let bar = container.querySelector(".booking-hover-feedback");
406
    if (!bar) {
407
        bar = document.createElement("div");
408
        bar.className = "booking-hover-feedback";
409
        bar.setAttribute("role", "status");
410
        bar.setAttribute("aria-live", "polite");
411
        container.appendChild(bar);
412
    }
413
    return /** @type {HTMLDivElement} */ (bar);
414
}
415
416
/** @type {number|null} */
417
let _feedbackHideTimer = null;
418
419
/**
420
 * Update the feedback bar inside the flatpickr calendar container.
421
 * Hides are deferred so that rapid mouseout→mouseover between adjacent
422
 * days doesn't trigger a visible flicker.
423
 *
424
 * @param {HTMLDivElement} bar
425
 * @param {{ message: string, variant: string } | null} feedback
426
 */
427
function updateFeedbackBar(bar, feedback) {
428
    if (!feedback) {
429
        if (_feedbackHideTimer == null) {
430
            _feedbackHideTimer = setTimeout(() => {
431
                _feedbackHideTimer = null;
432
                bar.classList.remove(
433
                    "booking-hover-feedback--visible",
434
                    "booking-hover-feedback--info",
435
                    "booking-hover-feedback--warning",
436
                    "booking-hover-feedback--danger"
437
                );
438
            }, 16);
439
        }
440
        return;
441
    }
442
    if (_feedbackHideTimer != null) {
443
        clearTimeout(_feedbackHideTimer);
444
        _feedbackHideTimer = null;
445
    }
446
    bar.textContent = feedback.message;
447
    bar.classList.remove(
448
        "booking-hover-feedback--info",
449
        "booking-hover-feedback--warning",
450
        "booking-hover-feedback--danger"
451
    );
452
    bar.classList.add(
453
        "booking-hover-feedback--visible",
454
        `booking-hover-feedback--${feedback.variant}`
455
    );
456
}
457
458
/**
459
 * Create Flatpickr `onDayCreate` handler.
460
 *
461
 * Renders per-day marker dots, hover classes, and shows a tooltip with
462
 * aggregated markers. Appends a contextual feedback bar inside the
463
 * flatpickr calendar container (matching upstream placement).
464
 * Reapplies constraint highlighting across month navigation using the
465
 * instance's cached highlighting data.
466
 *
467
 * @param {object} store - booking store or compatible state
468
 * @param {import('../../../types/bookings').RefLike<import('../../../types/bookings').CalendarMarker[]>} tooltipMarkers - ref of markers shown in tooltip
469
 * @param {import('../../../types/bookings').RefLike<boolean>} tooltipVisible - visibility ref for tooltip
470
 * @param {import('../../../types/bookings').RefLike<number>} tooltipX - x position ref
471
 * @param {import('../../../types/bookings').RefLike<number>} tooltipY - y position ref
472
 * @returns {import('flatpickr/dist/types/options').Hook}
473
 */
474
export function createOnDayCreate(
475
    store,
476
    tooltipMarkers,
477
    tooltipVisible,
478
    tooltipX,
479
    tooltipY
480
) {
481
    return function (
482
        ...[
483
            ,
484
            ,
485
            /** @type {import('flatpickr/dist/types/instance').Instance} */ fp,
486
            /** @type {import('flatpickr/dist/types/instance').DayElement} */ dayElem,
487
        ]
488
    ) {
489
        const existingGrids = dayElem.querySelectorAll(
490
            `.${CLASS_BOOKING_MARKER_GRID}`
491
        );
492
        existingGrids.forEach(grid => grid.remove());
493
494
        const el =
495
            /** @type {import('flatpickr/dist/types/instance').DayElement} */ (
496
                dayElem
497
            );
498
        const dateStrForMarker = formatYMD(el.dateObj);
499
        const markersForDots = getBookingMarkersForDate(
500
            store.unavailableByDate,
501
            dateStrForMarker,
502
            store.bookableItems
503
        );
504
505
        if (markersForDots.length > 0) {
506
            const aggregatedMarkers = aggregateMarkersByType(markersForDots);
507
            const grid = buildMarkerGrid(aggregatedMarkers);
508
            if (grid.hasChildNodes()) dayElem.appendChild(grid);
509
        }
510
511
        dayElem.addEventListener("mouseover", () => {
512
            const hoveredDateStr = formatYMD(el.dateObj);
513
            const currentTooltipMarkersData = getBookingMarkersForDate(
514
                store.unavailableByDate,
515
                hoveredDateStr,
516
                store.bookableItems
517
            );
518
519
            el.classList.remove(
520
                CLASS_BOOKING_DAY_HOVER_LEAD,
521
                CLASS_BOOKING_DAY_HOVER_TRAIL
522
            );
523
            let hasLeadMarker = false;
524
            let hasTrailMarker = false;
525
526
            currentTooltipMarkersData.forEach(marker => {
527
                if (marker.type === "lead") hasLeadMarker = true;
528
                if (marker.type === "trail") hasTrailMarker = true;
529
            });
530
531
            if (hasLeadMarker) {
532
                el.classList.add(CLASS_BOOKING_DAY_HOVER_LEAD);
533
            }
534
            if (hasTrailMarker) {
535
                el.classList.add(CLASS_BOOKING_DAY_HOVER_TRAIL);
536
            }
537
538
            if (currentTooltipMarkersData.length > 0) {
539
                tooltipMarkers.value = currentTooltipMarkersData;
540
                tooltipVisible.value = true;
541
542
                const rect = el.getBoundingClientRect();
543
                tooltipX.value = rect.right + window.scrollX + 8;
544
                tooltipY.value = rect.top + window.scrollY + rect.height / 2;
545
            } else {
546
                tooltipMarkers.value = [];
547
                tooltipVisible.value = false;
548
            }
549
550
            const feedbackBar = ensureFeedbackBar(fp);
551
            try {
552
                const isDisabled = el.classList.contains(CLASS_FLATPICKR_DISABLED);
553
                const rules =
554
                    (store.circulationRules && store.circulationRules[0]) || {};
555
                const feedback = getDateFeedbackMessage(el.dateObj, {
556
                    isDisabled,
557
                    selectedDateRange: store.selectedDateRange || [],
558
                    circulationRules: rules,
559
                    unavailableByDate: store.unavailableByDate,
560
                    holidays: store.holidays || [],
561
                });
562
                updateFeedbackBar(feedbackBar, feedback);
563
            } catch (_e) {
564
                updateFeedbackBar(feedbackBar, null);
565
            }
566
        });
567
568
        dayElem.addEventListener("mouseout", () => {
569
            dayElem.classList.remove(
570
                CLASS_BOOKING_DAY_HOVER_LEAD,
571
                CLASS_BOOKING_DAY_HOVER_TRAIL
572
            );
573
            tooltipVisible.value = false;
574
            const feedbackBar = ensureFeedbackBar(fp);
575
            updateFeedbackBar(feedbackBar, null);
576
        });
577
578
        // Reapply constraint highlighting if it exists (for month navigation, etc.)
579
        const fpWithCache =
580
            /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../../types/bookings').ConstraintHighlighting | null }} */ (
581
                fp
582
            );
583
        if (
584
            fpWithCache &&
585
            fpWithCache._constraintHighlighting &&
586
            fpWithCache.calendarContainer
587
        ) {
588
            requestAnimationFrame(() => {
589
                applyCalendarHighlighting(
590
                    fpWithCache,
591
                    fpWithCache._constraintHighlighting
592
                );
593
            });
594
        }
595
    };
596
}
597
598
/**
599
 * Create Flatpickr `onClose` handler to clear tooltip state.
600
 * @param {import('../../../types/bookings').RefLike<import('../../../types/bookings').CalendarMarker[]>} tooltipMarkers
601
 * @param {import('../../../types/bookings').RefLike<boolean>} tooltipVisible
602
 */
603
export function createOnClose(tooltipMarkers, tooltipVisible) {
604
    return function () {
605
        tooltipMarkers.value = [];
606
        tooltipVisible.value = false;
607
    };
608
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/highlighting.mjs (+308 lines)
Line 0 Link Here
1
/**
2
 * Calendar constraint highlighting utilities.
3
 * @module calendar/highlighting
4
 *
5
 * ## Instance Cache Pattern
6
 *
7
 * This module reads and writes custom properties on the Flatpickr instance:
8
 *
9
 * - `instance._constraintHighlighting` - Written by applyCalendarHighlighting to
10
 *   cache highlighting data for re-application after month navigation
11
 * - `instance._loanBoundaryTimes` - Read from instance (set by events.mjs) to
12
 *   apply loan boundary styling to specific dates
13
 *
14
 * See events.mjs for the full explanation of why instance mutation is used.
15
 */
16
17
import { calendarLogger as logger } from "../../booking/logger.mjs";
18
import {
19
    CONSTRAINT_MODE_END_DATE_ONLY,
20
    CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
21
    CLASS_BOOKING_INTERMEDIATE_BLOCKED,
22
    CLASS_BOOKING_LOAN_BOUNDARY,
23
    CLASS_BOOKING_OVERRIDE_ALLOWED,
24
    CLASS_FLATPICKR_DAY,
25
    CLASS_FLATPICKR_DISABLED,
26
    CLASS_FLATPICKR_NOT_ALLOWED,
27
    DATA_ATTRIBUTE_BOOKING_OVERRIDE,
28
    HIGHLIGHTING_MAX_RETRIES,
29
} from "../../booking/constants.mjs";
30
import {
31
    applyClickPrevention,
32
    applyHolidayClickPrevention,
33
} from "./prevention.mjs";
34
35
/**
36
 * Clear constraint highlighting from the Flatpickr calendar.
37
 *
38
 * @param {import('flatpickr/dist/types/instance').Instance} instance
39
 * @returns {void}
40
 */
41
export function clearCalendarHighlighting(instance) {
42
    logger.debug("Clearing calendar highlighting");
43
44
    if (!instance || !instance.calendarContainer) return;
45
46
    // Query separately to accommodate simple test DOM mocks
47
    const lists = [
48
        instance.calendarContainer.querySelectorAll(
49
            `.${CLASS_BOOKING_CONSTRAINED_RANGE_MARKER}`
50
        ),
51
        instance.calendarContainer.querySelectorAll(
52
            `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}`
53
        ),
54
        instance.calendarContainer.querySelectorAll(
55
            `.${CLASS_BOOKING_LOAN_BOUNDARY}`
56
        ),
57
    ];
58
    const existingHighlights = lists.flatMap(list => Array.from(list || []));
59
    existingHighlights.forEach(elem => {
60
        elem.classList.remove(
61
            CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
62
            CLASS_BOOKING_INTERMEDIATE_BLOCKED,
63
            CLASS_BOOKING_LOAN_BOUNDARY
64
        );
65
    });
66
}
67
68
/**
69
 * Fix incorrect date unavailability via a CSS-based override.
70
 * Used for target end dates and dates after holidays that Flatpickr incorrectly blocks.
71
 *
72
 * @param {NodeListOf<Element>|Element[]} dayElements
73
 * @param {Date} targetDate
74
 * @param {string} [logContext="target end date"]
75
 * @returns {void}
76
 */
77
export function fixDateAvailability(
78
    dayElements,
79
    targetDate,
80
    logContext = "target end date"
81
) {
82
    if (!dayElements || typeof dayElements.length !== "number") {
83
        logger.warn(
84
            `Invalid dayElements passed to fixDateAvailability (${logContext})`,
85
            dayElements
86
        );
87
        return;
88
    }
89
90
    const targetElem = Array.from(dayElements).find(
91
        elem => elem.dateObj && elem.dateObj.getTime() === targetDate.getTime()
92
    );
93
94
    if (!targetElem) {
95
        logger.debug(`Date element not found for ${logContext}`, targetDate);
96
        return;
97
    }
98
99
    // Mark the element as explicitly allowed, overriding Flatpickr's styles
100
    targetElem.classList.remove(CLASS_FLATPICKR_NOT_ALLOWED);
101
    targetElem.removeAttribute("tabindex");
102
    targetElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED);
103
104
    targetElem.setAttribute(DATA_ATTRIBUTE_BOOKING_OVERRIDE, "allowed");
105
106
    logger.debug(`Applied CSS override for ${logContext} availability`, {
107
        targetDate,
108
        element: targetElem,
109
    });
110
111
    if (targetElem.classList.contains(CLASS_FLATPICKR_DISABLED)) {
112
        targetElem.classList.remove(
113
            CLASS_FLATPICKR_DISABLED,
114
            CLASS_FLATPICKR_NOT_ALLOWED
115
        );
116
        targetElem.removeAttribute("tabindex");
117
        targetElem.classList.add(CLASS_BOOKING_OVERRIDE_ALLOWED);
118
119
        logger.debug(`Applied fix for ${logContext} availability`, {
120
            finalClasses: Array.from(targetElem.classList),
121
        });
122
    }
123
}
124
125
/**
126
 * Fix incorrect target-end unavailability via a CSS-based override.
127
 * Wrapper for backward compatibility.
128
 *
129
 * @param {import('flatpickr/dist/types/instance').Instance} _instance
130
 * @param {NodeListOf<Element>|Element[]} dayElements
131
 * @param {Date} targetEndDate
132
 * @returns {void}
133
 */
134
function fixTargetEndDateAvailability(_instance, dayElements, targetEndDate) {
135
    fixDateAvailability(dayElements, targetEndDate, "target end date");
136
}
137
138
/**
139
 * Apply constraint highlighting to the Flatpickr calendar.
140
 *
141
 * @param {import('flatpickr/dist/types/instance').Instance} instance
142
 * @param {import('../../../types/bookings').ConstraintHighlighting} highlightingData
143
 * @returns {void}
144
 */
145
export function applyCalendarHighlighting(instance, highlightingData) {
146
    if (!instance || !instance.calendarContainer || !highlightingData) {
147
        logger.debug("Missing requirements", {
148
            hasInstance: !!instance,
149
            hasContainer: !!instance?.calendarContainer,
150
            hasData: !!highlightingData,
151
        });
152
        return;
153
    }
154
155
    // Cache highlighting data for re-application after navigation
156
    const instWithCache =
157
        /** @type {import('flatpickr/dist/types/instance').Instance & { _constraintHighlighting?: import('../../../types/bookings').ConstraintHighlighting | null }} */ (
158
            instance
159
        );
160
    instWithCache._constraintHighlighting = highlightingData;
161
162
    clearCalendarHighlighting(instance);
163
164
    const applyHighlighting = (retryCount = 0) => {
165
        // Guard: calendar may have closed between requestAnimationFrame calls
166
        if (!instance || !instance.calendarContainer) {
167
            logger.debug(
168
                "Calendar closed before highlighting could be applied"
169
            );
170
            return;
171
        }
172
173
        if (retryCount === 0) {
174
            logger.group("applyCalendarHighlighting");
175
        }
176
        const dayElements = instance.calendarContainer.querySelectorAll(
177
            `.${CLASS_FLATPICKR_DAY}`
178
        );
179
180
        if (dayElements.length === 0 && retryCount < HIGHLIGHTING_MAX_RETRIES) {
181
            logger.debug(`No day elements found, retry ${retryCount + 1}`);
182
            requestAnimationFrame(() => applyHighlighting(retryCount + 1));
183
            return;
184
        }
185
186
        let highlightedCount = 0;
187
        let blockedCount = 0;
188
189
        // Preload loan boundary times cached on instance (if present)
190
        const instWithCacheForBoundary =
191
            /** @type {import('flatpickr/dist/types/instance').Instance & { _loanBoundaryTimes?: Set<number> }} */ (
192
                instance
193
            );
194
        const boundaryTimes = instWithCacheForBoundary?._loanBoundaryTimes;
195
196
        dayElements.forEach(dayElem => {
197
            if (!dayElem.dateObj) return;
198
199
            const dayTime = dayElem.dateObj.getTime();
200
            const startTime = highlightingData.startDate.getTime();
201
            const targetTime = highlightingData.targetEndDate.getTime();
202
203
            // Apply bold styling to loan period boundary dates
204
            if (boundaryTimes && boundaryTimes.has(dayTime)) {
205
                dayElem.classList.add(CLASS_BOOKING_LOAN_BOUNDARY);
206
            }
207
208
            if (dayTime >= startTime && dayTime <= targetTime) {
209
                if (
210
                    highlightingData.constraintMode ===
211
                    CONSTRAINT_MODE_END_DATE_ONLY
212
                ) {
213
                    const isBlocked =
214
                        highlightingData.blockedIntermediateDates.some(
215
                            blockedDate => dayTime === blockedDate.getTime()
216
                        );
217
218
                    if (isBlocked) {
219
                        if (
220
                            !dayElem.classList.contains(
221
                                CLASS_FLATPICKR_DISABLED
222
                            )
223
                        ) {
224
                            dayElem.classList.add(
225
                                CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
226
                                CLASS_BOOKING_INTERMEDIATE_BLOCKED
227
                            );
228
                            blockedCount++;
229
                        }
230
                    } else {
231
                        if (
232
                            !dayElem.classList.contains(
233
                                CLASS_FLATPICKR_DISABLED
234
                            )
235
                        ) {
236
                            dayElem.classList.add(
237
                                CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
238
                            );
239
                            highlightedCount++;
240
                        }
241
                    }
242
                } else {
243
                    if (!dayElem.classList.contains(CLASS_FLATPICKR_DISABLED)) {
244
                        dayElem.classList.add(
245
                            CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
246
                        );
247
                        highlightedCount++;
248
                    }
249
                }
250
            }
251
        });
252
253
        logger.debug("Highlighting applied", {
254
            highlightedCount,
255
            blockedCount,
256
            retryCount,
257
            constraintMode: highlightingData.constraintMode,
258
        });
259
260
        if (highlightingData.constraintMode === CONSTRAINT_MODE_END_DATE_ONLY) {
261
            applyClickPrevention(instance);
262
            fixTargetEndDateAvailability(
263
                instance,
264
                dayElements,
265
                highlightingData.targetEndDate
266
            );
267
268
            const targetEndElem = Array.from(dayElements).find(
269
                elem =>
270
                    elem.dateObj &&
271
                    elem.dateObj.getTime() ===
272
                        highlightingData.targetEndDate.getTime()
273
            );
274
            if (
275
                targetEndElem &&
276
                !targetEndElem.classList.contains(CLASS_FLATPICKR_DISABLED)
277
            ) {
278
                targetEndElem.classList.add(
279
                    CLASS_BOOKING_CONSTRAINED_RANGE_MARKER
280
                );
281
                logger.debug(
282
                    "Re-applied highlighting to target end date after availability fix"
283
                );
284
            }
285
        }
286
287
        if (highlightingData.holidays && highlightingData.holidays.length > 0) {
288
            const holidayTimestamps = new Set(
289
                highlightingData.holidays.map(dateStr => {
290
                    const d = new Date(dateStr);
291
                    d.setHours(0, 0, 0, 0);
292
                    return d.getTime();
293
                })
294
            );
295
296
            applyHolidayClickPrevention(
297
                dayElements,
298
                highlightingData.startDate,
299
                highlightingData.targetEndDate,
300
                holidayTimestamps
301
            );
302
        }
303
304
        logger.groupEnd();
305
    };
306
307
    requestAnimationFrame(() => applyHighlighting());
308
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/index.mjs (+26 lines)
Line 0 Link Here
1
/**
2
 * Calendar adapter barrel file.
3
 * Re-exports all calendar-related utilities for convenient importing.
4
 *
5
 * @module calendar
6
 */
7
8
export { getCurrentLanguageCode, preloadFlatpickrLocale } from "./locale.mjs";
9
10
export {
11
    clearCalendarHighlighting,
12
    applyCalendarHighlighting,
13
    fixDateAvailability,
14
} from "./highlighting.mjs";
15
16
export {
17
    preventClick,
18
    applyClickPrevention,
19
    applyHolidayClickPrevention,
20
} from "./prevention.mjs";
21
22
export { createOnChange, createOnDayCreate, createOnClose } from "./events.mjs";
23
24
export { getVisibleCalendarDates } from "./visibility.mjs";
25
26
export { buildMarkerGrid } from "./markers.mjs";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/locale.mjs (+34 lines)
Line 0 Link Here
1
/**
2
 * Flatpickr locale handling utilities.
3
 * @module calendar/locale
4
 */
5
6
/**
7
 * Get the current language code from the HTML lang attribute.
8
 * @returns {string} Two-letter language code
9
 */
10
export function getCurrentLanguageCode() {
11
    const htmlLang = document.documentElement.lang || "en";
12
    return htmlLang.split("-")[0].toLowerCase();
13
}
14
15
/**
16
 * Pre-load flatpickr locale based on current language.
17
 * Should ideally be called once when the page loads.
18
 * @returns {Promise<void>}
19
 */
20
export async function preloadFlatpickrLocale() {
21
    const langCode = getCurrentLanguageCode();
22
23
    if (langCode === "en") {
24
        return;
25
    }
26
27
    try {
28
        await import(`flatpickr/dist/l10n/${langCode}.js`);
29
    } catch (e) {
30
        console.warn(
31
            `Flatpickr locale for '${langCode}' not found, will use fallback translations`
32
        );
33
    }
34
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/markers.mjs (+40 lines)
Line 0 Link Here
1
/**
2
 * Calendar marker DOM building utilities.
3
 * @module calendar/markers
4
 */
5
6
import {
7
    CLASS_BOOKING_MARKER_COUNT,
8
    CLASS_BOOKING_MARKER_DOT,
9
    CLASS_BOOKING_MARKER_GRID,
10
    CLASS_BOOKING_MARKER_ITEM,
11
} from "../../booking/constants.mjs";
12
13
/**
14
 * Build the DOM grid for aggregated booking markers.
15
 *
16
 * @param {import('../../../types/bookings').MarkerAggregation} aggregatedMarkers - counts by marker type
17
 * @returns {HTMLDivElement} container element with marker items
18
 */
19
export function buildMarkerGrid(aggregatedMarkers) {
20
    const gridContainer = document.createElement("div");
21
    gridContainer.className = CLASS_BOOKING_MARKER_GRID;
22
    Object.entries(aggregatedMarkers).forEach(([type, count]) => {
23
        const markerSpan = document.createElement("span");
24
        markerSpan.className = CLASS_BOOKING_MARKER_ITEM;
25
26
        const dot = document.createElement("span");
27
        dot.className = `${CLASS_BOOKING_MARKER_DOT} ${CLASS_BOOKING_MARKER_DOT}--${type}`;
28
        dot.title = type.charAt(0).toUpperCase() + type.slice(1);
29
        markerSpan.appendChild(dot);
30
31
        if (count > 0) {
32
            const countSpan = document.createElement("span");
33
            countSpan.className = CLASS_BOOKING_MARKER_COUNT;
34
            countSpan.textContent = ` ${count}`;
35
            markerSpan.appendChild(countSpan);
36
        }
37
        gridContainer.appendChild(markerSpan);
38
    });
39
    return gridContainer;
40
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/prevention.mjs (+87 lines)
Line 0 Link Here
1
/**
2
 * Click prevention utilities for calendar date selection.
3
 * @module calendar/prevention
4
 */
5
6
import { calendarLogger as logger } from "../../booking/logger.mjs";
7
import {
8
    CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
9
    CLASS_BOOKING_INTERMEDIATE_BLOCKED,
10
} from "../../booking/constants.mjs";
11
12
/**
13
 * Click prevention handler.
14
 * @param {Event} e - Click event
15
 * @returns {boolean} Always false to prevent default
16
 */
17
export function preventClick(e) {
18
    e.preventDefault();
19
    e.stopPropagation();
20
    return false;
21
}
22
23
/**
24
 * Apply click prevention for intermediate dates in end_date_only mode.
25
 * @param {import('flatpickr/dist/types/instance').Instance} instance - Flatpickr instance
26
 * @returns {void}
27
 */
28
export function applyClickPrevention(instance) {
29
    if (!instance || !instance.calendarContainer) return;
30
31
    const blockedElements = instance.calendarContainer.querySelectorAll(
32
        `.${CLASS_BOOKING_INTERMEDIATE_BLOCKED}`
33
    );
34
    blockedElements.forEach(elem => {
35
        elem.removeEventListener("click", preventClick, { capture: true });
36
        elem.addEventListener("click", preventClick, { capture: true });
37
    });
38
}
39
40
/**
41
 * Apply click prevention for holidays when selecting end dates.
42
 * Holidays are not disabled in the function (to allow Flatpickr range validation to pass),
43
 * but we prevent clicking on them and add visual styling.
44
 *
45
 * @param {NodeListOf<Element>|Element[]} dayElements - Day elements from Flatpickr
46
 * @param {Date} startDate - Selected start date
47
 * @param {Date} targetEndDate - Maximum allowed end date
48
 * @param {Set<number>} holidayTimestamps - Set of holiday date timestamps
49
 * @returns {void}
50
 */
51
export function applyHolidayClickPrevention(
52
    dayElements,
53
    startDate,
54
    targetEndDate,
55
    holidayTimestamps
56
) {
57
    if (!dayElements || holidayTimestamps.size === 0) {
58
        return;
59
    }
60
61
    const startTime = startDate.getTime();
62
    const endTime = targetEndDate.getTime();
63
    let blockedCount = 0;
64
65
    Array.from(dayElements).forEach(elem => {
66
        if (!elem.dateObj) return;
67
68
        const dayTime = elem.dateObj.getTime();
69
70
        if (dayTime <= startTime || dayTime > endTime) return;
71
        if (!holidayTimestamps.has(dayTime)) return;
72
73
        elem.classList.add(
74
            CLASS_BOOKING_CONSTRAINED_RANGE_MARKER,
75
            CLASS_BOOKING_INTERMEDIATE_BLOCKED
76
        );
77
78
        elem.removeEventListener("click", preventClick, { capture: true });
79
        elem.addEventListener("click", preventClick, { capture: true });
80
81
        blockedCount++;
82
    });
83
84
    if (blockedCount > 0) {
85
        logger.debug("Applied click prevention to holidays", { blockedCount });
86
    }
87
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/calendar/visibility.mjs (+46 lines)
Line 0 Link Here
1
/**
2
 * Calendar visibility and date extraction utilities.
3
 * @module calendar/visibility
4
 */
5
6
import { startOfDayTs, toDayjs } from "../../booking/BookingDate.mjs";
7
import { CLASS_FLATPICKR_DAY } from "../../booking/constants.mjs";
8
import { calendarLogger } from "../../booking/logger.mjs";
9
10
/**
11
 * Generate all visible dates for the current calendar view.
12
 * UI-level helper; belongs with calendar DOM logic.
13
 *
14
 * @param {import('../../../types/bookings').FlatpickrInstanceWithHighlighting} flatpickrInstance - Flatpickr instance
15
 * @returns {Date[]} Array of Date objects
16
 */
17
export function getVisibleCalendarDates(flatpickrInstance) {
18
    try {
19
        if (!flatpickrInstance) return [];
20
21
        // Prefer the calendar container; fall back to `.days` if present
22
        const container =
23
            flatpickrInstance.calendarContainer || flatpickrInstance.days;
24
        if (!container || !container.querySelectorAll) return [];
25
26
        const dayNodes = container.querySelectorAll(`.${CLASS_FLATPICKR_DAY}`);
27
        if (!dayNodes || dayNodes.length === 0) return [];
28
29
        // Map visible day elements to normalized Date objects and de-duplicate
30
        const seen = new Set();
31
        const dates = [];
32
        Array.from(dayNodes).forEach(el => {
33
            const d = el && el.dateObj ? el.dateObj : null;
34
            if (!d) return;
35
            const ts = startOfDayTs(d);
36
            if (!seen.has(ts)) {
37
                seen.add(ts);
38
                dates.push(toDayjs(d).startOf("day").toDate());
39
            }
40
        });
41
        return dates;
42
    } catch (e) {
43
        calendarLogger.warn("getVisibleCalendarDates", "Failed to extract visible dates", e);
44
        return [];
45
    }
46
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/external-dependents.mjs (+261 lines)
Line 0 Link Here
1
import { win } from "./globals.mjs";
2
import { transformPatronData } from "./patron.mjs";
3
import dayjs from "../../../../utils/dayjs.mjs";
4
5
const $__ = globalThis.$__ || (str => str);
6
7
/** @typedef {import('../../types/bookings').ExternalDependencies} ExternalDependencies */
8
9
export { debounce } from "../../../../utils/functions.mjs";
10
11
/**
12
 * Default dependencies for external updates - can be overridden in tests
13
 * @type {ExternalDependencies}
14
 */
15
const defaultDependencies = {
16
    timeline: () => win("timeline"),
17
    bookingsTable: () => win("bookings_table"),
18
    patronRenderer: () => win("$patron_to_html"),
19
    domQuery: selector => document.querySelectorAll(selector),
20
    logger: {
21
        warn: (msg, data) => console.warn(msg, data),
22
        error: (msg, error) => console.error(msg, error),
23
    },
24
};
25
26
/**
27
 * Renders patron content for display, with injected dependency
28
 *
29
 * @param {{ cardnumber?: string }|null} bookingPatron
30
 * @param {ExternalDependencies} [dependencies=defaultDependencies]
31
 * @returns {string}
32
 */
33
function renderPatronContent(
34
    bookingPatron,
35
    dependencies = defaultDependencies
36
) {
37
    try {
38
        const patronRenderer = dependencies.patronRenderer();
39
        if (typeof patronRenderer === "function" && bookingPatron) {
40
            return patronRenderer(bookingPatron, {
41
                display_cardnumber: true,
42
                url: true,
43
            });
44
        }
45
46
        if (bookingPatron) {
47
            const transformed = transformPatronData(bookingPatron);
48
            return transformed?.label || bookingPatron.cardnumber || "";
49
        }
50
51
        return "";
52
    } catch (error) {
53
        dependencies.logger.error("Failed to render patron content", {
54
            error,
55
            bookingPatron,
56
        });
57
        const transformed = transformPatronData(bookingPatron);
58
        return transformed?.label || bookingPatron?.cardnumber || "";
59
    }
60
}
61
62
/**
63
 * Updates timeline component with booking data
64
 *
65
 * @param {import('../../types/bookings').Booking} newBooking
66
 * @param {{ cardnumber?: string }|null} bookingPatron
67
 * @param {boolean} isUpdate
68
 * @param {ExternalDependencies} dependencies
69
 * @returns {{ success: boolean, reason?: string }}
70
 */
71
function updateTimelineComponent(
72
    newBooking,
73
    bookingPatron,
74
    isUpdate,
75
    dependencies
76
) {
77
    const timeline = dependencies.timeline();
78
    if (!timeline) return { success: false, reason: "Timeline not available" };
79
80
    try {
81
        const timezoneFn = win("$timezone");
82
        const tz = typeof timezoneFn === "function" ? timezoneFn() : null;
83
        const startDayjs = tz && dayjs.tz
84
            ? dayjs(newBooking.start_date).tz(tz)
85
            : dayjs(newBooking.start_date);
86
        const endDayjs = tz && dayjs.tz
87
            ? dayjs(newBooking.end_date).tz(tz)
88
            : dayjs(newBooking.end_date);
89
90
        const itemData = {
91
            id: newBooking.booking_id,
92
            booking: newBooking.booking_id,
93
            patron: newBooking.patron_id,
94
            start: startDayjs.toDate(),
95
            end: endDayjs.toDate(),
96
            content: renderPatronContent(bookingPatron, dependencies),
97
            editable: { remove: true, updateTime: true },
98
            type: "range",
99
            group: newBooking.item_id ? newBooking.item_id : 0,
100
        };
101
102
        if (isUpdate) {
103
            timeline.itemsData.update(itemData);
104
        } else {
105
            timeline.itemsData.add(itemData);
106
        }
107
        timeline.focus(newBooking.booking_id);
108
109
        return { success: true };
110
    } catch (error) {
111
        dependencies.logger.error("Failed to update timeline", {
112
            error,
113
            newBooking,
114
        });
115
        return { success: false, reason: error.message };
116
    }
117
}
118
119
/**
120
 * Updates bookings table component
121
 *
122
 * @param {ExternalDependencies} dependencies
123
 * @returns {{ success: boolean, reason?: string }}
124
 */
125
function updateBookingsTable(dependencies) {
126
    const bookingsTable = dependencies.bookingsTable();
127
    if (!bookingsTable)
128
        return { success: false, reason: "Bookings table not available" };
129
130
    try {
131
        bookingsTable.api().ajax.reload();
132
        return { success: true };
133
    } catch (error) {
134
        dependencies.logger.error("Failed to update bookings table", { error });
135
        return { success: false, reason: error.message };
136
    }
137
}
138
139
/**
140
 * Updates booking count elements in the DOM
141
 *
142
 * @param {boolean} isUpdate
143
 * @param {ExternalDependencies} dependencies
144
 * @returns {{ success: boolean, reason?: string, updatedElements?: number, totalElements?: number }}
145
 */
146
function updateBookingCounts(isUpdate, dependencies) {
147
    if (isUpdate)
148
        return { success: true, reason: "No count update needed for updates" };
149
150
    try {
151
        const countEls = dependencies.domQuery(".bookings_count");
152
        let updatedCount = 0;
153
154
        countEls.forEach(el => {
155
            const html = el.innerHTML;
156
            const match = html.match(/(\d+)/);
157
            if (match) {
158
                const newCount = parseInt(match[1], 10) + 1;
159
                el.innerHTML = html.replace(/(\d+)/, String(newCount));
160
                updatedCount++;
161
            }
162
        });
163
164
        return {
165
            success: true,
166
            updatedElements: updatedCount,
167
            totalElements: countEls.length,
168
        };
169
    } catch (error) {
170
        dependencies.logger.error("Failed to update booking counts", { error });
171
        return { success: false, reason: error.message };
172
    }
173
}
174
175
/**
176
 * Shows a transient success message in the #transient_result element
177
 *
178
 * @param {boolean} isUpdate - Whether this was an update or create
179
 * @param {ExternalDependencies} dependencies
180
 * @returns {{ success: boolean, reason?: string }}
181
 */
182
function showTransientSuccess(isUpdate, dependencies) {
183
    try {
184
        const container = dependencies.domQuery("#transient_result");
185
        if (!container || container.length === 0) {
186
            return { success: false, reason: "Transient result container not found" };
187
        }
188
189
        const msg = isUpdate
190
            ? $__("Booking successfully updated")
191
            : $__("Booking successfully placed");
192
193
        const el = container[0] || container;
194
        el.innerHTML = `<div class="alert alert-success alert-dismissible fade show" role="alert">
195
            ${msg}
196
            <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
197
        </div>`;
198
199
        return { success: true };
200
    } catch (error) {
201
        dependencies.logger.error("Failed to show transient success", { error });
202
        return { success: false, reason: error.message };
203
    }
204
}
205
206
/**
207
 * Updates external components that depend on booking data
208
 *
209
 * This function is designed with dependency injection to make it testable
210
 * and to provide proper error handling with detailed feedback.
211
 *
212
 * @param {import('../../types/bookings').Booking} newBooking - The booking data that was created/updated
213
 * @param {{ cardnumber?: string }|null} bookingPatron - The patron data for rendering
214
 * @param {boolean} isUpdate - Whether this is an update (true) or create (false)
215
 * @param {ExternalDependencies} dependencies - Injectable dependencies (for testing)
216
 * @returns {Record<string, { attempted: boolean, success?: boolean, reason?: string }>} Results summary with success/failure details
217
 */
218
export function updateExternalDependents(
219
    newBooking,
220
    bookingPatron,
221
    isUpdate = false,
222
    dependencies = defaultDependencies
223
) {
224
    const results = {
225
        timeline: { attempted: false },
226
        bookingsTable: { attempted: false },
227
        bookingCounts: { attempted: false },
228
        transientSuccess: { attempted: false },
229
    };
230
231
    if (dependencies.timeline()) {
232
        results.timeline = {
233
            attempted: true,
234
            ...updateTimelineComponent(
235
                newBooking,
236
                bookingPatron,
237
                isUpdate,
238
                dependencies
239
            ),
240
        };
241
    }
242
243
    if (dependencies.bookingsTable()) {
244
        results.bookingsTable = {
245
            attempted: true,
246
            ...updateBookingsTable(dependencies),
247
        };
248
    }
249
250
    results.bookingCounts = {
251
        attempted: true,
252
        ...updateBookingCounts(isUpdate, dependencies),
253
    };
254
255
    results.transientSuccess = {
256
        attempted: true,
257
        ...showTransientSuccess(isUpdate, dependencies),
258
    };
259
260
    return results;
261
}
(-)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 (+14 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
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/adapters/patron.mjs (+118 lines)
Line 0 Link Here
1
/**
2
 * Patron data transformation and search utilities.
3
 * @module adapters/patron
4
 *
5
 * ## Fallback Drift Risk
6
 *
7
 * `buildPatronSearchQuery` delegates to `window.buildPatronSearchQuery` when available,
8
 * falling back to a simplified local implementation. This creates a maintenance risk:
9
 *
10
 * - The fallback may drift from the real implementation as Koha evolves
11
 * - The fallback lacks support for extended attribute searching
12
 * - Search behavior may differ between staff interface (has global) and tests (uses fallback)
13
 *
14
 * If patron search behaves unexpectedly, verify that the global function is loaded
15
 * before the booking modal initializes. The fallback logs a warning when used.
16
 */
17
18
import { win } from "./globals.mjs";
19
import { managerLogger as logger } from "../booking/logger.mjs";
20
/**
21
 * Builds a search query for patron searches
22
 * This is a wrapper around the global buildPatronSearchQuery function
23
 * @param {string} term - The search term
24
 * @param {Object} [options] - Search options
25
 * @param {string} [options.search_type] - 'contains' or 'starts_with'
26
 * @param {string} [options.search_fields] - Comma-separated list of fields to search
27
 * @param {Array} [options.extended_attribute_types] - Extended attribute types to search
28
 * @param {string} [options.table_prefix] - Table name prefix for fields
29
 * @returns {Array} Query conditions for the API
30
 */
31
export function buildPatronSearchQuery(term, options = {}) {
32
    /** @type {((term: string, options?: object) => any) | null} */
33
    const globalBuilder =
34
        typeof win("buildPatronSearchQuery") === "function"
35
            ? /** @type {any} */ (win("buildPatronSearchQuery"))
36
            : null;
37
    if (globalBuilder) {
38
        return globalBuilder(term, options);
39
    }
40
41
    // Fallback implementation if the global function is not available
42
    logger.warn(
43
        "window.buildPatronSearchQuery is not available, using fallback implementation"
44
    );
45
    const q = [];
46
    if (!term) return q;
47
48
    const table_prefix = options.table_prefix || "me";
49
    const search_fields = options.search_fields
50
        ? options.search_fields.split(",").map(f => f.trim())
51
        : ["surname", "firstname", "cardnumber", "userid"];
52
53
    search_fields.forEach(field => {
54
        q.push({
55
            [`${table_prefix}.${field}`]: {
56
                like: `%${term}%`,
57
            },
58
        });
59
    });
60
61
    return [{ "-or": q }];
62
}
63
64
/**
65
 * Calculates age in years from a date of birth string.
66
 * @param {string} dateOfBirth - ISO date string (YYYY-MM-DD)
67
 * @returns {number|null} Age in whole years, or null if invalid
68
 */
69
export function getAgeFromDob(dateOfBirth) {
70
    if (!dateOfBirth) return null;
71
    const dob = new Date(dateOfBirth);
72
    if (isNaN(dob.getTime())) return null;
73
    const today = new Date();
74
    let age = today.getFullYear() - dob.getFullYear();
75
    const monthDiff = today.getMonth() - dob.getMonth();
76
    if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
77
        age--;
78
    }
79
    return age;
80
}
81
82
/**
83
 * Transforms patron data into a consistent format for display.
84
 * The label (used by vue-select for filtering/selection display) shows:
85
 *   Surname, Firstname (cardnumber)
86
 * Additional fields (age, library) are available for the custom #option slot.
87
 * @param {Object} patron - The patron object to transform
88
 * @returns {Object} Transformed patron object with a display label
89
 */
90
export function transformPatronData(patron) {
91
    if (!patron) return null;
92
93
    return {
94
        ...patron,
95
        label: [
96
            patron.surname,
97
            patron.firstname,
98
            patron.cardnumber ? `(${patron.cardnumber})` : "",
99
        ]
100
            .filter(Boolean)
101
            .join(" ")
102
            .trim(),
103
        _age: getAgeFromDob(patron.date_of_birth),
104
        _libraryName: patron.library?.name || null,
105
    };
106
}
107
108
/**
109
 * Transforms an array of patrons using transformPatronData
110
 * @param {Array|Object} data - The patron data (single object or array)
111
 * @returns {Array|Object} Transformed patron(s)
112
 */
113
export function transformPatronsData(data) {
114
    if (!data) return [];
115
116
    const patrons = Array.isArray(data) ? data : data.results || [];
117
    return patrons.map(transformPatronData);
118
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/BookingDate.mjs (+588 lines)
Line 0 Link Here
1
/**
2
 * BookingDate - Unified date adapter for the booking system.
3
 *
4
 * This class encapsulates all date operations and provides consistent
5
 * conversions between different date representations used throughout
6
 * the booking system:
7
 *
8
 * - ISO 8601 strings: Used in the Pinia store (single source of truth)
9
 * - Date objects: Used by Flatpickr widget
10
 * - dayjs instances: Used for all calculations
11
 * - API format (YYYY-MM-DD): Used in REST API payloads
12
 *
13
 * By centralizing date handling, we eliminate scattered conversion calls
14
 * and reduce the risk of timezone-related bugs.
15
 *
16
 * @example
17
 * // Creating BookingDate instances
18
 * const date1 = BookingDate.from('2025-03-14T00:00:00.000Z');
19
 * const date2 = BookingDate.from(new Date());
20
 * const date3 = BookingDate.today();
21
 *
22
 * // Converting to different formats
23
 * date1.toISO();        // '2025-03-14T00:00:00.000Z'
24
 * date1.toDate();       // Date object
25
 * date1.toAPIFormat();  // '2025-03-14'
26
 *
27
 * // Arithmetic
28
 * const nextWeek = date1.addDays(7);
29
 * const lastMonth = date1.subtractMonths(1);
30
 *
31
 * // Comparisons
32
 * date1.isBefore(date2);
33
 * date1.isSameDay(date2);
34
 *
35
 * @module BookingDate
36
 */
37
38
import dayjs from "../../../../utils/dayjs.mjs";
39
40
/**
41
 * Immutable date wrapper for booking operations.
42
 * All arithmetic operations return new BookingDate instances.
43
 */
44
export class BookingDate {
45
    /** @type {import('dayjs').Dayjs} */
46
    #dayjs;
47
48
    /**
49
     * Create a BookingDate from any date-like input.
50
     * The date is normalized to start of day to avoid time-related issues.
51
     *
52
     * @param {string|number|Date|import('dayjs').Dayjs|BookingDate} input - Date input (string, timestamp, Date, dayjs, or BookingDate)
53
     * @param {Object} [options]
54
     * @param {boolean} [options.preserveTime=false] - If true, don't normalize to start of day
55
     */
56
    constructor(input, options = {}) {
57
        if (input instanceof BookingDate) {
58
            this.#dayjs = input.#dayjs.clone();
59
        } else {
60
            this.#dayjs = dayjs(
61
                /** @type {import('dayjs').ConfigType} */ (input)
62
            );
63
        }
64
65
        if (!options.preserveTime) {
66
            this.#dayjs = this.#dayjs.startOf("day");
67
        }
68
69
        if (!this.#dayjs.isValid()) {
70
            throw new Error(`Invalid date input: ${input}`);
71
        }
72
    }
73
74
    // =========================================================================
75
    // Static Factory Methods
76
    // =========================================================================
77
78
    /**
79
     * Create a BookingDate from any date-like input.
80
     * Preferred factory method for creating instances.
81
     *
82
     * @param {string|number|Date|import('dayjs').Dayjs|BookingDate|null|undefined} input
83
     * @param {Object} [options]
84
     * @param {boolean} [options.preserveTime=false]
85
     * @returns {BookingDate|null} Returns null if input is null/undefined
86
     */
87
    static from(input, options = {}) {
88
        if (input == null) return null;
89
        if (input instanceof BookingDate) return input;
90
        return new BookingDate(input, options);
91
    }
92
93
    /**
94
     * Create a BookingDate for today (start of day).
95
     * @returns {BookingDate}
96
     */
97
    static today() {
98
        return new BookingDate(dayjs());
99
    }
100
101
    /**
102
     * Create a BookingDate from an ISO string.
103
     * @param {string} isoString
104
     * @returns {BookingDate}
105
     */
106
    static fromISO(isoString) {
107
        return new BookingDate(isoString);
108
    }
109
110
    /**
111
     * Create a BookingDate from a Date object.
112
     * @param {Date} date
113
     * @returns {BookingDate}
114
     */
115
    static fromDate(date) {
116
        return new BookingDate(date);
117
    }
118
119
    /**
120
     * Create a BookingDate from API format (YYYY-MM-DD).
121
     * @param {string} apiDate
122
     * @returns {BookingDate}
123
     */
124
    static fromAPIFormat(apiDate) {
125
        return new BookingDate(apiDate);
126
    }
127
128
    /**
129
     * Convert an array of ISO strings to BookingDate array.
130
     * Filters out null/invalid values.
131
     *
132
     * @param {Array<string|null|undefined>} isoArray
133
     * @returns {BookingDate[]}
134
     */
135
    static fromISOArray(isoArray) {
136
        if (!Array.isArray(isoArray)) return [];
137
        return isoArray
138
            .filter(Boolean)
139
            .map(iso => BookingDate.fromISO(iso))
140
            .filter(d => d !== null);
141
    }
142
143
    /**
144
     * Convert an array of BookingDates to ISO strings.
145
     * @param {BookingDate[]} dates
146
     * @returns {string[]}
147
     */
148
    static toISOArray(dates) {
149
        if (!Array.isArray(dates)) return [];
150
        return dates.filter(d => d instanceof BookingDate).map(d => d.toISO());
151
    }
152
153
    /**
154
     * Convert an array of BookingDates to Date objects.
155
     * Used for Flatpickr integration.
156
     * @param {BookingDate[]} dates
157
     * @returns {Date[]}
158
     */
159
    static toDateArray(dates) {
160
        if (!Array.isArray(dates)) return [];
161
        return dates.filter(d => d instanceof BookingDate).map(d => d.toDate());
162
    }
163
164
    // =========================================================================
165
    // Conversion Methods (Output)
166
    // =========================================================================
167
168
    /**
169
     * Convert to ISO 8601 string for store storage.
170
     * @returns {string}
171
     */
172
    toISO() {
173
        return this.#dayjs.toISOString();
174
    }
175
176
    /**
177
     * Convert to native Date object for Flatpickr.
178
     * @returns {Date}
179
     */
180
    toDate() {
181
        return this.#dayjs.toDate();
182
    }
183
184
    /**
185
     * Convert to dayjs instance for complex calculations.
186
     * Returns a clone to maintain immutability.
187
     * @returns {import('dayjs').Dayjs}
188
     */
189
    toDayjs() {
190
        return this.#dayjs.clone();
191
    }
192
193
    /**
194
     * Convert to API format (YYYY-MM-DD) for REST payloads.
195
     * @returns {string}
196
     */
197
    toAPIFormat() {
198
        return this.#dayjs.format("YYYY-MM-DD");
199
    }
200
201
    /**
202
     * Format date with custom pattern.
203
     * @param {string} pattern - dayjs format pattern
204
     * @returns {string}
205
     */
206
    format(pattern) {
207
        return this.#dayjs.format(pattern);
208
    }
209
210
    /**
211
     * Get Unix timestamp in milliseconds.
212
     * @returns {number}
213
     */
214
    valueOf() {
215
        return this.#dayjs.valueOf();
216
    }
217
218
    /**
219
     * Get Unix timestamp in milliseconds (alias for valueOf).
220
     * @returns {number}
221
     */
222
    getTime() {
223
        return this.valueOf();
224
    }
225
226
    /**
227
     * String representation (ISO format).
228
     * @returns {string}
229
     */
230
    toString() {
231
        return this.toISO();
232
    }
233
234
    // =========================================================================
235
    // Arithmetic Methods (Return new BookingDate)
236
    // =========================================================================
237
238
    /**
239
     * Add days to the date.
240
     * @param {number} days
241
     * @returns {BookingDate}
242
     */
243
    addDays(days) {
244
        return new BookingDate(this.#dayjs.add(days, "day"));
245
    }
246
247
    /**
248
     * Subtract days from the date.
249
     * @param {number} days
250
     * @returns {BookingDate}
251
     */
252
    subtractDays(days) {
253
        return new BookingDate(this.#dayjs.subtract(days, "day"));
254
    }
255
256
    /**
257
     * Add months to the date.
258
     * @param {number} months
259
     * @returns {BookingDate}
260
     */
261
    addMonths(months) {
262
        return new BookingDate(this.#dayjs.add(months, "month"));
263
    }
264
265
    /**
266
     * Subtract months from the date.
267
     * @param {number} months
268
     * @returns {BookingDate}
269
     */
270
    subtractMonths(months) {
271
        return new BookingDate(this.#dayjs.subtract(months, "month"));
272
    }
273
274
    /**
275
     * Add years to the date.
276
     * @param {number} years
277
     * @returns {BookingDate}
278
     */
279
    addYears(years) {
280
        return new BookingDate(this.#dayjs.add(years, "year"));
281
    }
282
283
    /**
284
     * Subtract years from the date.
285
     * @param {number} years
286
     * @returns {BookingDate}
287
     */
288
    subtractYears(years) {
289
        return new BookingDate(this.#dayjs.subtract(years, "year"));
290
    }
291
292
    // =========================================================================
293
    // Comparison Methods
294
    // =========================================================================
295
296
    /**
297
     * Check if this date is before another date.
298
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
299
     * @param {'day'|'month'|'year'} [unit='day']
300
     * @returns {boolean}
301
     */
302
    isBefore(other, unit = "day") {
303
        const otherDate = BookingDate.from(other);
304
        if (!otherDate) return false;
305
        return this.#dayjs.isBefore(otherDate.#dayjs, unit);
306
    }
307
308
    /**
309
     * Check if this date is after another date.
310
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
311
     * @param {'day'|'month'|'year'} [unit='day']
312
     * @returns {boolean}
313
     */
314
    isAfter(other, unit = "day") {
315
        const otherDate = BookingDate.from(other);
316
        if (!otherDate) return false;
317
        return this.#dayjs.isAfter(otherDate.#dayjs, unit);
318
    }
319
320
    /**
321
     * Check if this date is the same as another date.
322
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
323
     * @param {'day'|'month'|'year'} [unit='day']
324
     * @returns {boolean}
325
     */
326
    isSame(other, unit = "day") {
327
        const otherDate = BookingDate.from(other);
328
        if (!otherDate) return false;
329
        return this.#dayjs.isSame(otherDate.#dayjs, unit);
330
    }
331
332
    /**
333
     * Check if this date is the same day as another date.
334
     * Convenience method for isSame(other, 'day').
335
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
336
     * @returns {boolean}
337
     */
338
    isSameDay(other) {
339
        return this.isSame(other, "day");
340
    }
341
342
    /**
343
     * Check if this date is the same or before another date.
344
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
345
     * @param {'day'|'month'|'year'} [unit='day']
346
     * @returns {boolean}
347
     */
348
    isSameOrBefore(other, unit = "day") {
349
        const otherDate = BookingDate.from(other);
350
        if (!otherDate) return false;
351
        return this.#dayjs.isSameOrBefore(otherDate.#dayjs, unit);
352
    }
353
354
    /**
355
     * Check if this date is the same or after another date.
356
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
357
     * @param {'day'|'month'|'year'} [unit='day']
358
     * @returns {boolean}
359
     */
360
    isSameOrAfter(other, unit = "day") {
361
        const otherDate = BookingDate.from(other);
362
        if (!otherDate) return false;
363
        return this.#dayjs.isSameOrAfter(otherDate.#dayjs, unit);
364
    }
365
366
    /**
367
     * Check if this date is between two other dates (inclusive).
368
     * Implemented using isSameOrAfter/isSameOrBefore to avoid requiring isBetween plugin.
369
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} start
370
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} end
371
     * @param {'day'|'month'|'year'} [unit='day']
372
     * @returns {boolean}
373
     */
374
    isBetween(start, end, unit = "day") {
375
        const startDate = BookingDate.from(start);
376
        const endDate = BookingDate.from(end);
377
        if (!startDate || !endDate) return false;
378
        return (
379
            this.isSameOrAfter(startDate, unit) &&
380
            this.isSameOrBefore(endDate, unit)
381
        );
382
    }
383
384
    /**
385
     * Get the difference between this date and another.
386
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
387
     * @param {'day'|'month'|'year'|'hour'|'minute'|'second'} [unit='day']
388
     * @returns {number}
389
     */
390
    diff(other, unit = "day") {
391
        const otherDate = BookingDate.from(other);
392
        if (!otherDate) return 0;
393
        return this.#dayjs.diff(otherDate.#dayjs, unit);
394
    }
395
396
    /**
397
     * Compare two dates, returning -1, 0, or 1.
398
     * Useful for array sorting.
399
     * @param {string|Date|import('dayjs').Dayjs|BookingDate} other
400
     * @returns {-1|0|1}
401
     */
402
    compare(other) {
403
        const otherDate = BookingDate.from(other);
404
        if (!otherDate) return 1;
405
        if (this.isBefore(otherDate)) return -1;
406
        if (this.isAfter(otherDate)) return 1;
407
        return 0;
408
    }
409
410
    // =========================================================================
411
    // Component Accessors
412
    // =========================================================================
413
414
    /**
415
     * Get the year.
416
     * @returns {number}
417
     */
418
    year() {
419
        return this.#dayjs.year();
420
    }
421
422
    /**
423
     * Get the month (0-11).
424
     * @returns {number}
425
     */
426
    month() {
427
        return this.#dayjs.month();
428
    }
429
430
    /**
431
     * Get the day of month (1-31).
432
     * @returns {number}
433
     */
434
    date() {
435
        return this.#dayjs.date();
436
    }
437
438
    /**
439
     * Get the day of week (0-6, Sunday is 0).
440
     * @returns {number}
441
     */
442
    day() {
443
        return this.#dayjs.day();
444
    }
445
446
    // =========================================================================
447
    // Utility Methods
448
    // =========================================================================
449
450
    /**
451
     * Check if the date is valid.
452
     * @returns {boolean}
453
     */
454
    isValid() {
455
        return this.#dayjs.isValid();
456
    }
457
458
    /**
459
     * Clone this BookingDate.
460
     * @returns {BookingDate}
461
     */
462
    clone() {
463
        return new BookingDate(this.#dayjs.clone());
464
    }
465
466
    /**
467
     * Check if this date is today.
468
     * @returns {boolean}
469
     */
470
    isToday() {
471
        return this.isSameDay(BookingDate.today());
472
    }
473
474
    /**
475
     * Check if this date is in the past (before today).
476
     * @returns {boolean}
477
     */
478
    isPast() {
479
        return this.isBefore(BookingDate.today());
480
    }
481
482
    /**
483
     * Check if this date is in the future (after today).
484
     * @returns {boolean}
485
     */
486
    isFuture() {
487
        return this.isAfter(BookingDate.today());
488
    }
489
}
490
491
// =========================================================================
492
// Standalone Helper Functions
493
// =========================================================================
494
495
/**
496
 * Convert an array of ISO strings to Date objects.
497
 * @param {Array<string>} values
498
 * @returns {Date[]}
499
 */
500
export function isoArrayToDates(values) {
501
    return BookingDate.toDateArray(BookingDate.fromISOArray(values));
502
}
503
504
/**
505
 * Convert any date input to ISO string.
506
 * @param {string|Date|import('dayjs').Dayjs} input
507
 * @returns {string}
508
 */
509
export function toISO(input) {
510
    const bd = BookingDate.from(input);
511
    return bd ? bd.toISO() : "";
512
}
513
514
/**
515
 * Convert any date input to dayjs instance.
516
 * @param {string|Date|import('dayjs').Dayjs} input
517
 * @returns {import('dayjs').Dayjs}
518
 */
519
export function toDayjs(input) {
520
    const bd = BookingDate.from(input);
521
    return bd ? bd.toDayjs() : dayjs();
522
}
523
524
/**
525
 * Get start-of-day timestamp for any date input.
526
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
527
 * @returns {number}
528
 */
529
export function startOfDayTs(input) {
530
    const bd = BookingDate.from(input);
531
    return bd ? bd.valueOf() : 0;
532
}
533
534
/**
535
 * Format any date input as YYYY-MM-DD.
536
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
537
 * @returns {string}
538
 */
539
export function formatYMD(input) {
540
    const bd = BookingDate.from(input);
541
    return bd ? bd.toAPIFormat() : "";
542
}
543
544
/**
545
 * Add days to any date input.
546
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
547
 * @param {number} days
548
 * @returns {import('dayjs').Dayjs}
549
 */
550
export function addDays(input, days) {
551
    const bd = BookingDate.from(input);
552
    return bd ? bd.addDays(days).toDayjs() : dayjs();
553
}
554
555
/**
556
 * Subtract days from any date input.
557
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
558
 * @param {number} days
559
 * @returns {import('dayjs').Dayjs}
560
 */
561
export function subDays(input, days) {
562
    const bd = BookingDate.from(input);
563
    return bd ? bd.subtractDays(days).toDayjs() : dayjs();
564
}
565
566
/**
567
 * Add months to any date input.
568
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
569
 * @param {number} months
570
 * @returns {import('dayjs').Dayjs}
571
 */
572
export function addMonths(input, months) {
573
    const bd = BookingDate.from(input);
574
    return bd ? bd.addMonths(months).toDayjs() : dayjs();
575
}
576
577
/**
578
 * Get end-of-day timestamp for any date input.
579
 * @param {string|Date|import('dayjs').Dayjs|BookingDate} input
580
 * @returns {number}
581
 */
582
export function endOfDayTs(input) {
583
    const bd = BookingDate.from(input, { preserveTime: true });
584
    return bd ? bd.toDayjs().endOf("day").valueOf() : 0;
585
}
586
587
// Default export for convenience
588
export default BookingDate;
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/interval-tree.mjs (+575 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 { BookingDate } from "../BookingDate.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 = BookingDate.from(startDate).valueOf(); // Convert to timestamp for fast comparison
31
        /** @type {number} Unix timestamp for end date */
32
        this.end = BookingDate.from(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 : BookingDate.from(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 = BookingDate.from(this.start).format("YYYY-MM-DD");
74
        const endStr = BookingDate.from(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
        this.root = this._insertNode(this.root, interval);
234
        this.size++;
235
    }
236
237
    /**
238
     * Recursive helper for insertion with balancing
239
     * @param {IntervalTreeNode} node
240
     * @param {BookingInterval} interval
241
     * @returns {IntervalTreeNode}
242
     */
243
    _insertNode(node, interval) {
244
        // Standard BST insertion based on start time
245
        if (!node) {
246
            return new IntervalTreeNode(interval);
247
        }
248
249
        if (interval.start < node.interval.start) {
250
            node.left = this._insertNode(node.left, interval);
251
        } else {
252
            node.right = this._insertNode(node.right, interval);
253
        }
254
255
        // Update height and max
256
        this._updateHeight(node);
257
        node.updateMax();
258
259
        // Balance the tree
260
        const balance = this._getBalance(node);
261
262
        // Left heavy
263
        if (balance > 1) {
264
            if (interval.start < node.left.interval.start) {
265
                return this._rotateRight(node);
266
            } else {
267
                node.left = this._rotateLeft(node.left);
268
                return this._rotateRight(node);
269
            }
270
        }
271
272
        // Right heavy
273
        if (balance < -1) {
274
            if (interval.start > node.right.interval.start) {
275
                return this._rotateLeft(node);
276
            } else {
277
                node.right = this._rotateRight(node.right);
278
                return this._rotateLeft(node);
279
            }
280
        }
281
282
        return node;
283
    }
284
285
    /**
286
     * Query all intervals that contain a specific date
287
     * @param {Date|import("dayjs").Dayjs|number} date - The date to query (Date object, dayjs instance, or timestamp)
288
     * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items)
289
     * @returns {BookingInterval[]} Array of intervals that contain the date
290
     */
291
    query(date, itemId = null) {
292
        const timestamp =
293
            typeof date === "number" ? date : BookingDate.from(date).valueOf();
294
        const results = [];
295
        this._queryNode(this.root, timestamp, results, itemId);
296
        return results;
297
    }
298
299
    /**
300
     * Recursive helper for point queries
301
     * @param {IntervalTreeNode} node
302
     * @param {number} timestamp
303
     * @param {BookingInterval[]} results
304
     * @param {string} itemId
305
     */
306
    _queryNode(node, timestamp, results, itemId) {
307
        if (!node) return;
308
309
        // Check if current interval contains the timestamp
310
        if (node.interval.containsDate(timestamp)) {
311
            if (!itemId || node.interval.itemId === itemId) {
312
                results.push(node.interval);
313
            }
314
        }
315
316
        // Recurse left if possible
317
        if (node.left && node.left.max >= timestamp) {
318
            this._queryNode(node.left, timestamp, results, itemId);
319
        }
320
321
        // Recurse right if possible
322
        if (node.right && node.interval.start <= timestamp) {
323
            this._queryNode(node.right, timestamp, results, itemId);
324
        }
325
    }
326
327
    /**
328
     * Query all intervals that overlap with a date range
329
     * @param {Date|import("dayjs").Dayjs|number} startDate - Start of the range to query
330
     * @param {Date|import("dayjs").Dayjs|number} endDate - End of the range to query
331
     * @param {string|null} [itemId=null] - Optional: filter by item ID (null for all items)
332
     * @returns {BookingInterval[]} Array of intervals that overlap with the range
333
     */
334
    queryRange(startDate, endDate, itemId = null) {
335
        const startTimestamp =
336
            typeof startDate === "number"
337
                ? startDate
338
                : BookingDate.from(startDate).valueOf();
339
        const endTimestamp =
340
            typeof endDate === "number" ? endDate : BookingDate.from(endDate).valueOf();
341
342
        const queryInterval = new BookingInterval(
343
            new Date(startTimestamp),
344
            new Date(endTimestamp),
345
            "",
346
            "query"
347
        );
348
        const results = [];
349
        this._queryRangeNode(this.root, queryInterval, results, itemId);
350
        return results;
351
    }
352
353
    /**
354
     * Recursive helper for range queries
355
     * @param {IntervalTreeNode} node
356
     * @param {BookingInterval} queryInterval
357
     * @param {BookingInterval[]} results
358
     * @param {string} itemId
359
     */
360
    _queryRangeNode(node, queryInterval, results, itemId) {
361
        if (!node) return;
362
363
        // Check if current interval overlaps with query
364
        if (node.interval.overlaps(queryInterval)) {
365
            if (!itemId || node.interval.itemId === itemId) {
366
                results.push(node.interval);
367
            }
368
        }
369
370
        // Recurse left if possible
371
        if (node.left && node.left.max >= queryInterval.start) {
372
            this._queryRangeNode(node.left, queryInterval, results, itemId);
373
        }
374
375
        // Recurse right if possible
376
        if (node.right && node.interval.start <= queryInterval.end) {
377
            this._queryRangeNode(node.right, queryInterval, results, itemId);
378
        }
379
    }
380
381
    /**
382
     * Remove all intervals matching a predicate
383
     * @param {Function} predicate - Function that returns true for intervals to remove
384
     * @returns {number} Number of intervals removed
385
     */
386
    removeWhere(predicate) {
387
        const toRemove = [];
388
        this._collectNodes(this.root, node => {
389
            if (predicate(node.interval)) {
390
                toRemove.push(node.interval);
391
            }
392
        });
393
394
        toRemove.forEach(interval => {
395
            this.root = this._removeNode(this.root, interval);
396
            this.size--;
397
        });
398
399
        return toRemove.length;
400
    }
401
402
    /**
403
     * Helper to collect all nodes
404
     * @param {IntervalTreeNode} node
405
     * @param {Function} callback
406
     */
407
    _collectNodes(node, callback) {
408
        if (!node) return;
409
        this._collectNodes(node.left, callback);
410
        callback(node);
411
        this._collectNodes(node.right, callback);
412
    }
413
414
    /**
415
     * Remove a specific interval (simplified - doesn't rebalance)
416
     * @param {IntervalTreeNode} node
417
     * @param {BookingInterval} interval
418
     * @returns {IntervalTreeNode}
419
     */
420
    _removeNode(node, interval) {
421
        if (!node) return null;
422
423
        if (interval.start < node.interval.start) {
424
            node.left = this._removeNode(node.left, interval);
425
        } else if (interval.start > node.interval.start) {
426
            node.right = this._removeNode(node.right, interval);
427
        } else if (
428
            interval.end === node.interval.end &&
429
            interval.itemId === node.interval.itemId &&
430
            interval.type === node.interval.type
431
        ) {
432
            // Found the node to remove
433
            if (!node.left) return node.right;
434
            if (!node.right) return node.left;
435
436
            // Node has two children - get inorder successor
437
            let minNode = node.right;
438
            while (minNode.left) {
439
                minNode = minNode.left;
440
            }
441
442
            node.interval = minNode.interval;
443
            node.right = this._removeNode(node.right, minNode.interval);
444
        } else {
445
            // Continue searching
446
            node.right = this._removeNode(node.right, interval);
447
        }
448
449
        if (node) {
450
            this._updateHeight(node);
451
            node.updateMax();
452
        }
453
454
        return node;
455
    }
456
457
    /**
458
     * Clear all intervals
459
     */
460
    clear() {
461
        this.root = null;
462
        this.size = 0;
463
    }
464
465
    /**
466
     * Get statistics about the tree for debugging and monitoring
467
     * @returns {Object} Statistics object
468
     */
469
    getStats() {
470
        return {
471
            size: this.size,
472
            height: this._getHeight(this.root),
473
            balanced: Math.abs(this._getBalance(this.root)) <= 1,
474
        };
475
    }
476
}
477
478
/**
479
 * Build an interval tree from bookings and checkouts data
480
 * @param {Array<Object>} bookings - Array of booking objects
481
 * @param {Array<Object>} checkouts - Array of checkout objects
482
 * @param {Object} circulationRules - Circulation rules configuration
483
 * @returns {IntervalTree} Populated interval tree ready for queries
484
 */
485
export function buildIntervalTree(bookings, checkouts, circulationRules) {
486
    const tree = new IntervalTree();
487
488
    // Add booking intervals with lead/trail times
489
    bookings.forEach(booking => {
490
        try {
491
            // Skip invalid bookings
492
            if (!booking.item_id || !booking.start_date || !booking.end_date) {
493
                logger.warn("Skipping invalid booking", { booking });
494
                return;
495
            }
496
497
            // Core booking interval
498
            const bookingInterval = new BookingInterval(
499
                booking.start_date,
500
                booking.end_date,
501
                booking.item_id,
502
                "booking",
503
                { booking_id: booking.booking_id, patron_id: booking.patron_id }
504
            );
505
            tree.insert(bookingInterval);
506
507
            // Lead time interval
508
            const leadDays = circulationRules?.bookings_lead_period || 0;
509
            if (leadDays > 0) {
510
                const bookingStart = BookingDate.from(booking.start_date);
511
                const leadStart = bookingStart.subtractDays(leadDays);
512
                const leadEnd = bookingStart.subtractDays(1);
513
                const leadInterval = new BookingInterval(
514
                    leadStart.toDate(),
515
                    leadEnd.toDate(),
516
                    booking.item_id,
517
                    "lead",
518
                    { booking_id: booking.booking_id, days: leadDays }
519
                );
520
                tree.insert(leadInterval);
521
            }
522
523
            // Trail time interval
524
            const trailDays = circulationRules?.bookings_trail_period || 0;
525
            if (trailDays > 0) {
526
                const bookingEnd = BookingDate.from(booking.end_date);
527
                const trailStart = bookingEnd.addDays(1);
528
                const trailEnd = bookingEnd.addDays(trailDays);
529
                const trailInterval = new BookingInterval(
530
                    trailStart.toDate(),
531
                    trailEnd.toDate(),
532
                    booking.item_id,
533
                    "trail",
534
                    { booking_id: booking.booking_id, days: trailDays }
535
                );
536
                tree.insert(trailInterval);
537
            }
538
        } catch (error) {
539
            logger.error("Failed to insert booking interval", {
540
                booking,
541
                error,
542
            });
543
        }
544
    });
545
546
    // Add checkout intervals
547
    checkouts.forEach(checkout => {
548
        try {
549
            if (
550
                checkout.item_id &&
551
                checkout.checkout_date &&
552
                checkout.due_date
553
            ) {
554
                const checkoutInterval = new BookingInterval(
555
                    checkout.checkout_date,
556
                    checkout.due_date,
557
                    checkout.item_id,
558
                    "checkout",
559
                    {
560
                        checkout_id: checkout.issue_id,
561
                        patron_id: checkout.patron_id,
562
                    }
563
                );
564
                tree.insert(checkoutInterval);
565
            }
566
        } catch (error) {
567
            logger.error("Failed to insert checkout interval", {
568
                checkout,
569
                error,
570
            });
571
        }
572
    });
573
574
    return tree;
575
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/algorithms/sweep-line-processor.mjs (+329 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 { BookingDate, startOfDayTs, endOfDayTs } from "../BookingDate.mjs";
9
import { MAX_SEARCH_DAYS } from "../constants.mjs";
10
11
/**
12
 * Event types for the sweep line algorithm
13
 * @readonly
14
 * @enum {string}
15
 * @exported for testability
16
 */
17
export 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
        const startTimestamp = startOfDayTs(viewStart);
70
        const endTimestamp = endOfDayTs(viewEnd);
71
72
        this.events = [];
73
        intervals.forEach(interval => {
74
            if (
75
                interval.end < startTimestamp ||
76
                interval.start > endTimestamp
77
            ) {
78
                return;
79
            }
80
81
            const clampedStart = Math.max(interval.start, startTimestamp);
82
            const nextDayStart = BookingDate.from(interval.end).addDays(1).valueOf();
83
            const endRemovalTs = Math.min(nextDayStart, endTimestamp + 1);
84
85
            this.events.push(new SweepEvent(clampedStart, "start", interval));
86
            this.events.push(new SweepEvent(endRemovalTs, "end", interval));
87
        });
88
89
        this.events.sort((a, b) => {
90
            if (a.timestamp !== b.timestamp) {
91
                return a.timestamp - b.timestamp;
92
            }
93
            return a.type === "start" ? -1 : 1;
94
        });
95
96
        /** @type {Record<string, Record<string, Set<string>>>} */
97
        const unavailableByDate = {};
98
        const activeIntervals = new Map(); // itemId -> Set of intervals
99
100
        allItemIds.forEach(itemId => {
101
            activeIntervals.set(itemId, new Set());
102
        });
103
104
        let eventIndex = 0;
105
106
        for (
107
            let date = BookingDate.from(viewStart).toDayjs();
108
            date.isSameOrBefore(viewEnd, "day");
109
            date = date.add(1, "day")
110
        ) {
111
            const dateKey = date.format("YYYY-MM-DD");
112
            const dateStart = date.valueOf();
113
            const dateEnd = date.endOf("day").valueOf();
114
115
            while (
116
                eventIndex < this.events.length &&
117
                this.events[eventIndex].timestamp <= dateEnd
118
            ) {
119
                const event = this.events[eventIndex];
120
                const itemId = event.interval.itemId;
121
122
                if (event.type === EventType.START) {
123
                    if (!activeIntervals.has(itemId)) {
124
                        activeIntervals.set(itemId, new Set());
125
                    }
126
                    activeIntervals.get(itemId).add(event.interval);
127
                } else {
128
                    if (activeIntervals.has(itemId)) {
129
                        activeIntervals.get(itemId).delete(event.interval);
130
                    }
131
                }
132
133
                eventIndex++;
134
            }
135
136
            unavailableByDate[dateKey] = {};
137
138
            activeIntervals.forEach((intervals, itemId) => {
139
                const reasons = new Set();
140
141
                intervals.forEach(interval => {
142
                    if (
143
                        interval.start <= dateEnd &&
144
                        interval.end >= dateStart
145
                    ) {
146
                        if (interval.type === "booking") {
147
                            reasons.add("core");
148
                        } else if (interval.type === "checkout") {
149
                            reasons.add("checkout");
150
                        } else {
151
                            reasons.add(interval.type); // 'lead' or 'trail'
152
                        }
153
                    }
154
                });
155
156
                if (reasons.size > 0) {
157
                    unavailableByDate[dateKey][itemId] = reasons;
158
                }
159
            });
160
        }
161
162
        return unavailableByDate;
163
    }
164
165
    /**
166
     * Process intervals and return aggregated statistics
167
     * @param {Array} intervals
168
     * @param {Date|import("dayjs").Dayjs} viewStart
169
     * @param {Date|import("dayjs").Dayjs} viewEnd
170
     * @returns {Object} Statistics about the date range
171
     */
172
    getDateRangeStatistics(intervals, viewStart, viewEnd) {
173
        const stats = {
174
            totalDays: 0,
175
            daysWithBookings: 0,
176
            daysWithCheckouts: 0,
177
            fullyBookedDays: 0,
178
            peakBookingCount: 0,
179
            peakDate: null,
180
            itemUtilization: new Map(),
181
        };
182
183
        const startDate = BookingDate.from(viewStart).toDayjs();
184
        const endDate = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day");
185
186
        stats.totalDays = endDate.diff(startDate, "day") + 1;
187
188
        for (
189
            let date = startDate;
190
            date.isSameOrBefore(endDate, "day");
191
            date = date.add(1, "day")
192
        ) {
193
            const dayStart = date.valueOf();
194
            const dayEnd = date.endOf("day").valueOf();
195
196
            let bookingCount = 0;
197
            let checkoutCount = 0;
198
            const itemsInUse = new Set();
199
200
            intervals.forEach(interval => {
201
                if (interval.start <= dayEnd && interval.end >= dayStart) {
202
                    if (interval.type === "booking") {
203
                        bookingCount++;
204
                        itemsInUse.add(interval.itemId);
205
                    } else if (interval.type === "checkout") {
206
                        checkoutCount++;
207
                        itemsInUse.add(interval.itemId);
208
                    }
209
                }
210
            });
211
212
            if (bookingCount > 0) stats.daysWithBookings++;
213
            if (checkoutCount > 0) stats.daysWithCheckouts++;
214
215
            const totalCount = bookingCount + checkoutCount;
216
            if (totalCount > stats.peakBookingCount) {
217
                stats.peakBookingCount = totalCount;
218
                stats.peakDate = date.format("YYYY-MM-DD");
219
            }
220
221
            itemsInUse.forEach(itemId => {
222
                if (!stats.itemUtilization.has(itemId)) {
223
                    stats.itemUtilization.set(itemId, 0);
224
                }
225
                stats.itemUtilization.set(
226
                    itemId,
227
                    stats.itemUtilization.get(itemId) + 1
228
                );
229
            });
230
        }
231
232
        return stats;
233
    }
234
235
    /**
236
     * Find the next available date for a specific item
237
     * @param {Array} intervals
238
     * @param {string} itemId
239
     * @param {Date|import('dayjs').Dayjs} startDate
240
     * @param {number} maxDaysToSearch
241
     * @returns {Date|null}
242
     */
243
    findNextAvailableDate(
244
        intervals,
245
        itemId,
246
        startDate,
247
        maxDaysToSearch = MAX_SEARCH_DAYS
248
    ) {
249
        const start = BookingDate.from(startDate).toDayjs();
250
        const itemIntervals = intervals.filter(
251
            interval => interval.itemId === itemId
252
        );
253
254
        itemIntervals.sort((a, b) => a.start - b.start);
255
256
        for (let i = 0; i < maxDaysToSearch; i++) {
257
            const checkDate = start.add(i, "day");
258
            const dateStart = checkDate.valueOf();
259
            const dateEnd = checkDate.endOf("day").valueOf();
260
261
            const isAvailable = !itemIntervals.some(
262
                interval =>
263
                    interval.start <= dateEnd && interval.end >= dateStart
264
            );
265
266
            if (isAvailable) {
267
                return checkDate.toDate();
268
            }
269
        }
270
271
        return null;
272
    }
273
274
    /**
275
     * Find gaps (available periods) for an item
276
     * @param {Array} intervals
277
     * @param {string} itemId
278
     * @param {Date|import('dayjs').Dayjs} viewStart
279
     * @param {Date|import('dayjs').Dayjs} viewEnd
280
     * @param {number} minGapDays - Minimum gap size to report
281
     * @returns {Array<{start: Date, end: Date, days: number}>}
282
     */
283
    findAvailableGaps(intervals, itemId, viewStart, viewEnd, minGapDays = 1) {
284
        const gaps = [];
285
        const itemIntervals = intervals
286
            .filter(interval => interval.itemId === itemId)
287
            .sort((a, b) => a.start - b.start);
288
289
        const rangeStart = BookingDate.from(viewStart).valueOf();
290
        const rangeEnd = BookingDate.from(viewEnd, { preserveTime: true }).toDayjs().endOf("day").valueOf();
291
292
        let lastEnd = rangeStart;
293
294
        itemIntervals.forEach(interval => {
295
            if (interval.end < rangeStart || interval.start > rangeEnd) {
296
                return;
297
            }
298
299
            const gapStart = Math.max(lastEnd, rangeStart);
300
            const gapEnd = Math.min(interval.start, rangeEnd);
301
302
            if (gapEnd > gapStart) {
303
                const gapDays = BookingDate.from(gapEnd).diff(BookingDate.from(gapStart), "day");
304
                if (gapDays >= minGapDays) {
305
                    gaps.push({
306
                        start: new Date(gapStart),
307
                        end: new Date(gapEnd - 1), // End of previous day
308
                        days: gapDays,
309
                    });
310
                }
311
            }
312
313
            lastEnd = Math.max(lastEnd, interval.end + 1); // Start of next day
314
        });
315
316
        if (lastEnd < rangeEnd) {
317
            const gapDays = BookingDate.from(rangeEnd).diff(BookingDate.from(lastEnd), "day");
318
            if (gapDays >= minGapDays) {
319
                gaps.push({
320
                    start: new Date(lastEnd),
321
                    end: new Date(rangeEnd),
322
                    days: gapDays,
323
                });
324
            }
325
        }
326
327
        return gaps;
328
    }
329
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability.mjs (+37 lines)
Line 0 Link Here
1
/**
2
 * Core availability calculation logic for the booking system.
3
 *
4
 * This module has been split into focused sub-modules for better maintainability.
5
 * All exports are re-exported from ./availability/index.mjs for backward compatibility.
6
 *
7
 * Sub-modules:
8
 * - ./availability/rules.mjs - Circulation rules utilities
9
 * - ./availability/period-validators.mjs - Period validation utilities
10
 * - ./availability/unavailable-map.mjs - Unavailable date map builders
11
 * - ./availability/disabled-dates.mjs - Main calculateDisabledDates function
12
 * - ./availability/date-change.mjs - Date change handlers
13
 *
14
 * @module availability
15
 */
16
17
export {
18
    extractBookingConfiguration,
19
    deriveEffectiveRules,
20
    toEffectiveRules,
21
    calculateMaxBookingPeriod,
22
    calculateMaxEndDate,
23
    validateBookingPeriod,
24
    validateLeadPeriodOptimized,
25
    validateTrailPeriodOptimized,
26
    validateRangeOverlapForEndDate,
27
    getAvailableItemsForPeriod,
28
    buildUnavailableByDateMap,
29
    addHolidayMarkers,
30
    addLeadPeriodFromTodayMarkers,
31
    addTheoreticalLeadPeriodMarkers,
32
    calculateDisabledDates,
33
    buildIntervalTree,
34
    findFirstBlockingDate,
35
    calculateAvailabilityData,
36
    handleBookingDateChange,
37
} from "./availability/index.mjs";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/date-change.mjs (+302 lines)
Line 0 Link Here
1
/**
2
 * Date change handlers for booking availability.
3
 * @module availability/date-change
4
 */
5
6
import { BookingDate, isoArrayToDates } from "../BookingDate.mjs";
7
import { createConstraintStrategy } from "../strategies.mjs";
8
import { buildIntervalTree } from "../algorithms/interval-tree.mjs";
9
import { CONSTRAINT_MODE_END_DATE_ONLY } from "../constants.mjs";
10
import { calculateDisabledDates } from "./disabled-dates.mjs";
11
import { deriveEffectiveRules, calculateMaxBookingPeriod } from "./rules.mjs";
12
import { calculateMaxEndDate } from "./period-validators.mjs";
13
import {
14
    queryRangeAndResolve,
15
    createConflictContext,
16
} from "../conflict-resolution.mjs";
17
18
const $__ = globalThis.$__ || (str => str);
19
20
/**
21
 * Find the first date where a booking range [startDate, candidateEnd] would conflict
22
 * with all items. This mirrors the backend's range-overlap detection logic.
23
 *
24
 * The backend considers two bookings overlapping if:
25
 * - existing.start_date BETWEEN new.start AND new.end (inclusive)
26
 * - existing.end_date BETWEEN new.start AND new.end (inclusive)
27
 * - existing completely wraps new
28
 *
29
 * @param {Date|import('dayjs').Dayjs} startDate - Start of the booking range
30
 * @param {Date|import('dayjs').Dayjs} endDate - Maximum end date to check
31
 * @param {Array} bookings - Array of booking objects
32
 * @param {Array} checkouts - Array of checkout objects
33
 * @param {Array} bookableItems - Array of bookable items
34
 * @param {string|number|null} selectedItem - Selected item ID or null for "any item"
35
 * @param {string|number|null} editBookingId - Booking ID being edited (to exclude)
36
 * @param {Object} circulationRules - Circulation rules for lead/trail periods
37
 * @returns {{ firstBlockingDate: Date|null, reason: string|null }} The first date that would cause all items to conflict
38
 */
39
export function findFirstBlockingDate(
40
    startDate,
41
    endDate,
42
    bookings,
43
    checkouts,
44
    bookableItems,
45
    selectedItem,
46
    editBookingId,
47
    circulationRules = {}
48
) {
49
    if (!bookableItems || bookableItems.length === 0) {
50
        return {
51
            firstBlockingDate: BookingDate.from(startDate).toDate(),
52
            reason: "no_items",
53
        };
54
    }
55
56
    const intervalTree = buildIntervalTree(
57
        bookings,
58
        checkouts,
59
        circulationRules
60
    );
61
    const allItemIds = bookableItems.map(i => String(i.item_id));
62
    const ctx = createConflictContext(selectedItem, editBookingId, allItemIds);
63
64
    const start = BookingDate.from(startDate).toDayjs();
65
    const end = BookingDate.from(endDate).toDayjs();
66
67
    // For each potential end date, check if the range [start, candidateEnd] would have at least one available item
68
    for (
69
        let candidateEnd = start.add(1, "day");
70
        candidateEnd.isSameOrBefore(end, "day");
71
        candidateEnd = candidateEnd.add(1, "day")
72
    ) {
73
        const result = queryRangeAndResolve(
74
            intervalTree,
75
            start.valueOf(),
76
            candidateEnd.valueOf(),
77
            ctx
78
        );
79
80
        if (result.hasConflict) {
81
            return {
82
                firstBlockingDate: candidateEnd.toDate(),
83
                reason: ctx.selectedItem
84
                    ? result.conflicts[0]?.type || "conflict"
85
                    : "all_items_have_conflicts",
86
            };
87
        }
88
    }
89
90
    return { firstBlockingDate: null, reason: null };
91
}
92
93
/**
94
 * Convenience wrapper to calculate availability (disable fn + map) given a dateRange.
95
 * Accepts ISO strings for dateRange and returns the result of calculateDisabledDates.
96
 * @returns {import('../../../types/bookings').AvailabilityResult}
97
 */
98
export function calculateAvailabilityData(dateRange, storeData, options = {}) {
99
    const {
100
        bookings,
101
        checkouts,
102
        bookableItems,
103
        circulationRules,
104
        bookingItemId,
105
        bookingId,
106
    } = storeData;
107
108
    if (!bookings || !checkouts || !bookableItems) {
109
        return { disable: () => false, unavailableByDate: {} };
110
    }
111
112
    const baseRules = circulationRules?.[0] || {};
113
    const maxBookingPeriod = calculateMaxBookingPeriod(
114
        circulationRules,
115
        options.dateRangeConstraint,
116
        options.customDateRangeFormula
117
    );
118
    const effectiveRules = deriveEffectiveRules(baseRules, {
119
        dateRangeConstraint: options.dateRangeConstraint,
120
        maxBookingPeriod,
121
    });
122
123
    let selectedDatesArray = [];
124
    if (Array.isArray(dateRange)) {
125
        selectedDatesArray = isoArrayToDates(dateRange);
126
    } else if (typeof dateRange === "string") {
127
        throw new TypeError(
128
            "calculateAvailabilityData expects an array of ISO/date values for dateRange"
129
        );
130
    }
131
132
    return calculateDisabledDates(
133
        bookings,
134
        checkouts,
135
        bookableItems,
136
        bookingItemId,
137
        bookingId,
138
        selectedDatesArray,
139
        effectiveRules
140
    );
141
}
142
143
/**
144
 * Pure function to handle Flatpickr's onChange event logic for booking period selection.
145
 * Determines the valid end date range, applies circulation rules, and returns validation info.
146
 *
147
 * @param {Array} selectedDates - Array of currently selected dates ([start], or [start, end])
148
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, etc.)
149
 * @param {Array} bookings - Array of bookings
150
 * @param {Array} checkouts - Array of checkouts
151
 * @param {Array} bookableItems - Array of all bookable items
152
 * @param {number|string|null} selectedItem - The currently selected item
153
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
154
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
155
 * @returns {Object} - { valid: boolean, errors: Array<string>, newMaxEndDate: Date|null, newMinEndDate: Date|null }
156
 */
157
export function handleBookingDateChange(
158
    selectedDates,
159
    circulationRules,
160
    bookings,
161
    checkouts,
162
    bookableItems,
163
    selectedItem,
164
    editBookingId,
165
    todayArg = undefined,
166
    options = {}
167
) {
168
    const dayjsStart = selectedDates[0]
169
        ? BookingDate.from(selectedDates[0]).toDayjs()
170
        : null;
171
    const dayjsEnd = selectedDates[1]
172
        ? BookingDate.from(selectedDates[1], { preserveTime: true }).toDayjs().endOf("day")
173
        : null;
174
    const errors = [];
175
    let valid = true;
176
    let newMaxEndDate = null;
177
    let newMinEndDate = null;
178
179
    // Validate: ensure start date is present
180
    if (!dayjsStart) {
181
        errors.push(String($__("Start date is required.")));
182
        valid = false;
183
    } else {
184
        // Apply circulation rules: leadDays, maxPeriod (in days)
185
        const leadDays = circulationRules?.leadDays || 0;
186
        const maxPeriod =
187
            Number(circulationRules?.maxPeriod) ||
188
            Number(circulationRules?.issuelength) ||
189
            0;
190
191
        // Calculate min end date; max end date only when constrained
192
        newMinEndDate = dayjsStart.add(1, "day").startOf("day");
193
        if (maxPeriod > 0) {
194
            newMaxEndDate = calculateMaxEndDate(dayjsStart, maxPeriod).startOf(
195
                "day"
196
            );
197
        } else {
198
            newMaxEndDate = null;
199
        }
200
201
        // Validate: start must be after today + leadDays
202
        const today = todayArg
203
            ? BookingDate.from(todayArg).toDayjs()
204
            : BookingDate.today().toDayjs();
205
        if (dayjsStart.isBefore(today.add(leadDays, "day"))) {
206
            errors.push(
207
                String($__("Start date is too soon (lead time required)"))
208
            );
209
            valid = false;
210
        }
211
212
        // Validate: end must not be before start (only if end date exists)
213
        if (dayjsEnd && dayjsEnd.isBefore(dayjsStart)) {
214
            errors.push(String($__("End date is before start date")));
215
            valid = false;
216
        }
217
218
        // Validate: period must not exceed maxPeriod unless overridden in end_date_only by backend due date
219
        // Start date counts as day 1, so valid range is: end <= start + (maxPeriod - 1)
220
        // Equivalently: diff < maxPeriod, or diff >= maxPeriod means invalid
221
        if (dayjsEnd) {
222
            const isEndDateOnly =
223
                circulationRules?.booking_constraint_mode ===
224
                CONSTRAINT_MODE_END_DATE_ONLY;
225
            const dueStr = circulationRules?.calculated_due_date;
226
            const hasBackendDue = Boolean(dueStr);
227
            if (!isEndDateOnly || !hasBackendDue) {
228
                if (
229
                    maxPeriod > 0 &&
230
                    dayjsEnd.diff(dayjsStart, "day") >= maxPeriod
231
                ) {
232
                    errors.push(
233
                        String($__("Booking period exceeds maximum allowed"))
234
                    );
235
                    valid = false;
236
                }
237
            }
238
        }
239
240
        // Strategy-specific enforcement for end date (e.g., end_date_only)
241
        const strategy = createConstraintStrategy(
242
            circulationRules?.booking_constraint_mode
243
        );
244
        const enforcement = strategy.enforceEndDateSelection(
245
            dayjsStart,
246
            dayjsEnd,
247
            circulationRules
248
        );
249
        if (!enforcement.ok) {
250
            errors.push(
251
                String(
252
                    $__(
253
                        "In end date only mode, you can only select the calculated end date"
254
                    )
255
                )
256
            );
257
            valid = false;
258
        }
259
260
        // Validate: check for booking/checkouts overlap using calculateDisabledDates
261
        // This check is only meaningful if we have at least a start date,
262
        // and if an end date is also present, we check the whole range.
263
        // If only start date, effectively checks that single day.
264
        const endDateForLoop = dayjsEnd || dayjsStart; // If no end date, loop for the start date only
265
266
        const disableFnResults = calculateDisabledDates(
267
            bookings,
268
            checkouts,
269
            bookableItems,
270
            selectedItem,
271
            editBookingId,
272
            selectedDates,
273
            circulationRules,
274
            todayArg,
275
            options
276
        );
277
        for (
278
            let d = dayjsStart.clone();
279
            d.isSameOrBefore(endDateForLoop, "day");
280
            d = d.add(1, "day")
281
        ) {
282
            if (disableFnResults.disable(d.toDate())) {
283
                errors.push(
284
                    String(
285
                        $__("Date %s is unavailable.").format(
286
                            d.format("YYYY-MM-DD")
287
                        )
288
                    )
289
                );
290
                valid = false;
291
                break;
292
            }
293
        }
294
    }
295
296
    return {
297
        valid,
298
        errors,
299
        newMaxEndDate: newMaxEndDate ? newMaxEndDate.toDate() : null,
300
        newMinEndDate: newMinEndDate ? newMinEndDate.toDate() : null,
301
    };
302
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/disabled-dates.mjs (+367 lines)
Line 0 Link Here
1
/**
2
 * Disabled dates calculation for booking availability.
3
 * Contains the main calculateDisabledDates function and createDisableFunction.
4
 * @module availability/disabled-dates
5
 */
6
7
import { BookingDate } from "../BookingDate.mjs";
8
import { createConstraintStrategy } from "../strategies.mjs";
9
import { buildIntervalTree } from "../algorithms/interval-tree.mjs";
10
import {
11
    CONSTRAINT_MODE_END_DATE_ONLY,
12
    CONSTRAINT_MODE_NORMAL,
13
} from "../constants.mjs";
14
import { extractBookingConfiguration } from "./rules.mjs";
15
import {
16
    calculateMaxEndDate,
17
    validateLeadPeriodOptimized,
18
    validateTrailPeriodOptimized,
19
    validateRangeOverlapForEndDate,
20
} from "./period-validators.mjs";
21
import {
22
    buildUnavailableByDateMap,
23
    addHolidayMarkers,
24
    addLeadPeriodFromTodayMarkers,
25
    addTheoreticalLeadPeriodMarkers,
26
} from "./unavailable-map.mjs";
27
import {
28
    queryPointAndResolve,
29
    createConflictContext,
30
} from "../conflict-resolution.mjs";
31
32
/**
33
 * Creates the main disable function that determines if a date should be disabled
34
 * @param {Object} intervalTree - Interval tree for conflict checking
35
 * @param {Object} config - Configuration object from extractBookingConfiguration
36
 * @param {Array<import('../../../types/bookings').BookableItem>} bookableItems - Array of bookable items
37
 * @param {string|null} selectedItem - Selected item ID or null
38
 * @param {number|null} editBookingId - Booking ID being edited
39
 * @param {Array<Date>} selectedDates - Currently selected dates
40
 * @param {Array<string>} holidays - Array of holiday dates in YYYY-MM-DD format
41
 * @returns {(date: Date) => boolean} Disable function for Flatpickr
42
 */
43
function createDisableFunction(
44
    intervalTree,
45
    config,
46
    bookableItems,
47
    selectedItem,
48
    editBookingId,
49
    selectedDates,
50
    holidays = []
51
) {
52
    const {
53
        today,
54
        leadDays,
55
        trailDays,
56
        maxPeriod,
57
        isEndDateOnly,
58
        calculatedDueDate,
59
    } = config;
60
    const allItemIds = bookableItems.map(i => String(i.item_id));
61
    const strategy = createConstraintStrategy(
62
        isEndDateOnly ? CONSTRAINT_MODE_END_DATE_ONLY : CONSTRAINT_MODE_NORMAL
63
    );
64
    const conflictCtx = createConflictContext(
65
        selectedItem,
66
        editBookingId,
67
        allItemIds
68
    );
69
70
    const holidaySet = new Set(holidays);
71
72
    return date => {
73
        const dayjs_date = BookingDate.from(date).toDayjs();
74
75
        if (dayjs_date.isBefore(today, "day")) return true;
76
77
        // Only disable holidays when selecting START date - for END date selection,
78
        // we use click prevention instead so Flatpickr's range validation passes
79
        if (
80
            holidaySet.size > 0 &&
81
            (!selectedDates || selectedDates.length === 0)
82
        ) {
83
            const dateKey = dayjs_date.format("YYYY-MM-DD");
84
            if (holidaySet.has(dateKey)) {
85
                return true;
86
            }
87
        }
88
89
        // Guard clause: No bookable items available
90
        if (!bookableItems || bookableItems.length === 0) {
91
            return true;
92
        }
93
94
        // Mode-specific start date validation
95
        if (
96
            strategy.validateStartDateSelection(
97
                dayjs_date,
98
                {
99
                    today,
100
                    leadDays,
101
                    trailDays,
102
                    maxPeriod,
103
                    isEndDateOnly,
104
                    calculatedDueDate,
105
                },
106
                intervalTree,
107
                selectedItem,
108
                editBookingId,
109
                allItemIds,
110
                selectedDates
111
            )
112
        ) {
113
            return true;
114
        }
115
116
        // Mode-specific intermediate date handling
117
        const intermediateResult = strategy.handleIntermediateDate(
118
            dayjs_date,
119
            selectedDates,
120
            {
121
                today,
122
                leadDays,
123
                trailDays,
124
                maxPeriod,
125
                isEndDateOnly,
126
                calculatedDueDate,
127
            }
128
        );
129
        if (intermediateResult === true) {
130
            return true;
131
        }
132
133
        // Guard clause: Standard point-in-time availability check using conflict resolution
134
        const pointResult = queryPointAndResolve(
135
            intervalTree,
136
            dayjs_date.valueOf(),
137
            conflictCtx
138
        );
139
140
        if (pointResult.hasConflict) {
141
            return true;
142
        }
143
144
        // Lead/trail period validation using optimized queries
145
        if (!selectedDates || selectedDates.length === 0) {
146
            // Potential start date - check lead period
147
            if (leadDays > 0) {
148
                // Enforce minimum advance booking: start date must be >= today + leadDays
149
                // This applies even for the first booking (no existing bookings to conflict with)
150
                const minStartDate = today.add(leadDays, "day");
151
                if (dayjs_date.isBefore(minStartDate, "day")) {
152
                    return true;
153
                }
154
            }
155
156
            // Optimized lead period validation using range queries
157
            // This checks for conflicts with existing bookings in the lead window
158
            if (
159
                validateLeadPeriodOptimized(
160
                    dayjs_date,
161
                    leadDays,
162
                    intervalTree,
163
                    selectedItem,
164
                    editBookingId,
165
                    allItemIds
166
                )
167
            ) {
168
                return true;
169
            }
170
        } else if (
171
            selectedDates[0] &&
172
            dayjs_date.isSameOrBefore(
173
                BookingDate.from(selectedDates[0]).toDayjs(),
174
                "day"
175
            )
176
        ) {
177
            // Date is before or same as selected start - still needs validation as potential start
178
            // This handles the case where user clicks a date before their current selection
179
            // (which in Flatpickr range mode would reset and start a new range)
180
            if (leadDays > 0) {
181
                const minStartDate = today.add(leadDays, "day");
182
                if (dayjs_date.isBefore(minStartDate, "day")) {
183
                    return true;
184
                }
185
            }
186
187
            if (
188
                validateLeadPeriodOptimized(
189
                    dayjs_date,
190
                    leadDays,
191
                    intervalTree,
192
                    selectedItem,
193
                    editBookingId,
194
                    allItemIds
195
                )
196
            ) {
197
                return true;
198
            }
199
        } else if (
200
            selectedDates[0] &&
201
            dayjs_date.isAfter(BookingDate.from(selectedDates[0]).toDayjs(), "day")
202
        ) {
203
            // Potential end date - any date after the start could become the new end
204
            // This applies whether we have an end date selected or not
205
            const start = BookingDate.from(selectedDates[0]).toDayjs();
206
207
            // Basic end date validations
208
            if (dayjs_date.isBefore(start, "day")) return true;
209
210
            // Calculate the target end date for fixed-duration modes
211
            let calculatedEnd = null;
212
            if (
213
                config.calculatedDueDate &&
214
                !config.calculatedDueDate.isBefore(start, "day")
215
            ) {
216
                calculatedEnd = config.calculatedDueDate;
217
            } else if (maxPeriod > 0) {
218
                calculatedEnd = calculateMaxEndDate(start, maxPeriod);
219
            }
220
221
            // In end_date_only mode, the target end date is ALWAYS selectable
222
            // Skip all other validation for it (trail period, range overlap, etc.)
223
            if (isEndDateOnly && calculatedEnd && dayjs_date.isSame(calculatedEnd, "day")) {
224
                return false;
225
            }
226
227
            // Use backend-calculated due date when available (respects useDaysMode/calendar)
228
            // This correctly calculates the Nth opening day from start, skipping closed days
229
            // Fall back to simple maxPeriod arithmetic only if no calculated date
230
            if (calculatedEnd) {
231
                if (dayjs_date.isAfter(calculatedEnd, "day"))
232
                    return true;
233
            }
234
235
            // Optimized trail period validation using range queries
236
            if (
237
                validateTrailPeriodOptimized(
238
                    dayjs_date,
239
                    trailDays,
240
                    intervalTree,
241
                    selectedItem,
242
                    editBookingId,
243
                    allItemIds
244
                )
245
            ) {
246
                return true;
247
            }
248
249
            // In end_date_only mode, intermediate dates are not disabled here
250
            // (they use click prevention instead for better UX)
251
            if (isEndDateOnly) {
252
                // Intermediate date - don't disable, click prevention handles it
253
                return false;
254
            }
255
256
            // Check if the booking range [start, end] would conflict with all items
257
            // This mirrors the backend's BETWEEN-based overlap detection
258
            if (
259
                validateRangeOverlapForEndDate(
260
                    start,
261
                    dayjs_date,
262
                    intervalTree,
263
                    selectedItem,
264
                    editBookingId,
265
                    allItemIds
266
                )
267
            ) {
268
                return true;
269
            }
270
        }
271
272
        return false;
273
    };
274
}
275
276
/**
277
 * Pure function for Flatpickr's `disable` option.
278
 * Disables dates that overlap with existing bookings or checkouts for the selected item, or when not enough items are available.
279
 * Also handles end_date_only constraint mode by disabling intermediate dates.
280
 *
281
 * @param {Array} bookings - Array of booking objects ({ booking_id, item_id, start_date, end_date })
282
 * @param {Array} checkouts - Array of checkout objects ({ item_id, due_date, ... })
283
 * @param {Array} bookableItems - Array of all bookable item objects (must have item_id)
284
 * @param {number|string|null} selectedItem - The currently selected item (item_id or null for 'any')
285
 * @param {number|string|null} editBookingId - The booking_id being edited (if any)
286
 * @param {Array} selectedDates - Array of currently selected dates in Flatpickr (can be empty, or [start], or [start, end])
287
 * @param {Object} circulationRules - Circulation rules object (leadDays, trailDays, maxPeriod, booking_constraint_mode, etc.)
288
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
289
 * @param {Object} options - Additional options for optimization
290
 * @param {Array<string>} [options.holidays] - Array of holiday dates in YYYY-MM-DD format
291
 * @returns {import('../../../types/bookings').AvailabilityResult}
292
 */
293
export function calculateDisabledDates(
294
    bookings,
295
    checkouts,
296
    bookableItems,
297
    selectedItem,
298
    editBookingId,
299
    selectedDates = [],
300
    circulationRules = {},
301
    todayArg = undefined,
302
    options = {}
303
) {
304
    const holidays = options.holidays || [];
305
    const normalizedSelectedItem =
306
        selectedItem != null ? String(selectedItem) : null;
307
308
    // Build IntervalTree with all booking/checkout data
309
    const intervalTree = buildIntervalTree(
310
        bookings,
311
        checkouts,
312
        circulationRules
313
    );
314
315
    // Extract and validate configuration
316
    const config = extractBookingConfiguration(circulationRules, todayArg);
317
    const allItemIds = bookableItems.map(i => String(i.item_id));
318
319
    // Create optimized disable function using extracted helper
320
    const normalizedEditBookingId =
321
        editBookingId != null ? Number(editBookingId) : null;
322
    const disableFunction = createDisableFunction(
323
        intervalTree,
324
        config,
325
        bookableItems,
326
        normalizedSelectedItem,
327
        normalizedEditBookingId,
328
        selectedDates,
329
        holidays
330
    );
331
332
    // Build unavailableByDate for backward compatibility and markers
333
    // Pass options for performance optimization
334
335
    const unavailableByDate = buildUnavailableByDateMap(
336
        intervalTree,
337
        config.today,
338
        allItemIds,
339
        normalizedEditBookingId,
340
        options
341
    );
342
343
    addHolidayMarkers(unavailableByDate, holidays, allItemIds);
344
345
    addLeadPeriodFromTodayMarkers(
346
        unavailableByDate,
347
        config.today,
348
        config.leadDays,
349
        allItemIds
350
    );
351
352
    addTheoreticalLeadPeriodMarkers(
353
        unavailableByDate,
354
        intervalTree,
355
        config.today,
356
        config.leadDays,
357
        normalizedEditBookingId
358
    );
359
360
    return {
361
        disable: disableFunction,
362
        unavailableByDate: unavailableByDate,
363
    };
364
}
365
366
// Re-export buildIntervalTree for consumers that need direct access
367
export { buildIntervalTree };
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/index.mjs (+42 lines)
Line 0 Link Here
1
/**
2
 * Availability module - re-exports for backward compatibility.
3
 *
4
 * This module consolidates all availability-related exports from the split modules.
5
 * Import from this index for the same API as the original availability.mjs.
6
 *
7
 * @module availability
8
 */
9
10
export {
11
    extractBookingConfiguration,
12
    deriveEffectiveRules,
13
    toEffectiveRules,
14
    calculateMaxBookingPeriod,
15
} from "./rules.mjs";
16
17
export {
18
    calculateMaxEndDate,
19
    validateBookingPeriod,
20
    validateLeadPeriodOptimized,
21
    validateTrailPeriodOptimized,
22
    validateRangeOverlapForEndDate,
23
    getAvailableItemsForPeriod,
24
} from "./period-validators.mjs";
25
26
export {
27
    buildUnavailableByDateMap,
28
    addHolidayMarkers,
29
    addLeadPeriodFromTodayMarkers,
30
    addTheoreticalLeadPeriodMarkers,
31
} from "./unavailable-map.mjs";
32
33
export {
34
    calculateDisabledDates,
35
    buildIntervalTree,
36
} from "./disabled-dates.mjs";
37
38
export {
39
    findFirstBlockingDate,
40
    calculateAvailabilityData,
41
    handleBookingDateChange,
42
} from "./date-change.mjs";
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/period-validators.mjs (+192 lines)
Line 0 Link Here
1
/**
2
 * Period validation utilities for booking availability.
3
 * @module availability/period-validators
4
 */
5
6
import { BookingDate } from "../BookingDate.mjs";
7
import {
8
    queryRangeAndResolve,
9
    createConflictContext,
10
} from "../conflict-resolution.mjs";
11
import { buildIntervalTree } from "../algorithms/interval-tree.mjs";
12
13
/**
14
 * Calculates the maximum end date for a booking period based on start date and maximum period.
15
 * Follows Koha circulation behavior where the start date counts as day 1.
16
 *
17
 * Example: issuelength=30, start=Feb 20 → end=March 21 (day 1 through day 30)
18
 *
19
 * @param {Date|string|import('dayjs').Dayjs} startDate - The start date
20
 * @param {number} maxPeriod - Maximum period in days (from circulation rules)
21
 * @returns {import('dayjs').Dayjs} The maximum end date
22
 */
23
export function calculateMaxEndDate(startDate, maxPeriod) {
24
    if (!maxPeriod || maxPeriod <= 0) {
25
        throw new Error("maxPeriod must be a positive number");
26
    }
27
28
    const start = BookingDate.from(startDate).toDayjs();
29
    // Start date is day 1, so end = start + (maxPeriod - 1)
30
    return start.add(maxPeriod - 1, "day");
31
}
32
33
/**
34
 * Validates if an end date exceeds the maximum allowed period
35
 *
36
 * @param {Date|string|import('dayjs').Dayjs} startDate - The start date
37
 * @param {Date|string|import('dayjs').Dayjs} endDate - The proposed end date
38
 * @param {number} maxPeriod - Maximum period in days
39
 * @returns {boolean} True if end date is valid (within limits)
40
 */
41
export function validateBookingPeriod(startDate, endDate, maxPeriod) {
42
    if (!maxPeriod || maxPeriod <= 0) {
43
        return true; // No limit
44
    }
45
46
    const maxEndDate = calculateMaxEndDate(startDate, maxPeriod);
47
    const proposedEnd = BookingDate.from(endDate).toDayjs();
48
49
    return !proposedEnd.isAfter(maxEndDate, "day");
50
}
51
52
/**
53
 * Optimized lead period validation using range queries instead of individual point queries
54
 * @param {import("dayjs").Dayjs} startDate - Potential start date to validate
55
 * @param {number} leadDays - Number of lead period days to check
56
 * @param {Object} intervalTree - Interval tree for conflict checking
57
 * @param {string|null} selectedItem - Selected item ID or null
58
 * @param {number|null} editBookingId - Booking ID being edited
59
 * @param {Array} allItemIds - All available item IDs
60
 * @returns {boolean} True if start date should be blocked due to lead period conflicts
61
 */
62
export function validateLeadPeriodOptimized(
63
    startDate,
64
    leadDays,
65
    intervalTree,
66
    selectedItem,
67
    editBookingId,
68
    allItemIds
69
) {
70
    if (leadDays <= 0) return false;
71
72
    const leadStart = startDate.subtract(leadDays, "day");
73
    const leadEnd = startDate.subtract(1, "day");
74
75
    const ctx = createConflictContext(selectedItem, editBookingId, allItemIds);
76
    const result = queryRangeAndResolve(
77
        intervalTree,
78
        leadStart.valueOf(),
79
        leadEnd.valueOf(),
80
        ctx
81
    );
82
83
    return result.hasConflict;
84
}
85
86
/**
87
 * Optimized trail period validation using range queries instead of individual point queries
88
 * @param {import("dayjs").Dayjs} endDate - Potential end date to validate
89
 * @param {number} trailDays - Number of trail period days to check
90
 * @param {Object} intervalTree - Interval tree for conflict checking
91
 * @param {string|null} selectedItem - Selected item ID or null
92
 * @param {number|null} editBookingId - Booking ID being edited
93
 * @param {Array} allItemIds - All available item IDs
94
 * @returns {boolean} True if end date should be blocked due to trail period conflicts
95
 */
96
export function validateTrailPeriodOptimized(
97
    endDate,
98
    trailDays,
99
    intervalTree,
100
    selectedItem,
101
    editBookingId,
102
    allItemIds
103
) {
104
    if (trailDays <= 0) return false;
105
106
    const trailStart = endDate.add(1, "day");
107
    const trailEnd = endDate.add(trailDays, "day");
108
109
    const ctx = createConflictContext(selectedItem, editBookingId, allItemIds);
110
    const result = queryRangeAndResolve(
111
        intervalTree,
112
        trailStart.valueOf(),
113
        trailEnd.valueOf(),
114
        ctx
115
    );
116
117
    return result.hasConflict;
118
}
119
120
/**
121
 * Validate if a booking range [startDate, endDate] would conflict with all available items.
122
 * This mirrors the backend's BETWEEN-based overlap detection.
123
 *
124
 * @param {import("dayjs").Dayjs} startDate - Start date of the potential booking
125
 * @param {import("dayjs").Dayjs} endDate - End date to validate
126
 * @param {Object} intervalTree - Interval tree for conflict checking
127
 * @param {string|null} selectedItem - Selected item ID or null for "any item"
128
 * @param {number|null} editBookingId - Booking ID being edited (to exclude)
129
 * @param {Array} allItemIds - All available item IDs
130
 * @returns {boolean} True if end date should be blocked due to range overlap conflicts
131
 */
132
export function validateRangeOverlapForEndDate(
133
    startDate,
134
    endDate,
135
    intervalTree,
136
    selectedItem,
137
    editBookingId,
138
    allItemIds
139
) {
140
    const ctx = createConflictContext(selectedItem, editBookingId, allItemIds);
141
    const result = queryRangeAndResolve(
142
        intervalTree,
143
        startDate.valueOf(),
144
        endDate.valueOf(),
145
        ctx
146
    );
147
148
    return result.hasConflict;
149
}
150
151
/**
152
 * Get items available for the entire specified period (no booking/checkout conflicts).
153
 * Used for "any item" mode payload construction at submission time to implement
154
 * upstream's 3-way logic: 0 available → error, 1 → auto-assign, 2+ → send itemtype_id.
155
 *
156
 * @param {string} startDate - ISO start date
157
 * @param {string} endDate - ISO end date
158
 * @param {Array} bookableItems - Constrained bookable items to check
159
 * @param {Array} bookings - All bookings for the biblio
160
 * @param {Array} checkouts - All checkouts for the biblio
161
 * @param {Object} circulationRules - Circulation rules (for interval tree construction)
162
 * @param {number|string|null} editBookingId - Booking being edited (excluded from conflicts)
163
 * @returns {Array} Items available for the entire period
164
 */
165
export function getAvailableItemsForPeriod(
166
    startDate,
167
    endDate,
168
    bookableItems,
169
    bookings,
170
    checkouts,
171
    circulationRules,
172
    editBookingId
173
) {
174
    const tree = buildIntervalTree(bookings, checkouts, circulationRules);
175
    const startTs = BookingDate.from(startDate).toDayjs().startOf("day").valueOf();
176
    const endTs = BookingDate.from(endDate).toDayjs().startOf("day").valueOf();
177
    const normalizedEditId =
178
        editBookingId != null ? Number(editBookingId) : null;
179
180
    return bookableItems.filter(item => {
181
        const itemId = String(item.item_id);
182
        const conflicts = tree
183
            .queryRange(startTs, endTs, itemId)
184
            .filter(
185
                c =>
186
                    !normalizedEditId ||
187
                    c.metadata.booking_id != normalizedEditId
188
            )
189
            .filter(c => c.type === "booking" || c.type === "checkout");
190
        return conflicts.length === 0;
191
    });
192
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/rules.mjs (+109 lines)
Line 0 Link Here
1
/**
2
 * Circulation rules utilities for booking availability.
3
 * @module availability/rules
4
 */
5
6
import { BookingDate } from "../BookingDate.mjs";
7
import { CONSTRAINT_MODE_END_DATE_ONLY } from "../constants.mjs";
8
9
/**
10
 * Extracts and validates configuration from circulation rules
11
 * @param {Object} circulationRules - Raw circulation rules object
12
 * @param {Date|import('dayjs').Dayjs} todayArg - Optional today value for deterministic tests
13
 * @returns {Object} Normalized configuration object
14
 */
15
export function extractBookingConfiguration(circulationRules, todayArg) {
16
    const today = todayArg
17
        ? BookingDate.from(todayArg).toDayjs()
18
        : BookingDate.today().toDayjs();
19
    const leadDays = Number(circulationRules?.bookings_lead_period) || 0;
20
    const trailDays = Number(circulationRules?.bookings_trail_period) || 0;
21
    // In unconstrained mode, do not enforce a default max period
22
    const maxPeriod =
23
        Number(circulationRules?.maxPeriod) ||
24
        Number(circulationRules?.issuelength) ||
25
        0;
26
    const isEndDateOnly =
27
        circulationRules?.booking_constraint_mode ===
28
        CONSTRAINT_MODE_END_DATE_ONLY;
29
    const calculatedDueDate = circulationRules?.calculated_due_date
30
        ? BookingDate.from(circulationRules.calculated_due_date).toDayjs()
31
        : null;
32
    const calculatedPeriodDays = Number(
33
        circulationRules?.calculated_period_days
34
    )
35
        ? Number(circulationRules.calculated_period_days)
36
        : null;
37
38
    return {
39
        today,
40
        leadDays,
41
        trailDays,
42
        maxPeriod,
43
        isEndDateOnly,
44
        calculatedDueDate,
45
        calculatedPeriodDays,
46
    };
47
}
48
49
/**
50
 * Derive effective circulation rules with constraint options applied.
51
 * - Applies maxPeriod only for constraining modes
52
 * - Strips caps for unconstrained mode
53
 * @param {import('../../../types/bookings').CirculationRule} [baseRules={}]
54
 * @param {import('../../../types/bookings').ConstraintOptions} [constraintOptions={}]
55
 * @returns {import('../../../types/bookings').CirculationRule}
56
 */
57
export function deriveEffectiveRules(baseRules = {}, constraintOptions = {}) {
58
    const effectiveRules = { ...baseRules };
59
    const mode = constraintOptions.dateRangeConstraint;
60
    if (mode === "issuelength" || mode === "issuelength_with_renewals") {
61
        if (constraintOptions.maxBookingPeriod) {
62
            effectiveRules.maxPeriod = constraintOptions.maxBookingPeriod;
63
        }
64
    } else {
65
        if ("maxPeriod" in effectiveRules) delete effectiveRules.maxPeriod;
66
        if ("issuelength" in effectiveRules) delete effectiveRules.issuelength;
67
    }
68
    return effectiveRules;
69
}
70
71
/**
72
 * Convenience: take full circulationRules array and constraint options,
73
 * return effective rules applying maxPeriod logic.
74
 * @param {import('../../../types/bookings').CirculationRule[]} circulationRules
75
 * @param {import('../../../types/bookings').ConstraintOptions} [constraintOptions={}]
76
 * @returns {import('../../../types/bookings').CirculationRule}
77
 */
78
export function toEffectiveRules(circulationRules, constraintOptions = {}) {
79
    const baseRules = circulationRules?.[0] || {};
80
    return deriveEffectiveRules(baseRules, constraintOptions);
81
}
82
83
/**
84
 * Calculate maximum booking period from circulation rules and constraint mode.
85
 */
86
export function calculateMaxBookingPeriod(
87
    circulationRules,
88
    dateRangeConstraint,
89
    customDateRangeFormula = null
90
) {
91
    if (!dateRangeConstraint) return null;
92
    const rules = circulationRules?.[0];
93
    if (!rules) return null;
94
    const issuelength = parseInt(rules.issuelength) || 0;
95
    switch (dateRangeConstraint) {
96
        case "issuelength":
97
            return issuelength;
98
        case "issuelength_with_renewals":
99
            const renewalperiod = parseInt(rules.renewalperiod) || 0;
100
            const renewalsallowed = parseInt(rules.renewalsallowed) || 0;
101
            return issuelength + renewalperiod * renewalsallowed;
102
        case "custom":
103
            return typeof customDateRangeFormula === "function"
104
                ? customDateRangeFormula(rules)
105
                : null;
106
        default:
107
            return null;
108
    }
109
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/availability/unavailable-map.mjs (+238 lines)
Line 0 Link Here
1
/**
2
 * Unavailable date map builders for booking availability.
3
 * @module availability/unavailable-map
4
 */
5
6
import { BookingDate, addDays, subDays } from "../BookingDate.mjs";
7
import { SweepLineProcessor } from "../algorithms/sweep-line-processor.mjs";
8
import {
9
    CALENDAR_BUFFER_DAYS,
10
    DEFAULT_LOOKAHEAD_DAYS,
11
    MAX_SEARCH_DAYS,
12
} from "../constants.mjs";
13
14
/**
15
 * Build unavailableByDate map from IntervalTree for backward compatibility
16
 * @param {import('../algorithms/interval-tree.mjs').IntervalTree} intervalTree - The interval tree containing all bookings/checkouts
17
 * @param {import('dayjs').Dayjs} today - Today's date for range calculation
18
 * @param {Array} allItemIds - Array of all item IDs
19
 * @param {number|string|null} editBookingId - The booking_id being edited (exclude from results)
20
 * @param {import('../../../types/bookings').ConstraintOptions} options - Additional options for optimization
21
 * @returns {import('../../../types/bookings').UnavailableByDate}
22
 */
23
export function buildUnavailableByDateMap(
24
    intervalTree,
25
    today,
26
    allItemIds,
27
    editBookingId,
28
    options = {}
29
) {
30
    /** @type {import('../../../types/bookings').UnavailableByDate} */
31
    const unavailableByDate = {};
32
33
    if (!intervalTree || intervalTree.size === 0) {
34
        return unavailableByDate;
35
    }
36
37
    let startDate, endDate;
38
    if (
39
        options.onDemand &&
40
        options.visibleStartDate &&
41
        options.visibleEndDate
42
    ) {
43
        startDate = subDays(options.visibleStartDate, CALENDAR_BUFFER_DAYS);
44
        endDate = addDays(options.visibleEndDate, CALENDAR_BUFFER_DAYS);
45
    } else {
46
        startDate = subDays(today, CALENDAR_BUFFER_DAYS);
47
        endDate = addDays(today, DEFAULT_LOOKAHEAD_DAYS);
48
    }
49
50
    const rangeIntervals = intervalTree.queryRange(
51
        startDate.toDate(),
52
        endDate.toDate()
53
    );
54
55
    // Exclude the booking being edited
56
    const relevantIntervals = editBookingId
57
        ? rangeIntervals.filter(
58
              interval => interval.metadata?.booking_id != editBookingId
59
          )
60
        : rangeIntervals;
61
62
    const processor = new SweepLineProcessor();
63
    const sweptMap = processor.processIntervals(
64
        relevantIntervals,
65
        startDate.toDate(),
66
        endDate.toDate(),
67
        allItemIds
68
    );
69
70
    // Ensure the map contains all dates in the requested range, even if empty
71
    const filledMap = sweptMap && typeof sweptMap === "object" ? sweptMap : {};
72
    for (
73
        let d = startDate.clone();
74
        d.isSameOrBefore(endDate, "day");
75
        d = d.add(1, "day")
76
    ) {
77
        const key = d.format("YYYY-MM-DD");
78
        if (!filledMap[key]) filledMap[key] = {};
79
    }
80
81
    // Normalize reasons for legacy API expectations: convert 'core' -> 'booking'
82
    Object.keys(filledMap).forEach(dateKey => {
83
        const byItem = filledMap[dateKey];
84
        Object.keys(byItem).forEach(itemId => {
85
            const original = byItem[itemId];
86
            if (original && original instanceof Set) {
87
                const mapped = new Set();
88
                original.forEach(reason => {
89
                    mapped.add(reason === "core" ? "booking" : reason);
90
                });
91
                byItem[itemId] = mapped;
92
            }
93
        });
94
    });
95
96
    return filledMap;
97
}
98
99
/**
100
 * Add holiday markers for dates that are library closed days.
101
 * This ensures visual highlighting for closed days in the calendar.
102
 *
103
 * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify
104
 * @param {Array<string>} holidays - Array of holiday dates in YYYY-MM-DD format
105
 * @param {Array<string>} allItemIds - Array of all item IDs
106
 */
107
export function addHolidayMarkers(unavailableByDate, holidays, allItemIds) {
108
    if (
109
        !holidays ||
110
        holidays.length === 0 ||
111
        !allItemIds ||
112
        allItemIds.length === 0
113
    ) {
114
        return;
115
    }
116
117
    holidays.forEach(dateStr => {
118
        if (!unavailableByDate[dateStr]) {
119
            unavailableByDate[dateStr] = {};
120
        }
121
122
        allItemIds.forEach(itemId => {
123
            if (!unavailableByDate[dateStr][itemId]) {
124
                unavailableByDate[dateStr][itemId] = new Set();
125
            }
126
            unavailableByDate[dateStr][itemId].add("holiday");
127
        });
128
    });
129
}
130
131
/**
132
 * Add lead period markers for dates within the lead period from today.
133
 * This ensures visual highlighting for the first booking on a given bibliographic record.
134
 *
135
 * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify
136
 * @param {import('dayjs').Dayjs} today - Today's date
137
 * @param {number} leadDays - Number of lead period days
138
 * @param {Array<string>} allItemIds - Array of all item IDs
139
 */
140
export function addLeadPeriodFromTodayMarkers(
141
    unavailableByDate,
142
    today,
143
    leadDays,
144
    allItemIds
145
) {
146
    if (leadDays <= 0 || !allItemIds || allItemIds.length === 0) return;
147
148
    // Add "lead" markers for dates from today to today + leadDays - 1
149
    for (let i = 0; i < leadDays; i++) {
150
        const date = today.add(i, "day");
151
        const key = date.format("YYYY-MM-DD");
152
153
        if (!unavailableByDate[key]) {
154
            unavailableByDate[key] = {};
155
        }
156
157
        // Add lead reason for items not already blocked by a stronger reason
158
        allItemIds.forEach(itemId => {
159
            const existing = unavailableByDate[key][itemId];
160
            if (existing && (existing.has("booking") || existing.has("checkout"))) {
161
                return; // already unavailable for a stronger reason
162
            }
163
            if (!existing) {
164
                unavailableByDate[key][itemId] = new Set();
165
            }
166
            unavailableByDate[key][itemId].add("lead");
167
        });
168
    }
169
}
170
171
/**
172
 * Add lead period markers for dates after trail periods where the lead period
173
 * would overlap with the trail. This ensures visual highlighting for the
174
 * theoretical lead period after existing bookings.
175
 *
176
 * @param {import('../../../types/bookings').UnavailableByDate} unavailableByDate - The map to modify
177
 * @param {import('../algorithms/interval-tree.mjs').IntervalTree} intervalTree - The interval tree with all bookings/checkouts
178
 * @param {import('dayjs').Dayjs} today - Today's date
179
 * @param {number} leadDays - Number of lead period days
180
 * @param {number|null} editBookingId - Booking ID being edited (to exclude)
181
 */
182
export function addTheoreticalLeadPeriodMarkers(
183
    unavailableByDate,
184
    intervalTree,
185
    today,
186
    leadDays,
187
    editBookingId
188
) {
189
    if (leadDays <= 0 || !intervalTree || intervalTree.size === 0) return;
190
191
    // Query all trail intervals in a reasonable range
192
    const rangeStart = today.subtract(CALENDAR_BUFFER_DAYS, "day");
193
    const rangeEnd = today.add(MAX_SEARCH_DAYS, "day");
194
195
    const allIntervals = intervalTree.queryRange(
196
        rangeStart.valueOf(),
197
        rangeEnd.valueOf()
198
    );
199
200
    // Filter to get only trail intervals
201
    const trailIntervals = allIntervals.filter(
202
        interval =>
203
            interval.type === "trail" &&
204
            (!editBookingId || interval.metadata?.booking_id != editBookingId)
205
    );
206
207
    trailIntervals.forEach(trailInterval => {
208
        // Trail interval ends at trailInterval.end
209
        // After the trail, the next booking's lead period must not overlap
210
        // So dates from trailEnd+1 to trailEnd+leadDays are blocked due to lead requirements
211
        const trailEnd = BookingDate.from(trailInterval.end).toDayjs();
212
        const itemId = trailInterval.itemId;
213
214
        for (let i = 1; i <= leadDays; i++) {
215
            const blockedDate = trailEnd.add(i, "day");
216
            // Only mark future dates
217
            if (blockedDate.isBefore(today, "day")) continue;
218
219
            const key = blockedDate.format("YYYY-MM-DD");
220
221
            if (!unavailableByDate[key]) {
222
                unavailableByDate[key] = {};
223
            }
224
225
            const existing = unavailableByDate[key][itemId];
226
            if (existing && (existing.has("booking") || existing.has("checkout"))) {
227
                continue; // already unavailable for a stronger reason
228
            }
229
230
            if (!existing) {
231
                unavailableByDate[key][itemId] = new Set();
232
            }
233
234
            // Add "lead" reason to indicate this is blocked due to lead period
235
            unavailableByDate[key][itemId].add("lead");
236
        }
237
    });
238
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/conflict-resolution.mjs (+133 lines)
Line 0 Link Here
1
/**
2
 * Conflict resolution utilities for booking availability.
3
 *
4
 * This module centralizes the conflict detection and resolution logic that was
5
 * previously duplicated across 6 different locations in the codebase.
6
 *
7
 * @module conflict-resolution
8
 */
9
10
/**
11
 * @typedef {Object} ConflictContext
12
 * @property {string|null} selectedItem - Selected item ID or null for "any item" mode
13
 * @property {number|null} editBookingId - Booking ID being edited (excluded from conflicts)
14
 * @property {string[]} allItemIds - All available item IDs for "any item" mode resolution
15
 */
16
17
/**
18
 * @typedef {Object} ConflictResult
19
 * @property {boolean} hasConflict - Whether there is a blocking conflict
20
 * @property {Array} conflicts - The relevant conflicts (filtered by editBookingId)
21
 * @property {Set<string>} [itemsWithConflicts] - Set of item IDs that have conflicts (any item mode only)
22
 */
23
24
/**
25
 * Filter conflicts by edit booking ID and resolve based on item selection mode.
26
 *
27
 * This function encapsulates the conflict resolution logic that determines whether
28
 * a date/range should be blocked based on existing bookings and checkouts.
29
 *
30
 * Resolution modes:
31
 * - **Single item mode** (selectedItem !== null): Any conflict blocks the date
32
 * - **Any item mode** (selectedItem === null): Only block if ALL items have conflicts
33
 *
34
 * @param {Array} conflicts - Raw conflicts from interval tree query
35
 * @param {ConflictContext} ctx - Context for conflict resolution
36
 * @returns {ConflictResult} Resolution result with conflict status and details
37
 *
38
 * @example
39
 * // Single item mode
40
 * const result = resolveConflicts(conflicts, {
41
 *     selectedItem: '123',
42
 *     editBookingId: null,
43
 *     allItemIds: ['123', '456']
44
 * });
45
 * if (result.hasConflict) { // Block the date }
46
 *
47
 * @example
48
 * // Any item mode - only blocks if all items unavailable
49
 * const result = resolveConflicts(conflicts, {
50
 *     selectedItem: null,
51
 *     editBookingId: 789,  // Editing booking 789, exclude from conflicts
52
 *     allItemIds: ['123', '456', '789']
53
 * });
54
 */
55
export function resolveConflicts(conflicts, ctx) {
56
    const { selectedItem, editBookingId, allItemIds } = ctx;
57
58
    // Filter out the booking being edited
59
    const relevant = editBookingId
60
        ? conflicts.filter(c => c.metadata?.booking_id != editBookingId)
61
        : conflicts;
62
63
    if (relevant.length === 0) {
64
        return { hasConflict: false, conflicts: [] };
65
    }
66
67
    // Single item mode: any conflict blocks
68
    if (selectedItem) {
69
        return { hasConflict: true, conflicts: relevant };
70
    }
71
72
    // Any item mode: only block if ALL items have conflicts
73
    const itemsWithConflicts = new Set(relevant.map(c => String(c.itemId)));
74
    const allBlocked =
75
        allItemIds.length > 0 &&
76
        allItemIds.every(id => itemsWithConflicts.has(String(id)));
77
78
    return {
79
        hasConflict: allBlocked,
80
        conflicts: relevant,
81
        itemsWithConflicts,
82
    };
83
}
84
85
/**
86
 * Query interval tree for a point in time and resolve conflicts.
87
 *
88
 * Convenience wrapper that combines a point query with conflict resolution.
89
 *
90
 * @param {Object} intervalTree - Interval tree instance
91
 * @param {number} timestamp - Timestamp to query (milliseconds)
92
 * @param {ConflictContext} ctx - Context for conflict resolution
93
 * @returns {ConflictResult} Resolution result
94
 */
95
export function queryPointAndResolve(intervalTree, timestamp, ctx) {
96
    const conflicts = intervalTree.query(timestamp, ctx.selectedItem);
97
    return resolveConflicts(conflicts, ctx);
98
}
99
100
/**
101
 * Query interval tree for a range and resolve conflicts.
102
 *
103
 * Convenience wrapper that combines a range query with conflict resolution.
104
 *
105
 * @param {Object} intervalTree - Interval tree instance
106
 * @param {number} startTs - Start timestamp (milliseconds)
107
 * @param {number} endTs - End timestamp (milliseconds)
108
 * @param {ConflictContext} ctx - Context for conflict resolution
109
 * @returns {ConflictResult} Resolution result
110
 */
111
export function queryRangeAndResolve(intervalTree, startTs, endTs, ctx) {
112
    const conflicts = intervalTree.queryRange(startTs, endTs, ctx.selectedItem);
113
    return resolveConflicts(conflicts, ctx);
114
}
115
116
/**
117
 * Create a conflict context object from common parameters.
118
 *
119
 * Helper to construct a ConflictContext from the parameters commonly
120
 * passed around in availability checking functions.
121
 *
122
 * @param {string|number|null} selectedItem - Selected item ID or null
123
 * @param {string|number|null} editBookingId - Booking ID being edited
124
 * @param {string[]} allItemIds - All available item IDs
125
 * @returns {ConflictContext}
126
 */
127
export function createConflictContext(selectedItem, editBookingId, allItemIds) {
128
    return {
129
        selectedItem: selectedItem != null ? String(selectedItem) : null,
130
        editBookingId: editBookingId != null ? Number(editBookingId) : null,
131
        allItemIds: allItemIds.map(id => String(id)),
132
    };
133
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constants.mjs (+59 lines)
Line 0 Link Here
1
/**
2
 * Shared constants for the booking system (business logic + UI)
3
 * @module constants
4
 */
5
6
/** @constant {string} Constraint mode for end-date-only selection */
7
export const CONSTRAINT_MODE_END_DATE_ONLY = "end_date_only";
8
export const CONSTRAINT_MODE_NORMAL = "normal";
9
10
// Selection semantics (logging, diagnostics)
11
export const SELECTION_ANY_AVAILABLE = "ANY_AVAILABLE";
12
export const SELECTION_SPECIFIC_ITEM = "SPECIFIC_ITEM";
13
14
// UI class names (used across calendar/adapters/composables)
15
export const CLASS_BOOKING_CONSTRAINED_RANGE_MARKER =
16
    "booking-constrained-range-marker";
17
export const CLASS_BOOKING_DAY_HOVER_LEAD = "booking-day--hover-lead";
18
export const CLASS_BOOKING_DAY_HOVER_TRAIL = "booking-day--hover-trail";
19
export const CLASS_BOOKING_INTERMEDIATE_BLOCKED =
20
    "booking-intermediate-blocked";
21
export const CLASS_BOOKING_MARKER_COUNT = "booking-marker-count";
22
export const CLASS_BOOKING_MARKER_DOT = "booking-marker-dot";
23
export const CLASS_BOOKING_MARKER_GRID = "booking-marker-grid";
24
export const CLASS_BOOKING_MARKER_ITEM = "booking-marker-item";
25
export const CLASS_BOOKING_OVERRIDE_ALLOWED = "booking-override-allowed";
26
export const CLASS_FLATPICKR_DAY = "flatpickr-day";
27
export const CLASS_FLATPICKR_DISABLED = "flatpickr-disabled";
28
export const CLASS_FLATPICKR_NOT_ALLOWED = "notAllowed";
29
export const CLASS_BOOKING_LOAN_BOUNDARY = "booking-loan-boundary";
30
31
// Data attributes
32
export const DATA_ATTRIBUTE_BOOKING_OVERRIDE = "data-booking-override";
33
34
// Calendar range constants (days)
35
export const CALENDAR_BUFFER_DAYS = 7;
36
export const DEFAULT_LOOKAHEAD_DAYS = 90;
37
export const MAX_SEARCH_DAYS = 365;
38
export const DEFAULT_MAX_PERIOD_DAYS = 30;
39
40
// Calendar highlighting retry configuration
41
export const HIGHLIGHTING_MAX_RETRIES = 5;
42
43
// Calendar navigation delay (ms) - allows Flatpickr to settle before jumping
44
export const CALENDAR_NAVIGATION_DELAY_MS = 100;
45
46
// Debounce delays (ms)
47
export const PATRON_SEARCH_DEBOUNCE_MS = 250;
48
export const HOLIDAY_EXTENSION_DEBOUNCE_MS = 150;
49
50
// Holiday prefetch configuration
51
export const HOLIDAY_PREFETCH_THRESHOLD_DAYS = 60;
52
export const HOLIDAY_PREFETCH_MONTHS = 6;
53
54
// Marker type mapping (IntervalTree/Sweep reasons → CSS class names)
55
export const MARKER_TYPE_MAP = Object.freeze({
56
    booking: "booked",
57
    core: "booked",
58
    checkout: "checked-out",
59
});
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/constraints.mjs (+175 lines)
Line 0 Link Here
1
/**
2
 * Constraint filtering functions for the booking system.
3
 *
4
 * This module handles filtering of pickup locations, bookable items,
5
 * and item types based on selection constraints.
6
 *
7
 * @module constraints
8
 */
9
10
import { idsEqual, includesId } from "./id-utils.mjs";
11
12
/**
13
 * Helper to standardize constraint function return shape
14
 * @template T
15
 * @param {T[]} filtered - The filtered array
16
 * @param {number} total - Total count before filtering
17
 * @returns {import('../../types/bookings').ConstraintResult<T>}
18
 */
19
function buildConstraintResult(filtered, total) {
20
    const filteredOutCount = total - filtered.length;
21
    return {
22
        filtered,
23
        filteredOutCount,
24
        total,
25
        constraintApplied: filtered.length !== total,
26
    };
27
}
28
29
/**
30
 * Generic constraint application function.
31
 * Filters items using an array of predicates with AND logic.
32
 *
33
 * @template T
34
 * @param {T[]} items - Items to filter
35
 * @param {Array<(item: T) => boolean>} predicates - Filter predicates (AND logic)
36
 * @returns {import('../../types/bookings').ConstraintResult<T>}
37
 */
38
export function applyConstraints(items, predicates) {
39
    if (predicates.length === 0) {
40
        return buildConstraintResult(items, items.length);
41
    }
42
43
    const filtered = items.filter(item =>
44
        predicates.every(predicate => predicate(item))
45
    );
46
47
    return buildConstraintResult(filtered, items.length);
48
}
49
50
/**
51
 * Constrain pickup locations based on selected itemtype or item
52
 * Returns { filtered, filteredOutCount, total, constraintApplied }
53
 *
54
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
55
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
56
 * @param {string|number|null} bookingItemtypeId
57
 * @param {string|number|null} bookingItemId
58
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').PickupLocation>}
59
 */
60
export function constrainPickupLocations(
61
    pickupLocations,
62
    bookableItems,
63
    bookingItemtypeId,
64
    bookingItemId
65
) {
66
    const predicates = [];
67
68
    // When a specific item is selected, location must allow pickup of that item
69
    if (bookingItemId) {
70
        predicates.push(
71
            loc =>
72
                loc.pickup_items && includesId(loc.pickup_items, bookingItemId)
73
        );
74
    }
75
    // When an itemtype is selected, location must allow pickup of at least one item of that type
76
    else if (bookingItemtypeId) {
77
        predicates.push(
78
            loc =>
79
                loc.pickup_items &&
80
                bookableItems.some(
81
                    item =>
82
                        idsEqual(item.item_type_id, bookingItemtypeId) &&
83
                        includesId(loc.pickup_items, item.item_id)
84
                )
85
        );
86
    }
87
88
    return applyConstraints(pickupLocations, predicates);
89
}
90
91
/**
92
 * Constrain bookable items based on selected pickup location and/or itemtype
93
 * Returns { filtered, filteredOutCount, total, constraintApplied }
94
 *
95
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
96
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
97
 * @param {string|null} pickupLibraryId
98
 * @param {string|number|null} bookingItemtypeId
99
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').BookableItem>}
100
 */
101
export function constrainBookableItems(
102
    bookableItems,
103
    pickupLocations,
104
    pickupLibraryId,
105
    bookingItemtypeId
106
) {
107
    const predicates = [];
108
109
    // When a pickup location is selected, item must be pickable at that location
110
    if (pickupLibraryId) {
111
        predicates.push(item =>
112
            pickupLocations.some(
113
                loc =>
114
                    idsEqual(loc.library_id, pickupLibraryId) &&
115
                    loc.pickup_items &&
116
                    includesId(loc.pickup_items, item.item_id)
117
            )
118
        );
119
    }
120
121
    // When an itemtype is selected, item must match that type
122
    if (bookingItemtypeId) {
123
        predicates.push(item => idsEqual(item.item_type_id, bookingItemtypeId));
124
    }
125
126
    return applyConstraints(bookableItems, predicates);
127
}
128
129
/**
130
 * Constrain item types based on selected pickup location or item
131
 * Returns { filtered, filteredOutCount, total, constraintApplied }
132
 * @param {Array<import('../../types/bookings').ItemType>} itemTypes
133
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems
134
 * @param {Array<import('../../types/bookings').PickupLocation>} pickupLocations
135
 * @param {string|null} pickupLibraryId
136
 * @param {string|number|null} bookingItemId
137
 * @returns {import('../../types/bookings').ConstraintResult<import('../../types/bookings').ItemType>}
138
 */
139
export function constrainItemTypes(
140
    itemTypes,
141
    bookableItems,
142
    pickupLocations,
143
    pickupLibraryId,
144
    bookingItemId
145
) {
146
    const predicates = [];
147
148
    // When a specific item is selected, only show its itemtype
149
    if (bookingItemId) {
150
        predicates.push(type =>
151
            bookableItems.some(
152
                item =>
153
                    idsEqual(item.item_id, bookingItemId) &&
154
                    idsEqual(item.item_type_id, type.item_type_id)
155
            )
156
        );
157
    }
158
    // When a pickup location is selected, only show itemtypes that have items pickable there
159
    else if (pickupLibraryId) {
160
        predicates.push(type =>
161
            bookableItems.some(
162
                item =>
163
                    idsEqual(item.item_type_id, type.item_type_id) &&
164
                    pickupLocations.some(
165
                        loc =>
166
                            idsEqual(loc.library_id, pickupLibraryId) &&
167
                            loc.pickup_items &&
168
                            includesId(loc.pickup_items, item.item_id)
169
                    )
170
            )
171
        );
172
    }
173
174
    return applyConstraints(itemTypes, predicates);
175
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/highlighting.mjs (+80 lines)
Line 0 Link Here
1
/**
2
 * Calendar highlighting logic for the booking system.
3
 *
4
 * This module handles constraint highlighting calculations and
5
 * calendar navigation target determination.
6
 *
7
 * @module highlighting
8
 */
9
10
import { toDayjs } from "./BookingDate.mjs";
11
import { createConstraintStrategy } from "./strategies.mjs";
12
13
/**
14
 * Calculate constraint highlighting data for calendar display
15
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
16
 * @param {Object} circulationRules - Circulation rules object
17
 * @param {Object} constraintOptions - Additional constraint options
18
 * @returns {import('../../types/bookings').ConstraintHighlighting | null} Constraint highlighting
19
 */
20
export function calculateConstraintHighlighting(
21
    startDate,
22
    circulationRules,
23
    constraintOptions = {}
24
) {
25
    const strategy = createConstraintStrategy(
26
        circulationRules?.booking_constraint_mode
27
    );
28
    return strategy.calculateConstraintHighlighting(
29
        startDate,
30
        circulationRules,
31
        constraintOptions
32
    );
33
}
34
35
/**
36
 * Determine if calendar should navigate to show target end date
37
 * @param {Date|import('dayjs').Dayjs} startDate - Selected start date
38
 * @param {Date|import('dayjs').Dayjs} targetEndDate - Calculated target end date
39
 * @param {import('../../types/bookings').CalendarCurrentView} currentView - Current calendar view info
40
 * @returns {import('../../types/bookings').CalendarNavigationTarget}
41
 */
42
export function getCalendarNavigationTarget(
43
    startDate,
44
    targetEndDate,
45
    currentView = {}
46
) {
47
    const start = toDayjs(startDate);
48
    const target = toDayjs(targetEndDate);
49
50
    // Never navigate backwards if target is before the chosen start
51
    if (target.isBefore(start, "day")) {
52
        return { shouldNavigate: false };
53
    }
54
55
    // If we know the currently visible range, do not navigate when target is already visible
56
    if (currentView.visibleStartDate && currentView.visibleEndDate) {
57
        const visibleStart = toDayjs(currentView.visibleStartDate).startOf(
58
            "day"
59
        );
60
        const visibleEnd = toDayjs(currentView.visibleEndDate).endOf("day");
61
        const inView =
62
            target.isSameOrAfter(visibleStart, "day") &&
63
            target.isSameOrBefore(visibleEnd, "day");
64
        if (inView) {
65
            return { shouldNavigate: false };
66
        }
67
    }
68
69
    // Fallback: navigate when target month differs from start month
70
    if (start.month() !== target.month() || start.year() !== target.year()) {
71
        return {
72
            shouldNavigate: true,
73
            targetMonth: target.month(),
74
            targetYear: target.year(),
75
            targetDate: target.toDate(),
76
        };
77
    }
78
79
    return { shouldNavigate: false };
80
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/id-utils.mjs (+40 lines)
Line 0 Link Here
1
/**
2
 * Utilities for comparing and handling mixed string/number IDs consistently
3
 * @module id-utils
4
 */
5
6
/**
7
 * Compare two IDs for equality, handling mixed string/number types
8
 * @param {string|number|null|undefined} a - First ID to compare
9
 * @param {string|number|null|undefined} b - Second ID to compare
10
 * @returns {boolean} True if IDs are equal (after string conversion)
11
 */
12
export function idsEqual(a, b) {
13
    if (a == null || b == null) return false;
14
    return String(a) === String(b);
15
}
16
17
/**
18
 * Check if a list contains a target ID, handling mixed string/number types
19
 * @param {Array<string|number>} list - Array of IDs to search
20
 * @param {string|number} target - ID to find
21
 * @returns {boolean} True if target ID is found in the list
22
 */
23
export function includesId(list, target) {
24
    if (!Array.isArray(list)) return false;
25
    return list.some(id => idsEqual(id, target));
26
}
27
28
/**
29
 * Normalize an identifier's type to match a sample (number|string) for strict comparisons.
30
 * If sample is a number, casts value to number; otherwise casts to string.
31
 * Falls back to string when sample is null/undefined.
32
 *
33
 * @param {unknown} sample - A sample value to infer the desired type from
34
 * @param {unknown} value - The value to normalize
35
 * @returns {string|number|null}
36
 */
37
export function normalizeIdType(sample, value) {
38
    if (value == null) return null;
39
    return typeof sample === "number" ? Number(value) : String(value);
40
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/logger.mjs (+293 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
 * ## Browser vs Node.js Environment
8
 *
9
 * This module uses several browser-specific APIs that behave differently in Node.js:
10
 *
11
 * | API              | Browser                      | Node.js (test env)           |
12
 * |------------------|------------------------------|------------------------------|
13
 * | localStorage     | Persists debug settings      | Not available, uses defaults |
14
 * | console.group    | Creates collapsible groups   | Plain text output            |
15
 * | console.time     | Performance timing           | Works (Node 8+)              |
16
 * | performance.now  | High-res timing              | Works via perf_hooks         |
17
 * | window           | Global browser object        | undefined or JSDOM mock      |
18
 *
19
 * The module initializes with logging DISABLED by default. In browsers, set
20
 * `localStorage.setItem('koha.booking.debug', 'true')` or call
21
 * `window.BookingDebug.enable()` to enable.
22
 *
23
 * In Node.js test environments, a simplified BookingDebug object is attached to
24
 * globalThis.window if JSDOM creates one.
25
 */
26
27
class BookingLogger {
28
    constructor(module) {
29
        this.module = module;
30
        this.enabled = false;
31
        // Don't log anything by default unless explicitly enabled
32
        this.enabledLevels = new Set();
33
        // Track active timers and groups to prevent console errors
34
        this._activeTimers = new Set();
35
        this._activeGroups = [];
36
        // Initialize log buffer and timers in constructor
37
        this._logBuffer = [];
38
        this._timers = {};
39
40
        // Check for debug configuration
41
        if (typeof window !== "undefined" && window.localStorage) {
42
            // Check localStorage first, then global variable
43
            this.enabled =
44
                window.localStorage.getItem("koha.booking.debug") === "true" ||
45
                window["KOHA_BOOKING_DEBUG"] === true;
46
47
            // Allow configuring specific log levels
48
            const levels = window.localStorage.getItem(
49
                "koha.booking.debug.levels"
50
            );
51
            if (levels) {
52
                this.enabledLevels = new Set(levels.split(","));
53
            }
54
        }
55
    }
56
57
    /**
58
     * Enable or disable debug logging
59
     * @param {boolean} enabled
60
     */
61
    setEnabled(enabled) {
62
        this.enabled = enabled;
63
        if (enabled) {
64
            // When enabling debug, include all levels
65
            this.enabledLevels = new Set(["debug", "info", "warn", "error"]);
66
        } else {
67
            // When disabling, clear all levels
68
            this.enabledLevels = new Set();
69
        }
70
        if (typeof window !== "undefined" && window.localStorage) {
71
            window.localStorage.setItem(
72
                "koha.booking.debug",
73
                enabled.toString()
74
            );
75
        }
76
    }
77
78
    /**
79
     * Set which log levels are enabled
80
     * @param {string[]} levels - Array of level names (debug, info, warn, error)
81
     */
82
    setLevels(levels) {
83
        this.enabledLevels = new Set(levels);
84
        if (typeof window !== "undefined" && window.localStorage) {
85
            window.localStorage.setItem(
86
                "koha.booking.debug.levels",
87
                levels.join(",")
88
            );
89
        }
90
    }
91
92
    /**
93
     * Core logging method
94
     * @param {string} level
95
     * @param {string} message
96
     * @param  {...unknown} args
97
     */
98
    log(level, message, ...args) {
99
        if (!this.enabledLevels.has(level)) return;
100
101
        const timestamp = new Date().toISOString();
102
        const prefix = `[${timestamp}] [${
103
            this.module
104
        }] [${level.toUpperCase()}]`;
105
106
        console[level](prefix, message, ...args);
107
108
        this._logBuffer.push({
109
            timestamp,
110
            module: this.module,
111
            level,
112
            message,
113
            args,
114
        });
115
116
        if (this._logBuffer.length > 1000) {
117
            this._logBuffer = this._logBuffer.slice(-1000);
118
        }
119
    }
120
121
    // Convenience methods
122
    debug(message, ...args) {
123
        this.log("debug", message, ...args);
124
    }
125
    info(message, ...args) {
126
        this.log("info", message, ...args);
127
    }
128
    warn(message, ...args) {
129
        this.log("warn", message, ...args);
130
    }
131
    error(message, ...args) {
132
        this.log("error", message, ...args);
133
    }
134
135
    /**
136
     * Performance timing utilities
137
     */
138
    time(label) {
139
        if (!this.enabledLevels.has("debug")) return;
140
        const key = `[${this.module}] ${label}`;
141
        console.time(key);
142
        this._activeTimers.add(label);
143
        this._timers[label] = performance.now();
144
    }
145
146
    timeEnd(label) {
147
        if (!this.enabledLevels.has("debug")) return;
148
        // Only call console.timeEnd if we actually started this timer
149
        if (!this._activeTimers.has(label)) return;
150
151
        const key = `[${this.module}] ${label}`;
152
        console.timeEnd(key);
153
        this._activeTimers.delete(label);
154
155
        // Also log the duration
156
        if (this._timers[label]) {
157
            const duration = performance.now() - this._timers[label];
158
            this.debug(`${label} completed in ${duration.toFixed(2)}ms`);
159
            delete this._timers[label];
160
        }
161
    }
162
163
    /**
164
     * Group related log entries
165
     */
166
    group(label) {
167
        if (!this.enabledLevels.has("debug")) return;
168
        console.group(`[${this.module}] ${label}`);
169
        this._activeGroups.push(label);
170
    }
171
172
    groupEnd() {
173
        if (!this.enabledLevels.has("debug")) return;
174
        // Only call console.groupEnd if we have an active group
175
        if (this._activeGroups.length === 0) return;
176
177
        console.groupEnd();
178
        this._activeGroups.pop();
179
    }
180
181
    /**
182
     * Export logs for bug reports
183
     */
184
    exportLogs() {
185
        return {
186
            module: this.module,
187
            enabled: this.enabled,
188
            enabledLevels: Array.from(this.enabledLevels),
189
            logs: this._logBuffer || [],
190
        };
191
    }
192
193
    /**
194
     * Clear log buffer
195
     */
196
    clearLogs() {
197
        this._logBuffer = [];
198
        this._activeTimers.clear();
199
        this._activeGroups = [];
200
    }
201
}
202
203
// Create singleton instances for each module
204
export const managerLogger = new BookingLogger("BookingManager");
205
export const calendarLogger = new BookingLogger("BookingCalendar");
206
207
// Expose debug utilities to browser console
208
if (typeof window !== "undefined") {
209
    const debugObj = {
210
        // Enable/disable all booking debug logs
211
        enable() {
212
            managerLogger.setEnabled(true);
213
            calendarLogger.setEnabled(true);
214
            console.log("Booking debug logging enabled");
215
        },
216
217
        disable() {
218
            managerLogger.setEnabled(false);
219
            calendarLogger.setEnabled(false);
220
            console.log("Booking debug logging disabled");
221
        },
222
223
        // Set specific log levels
224
        setLevels(levels) {
225
            managerLogger.setLevels(levels);
226
            calendarLogger.setLevels(levels);
227
            console.log(`Booking log levels set to: ${levels.join(", ")}`);
228
        },
229
230
        // Export all logs
231
        exportLogs() {
232
            return {
233
                manager: managerLogger.exportLogs(),
234
                calendar: calendarLogger.exportLogs(),
235
            };
236
        },
237
238
        // Clear all logs
239
        clearLogs() {
240
            managerLogger.clearLogs();
241
            calendarLogger.clearLogs();
242
            console.log("Booking logs cleared");
243
        },
244
245
        // Get current status
246
        status() {
247
            return {
248
                enabled: {
249
                    manager: managerLogger.enabled,
250
                    calendar: calendarLogger.enabled,
251
                },
252
                levels: {
253
                    manager: Array.from(managerLogger.enabledLevels),
254
                    calendar: Array.from(calendarLogger.enabledLevels),
255
                },
256
            };
257
        },
258
    };
259
260
    // Set on browser window
261
    window["BookingDebug"] = debugObj;
262
263
    // Only log availability message if debug is already enabled
264
    if (managerLogger.enabled || calendarLogger.enabled) {
265
        console.log("Booking debug utilities available at window.BookingDebug");
266
    }
267
}
268
269
// Additional setup for Node.js testing environment
270
if (typeof globalThis !== "undefined" && typeof window === "undefined") {
271
    // We're in Node.js - set up global.window if it exists
272
    if (globalThis.window) {
273
        const debugObj = {
274
            enable: () => {
275
                managerLogger.setEnabled(true);
276
                calendarLogger.setEnabled(true);
277
            },
278
            disable: () => {
279
                managerLogger.setEnabled(false);
280
                calendarLogger.setEnabled(false);
281
            },
282
            exportLogs: () => ({
283
                manager: managerLogger.exportLogs(),
284
                calendar: calendarLogger.exportLogs(),
285
            }),
286
            status: () => ({
287
                managerEnabled: managerLogger.enabled,
288
                calendarEnabled: calendarLogger.enabled,
289
            }),
290
        };
291
        globalThis.window["BookingDebug"] = debugObj;
292
    }
293
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/markers.mjs (+81 lines)
Line 0 Link Here
1
/**
2
 * Marker generation and aggregation for the booking system.
3
 *
4
 * This module handles generation of calendar markers from availability data
5
 * and aggregation of markers by type for display purposes.
6
 *
7
 * @module markers
8
 */
9
10
import { BookingDate } from "./BookingDate.mjs";
11
import { idsEqual } from "./id-utils.mjs";
12
import { MARKER_TYPE_MAP } from "./constants.mjs";
13
14
/**
15
 * Aggregate all booking/checkouts for a given date (for calendar indicators)
16
 * @param {import('../../types/bookings').UnavailableByDate} unavailableByDate - Map produced by buildUnavailableByDateMap
17
 * @param {string|Date|import("dayjs").Dayjs} dateStr - date to check (YYYY-MM-DD or Date or dayjs)
18
 * @param {Array<import('../../types/bookings').BookableItem>} bookableItems - Array of all bookable items
19
 * @returns {import('../../types/bookings').CalendarMarker[]} indicators for that date
20
 */
21
export function getBookingMarkersForDate(
22
    unavailableByDate,
23
    dateStr,
24
    bookableItems = []
25
) {
26
    if (!unavailableByDate) {
27
        return [];
28
    }
29
30
    let d;
31
    try {
32
        d = dateStr ? BookingDate.from(dateStr).toDayjs() : BookingDate.today().toDayjs();
33
    } catch {
34
        d = BookingDate.today().toDayjs();
35
    }
36
    const key = d.format("YYYY-MM-DD");
37
    const markers = [];
38
39
    const findItem = item_id => {
40
        if (item_id == null) return undefined;
41
        return bookableItems.find(i => idsEqual(i?.item_id, item_id));
42
    };
43
44
    const entry = unavailableByDate[key];
45
46
    if (!entry) {
47
        return [];
48
    }
49
50
    for (const [item_id, reasons] of Object.entries(entry)) {
51
        const item = findItem(item_id);
52
        for (const reason of reasons) {
53
            // Map IntervalTree/Sweep reasons to CSS class names
54
            // lead and trail periods keep their original names for CSS
55
            const type = MARKER_TYPE_MAP[reason] ?? reason;
56
            markers.push({
57
                /** @type {import('../../types/bookings').MarkerType} */
58
                type: /** @type {any} */ (type),
59
                item: String(item_id),
60
                itemName: item?.title || String(item_id),
61
                barcode: item?.barcode || item?.external_id || null,
62
            });
63
        }
64
    }
65
    return markers;
66
}
67
68
/**
69
 * Aggregate markers by type for display
70
 * @param {Array} markers - Array of booking markers
71
 * @returns {import('../../types/bookings').MarkerAggregation} Aggregated counts by type
72
 */
73
export function aggregateMarkersByType(markers) {
74
    return markers.reduce((acc, marker) => {
75
        // Exclude lead and trail markers from visual display
76
        if (marker.type !== "lead" && marker.type !== "trail") {
77
            acc[marker.type] = (acc[marker.type] || 0) + 1;
78
        }
79
        return acc;
80
    }, {});
81
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/strategies.mjs (+315 lines)
Line 0 Link Here
1
import { BookingDate, addDays } from "./BookingDate.mjs";
2
import { calculateMaxEndDate } from "./availability.mjs";
3
import {
4
    CONSTRAINT_MODE_END_DATE_ONLY,
5
    CONSTRAINT_MODE_NORMAL,
6
    DEFAULT_MAX_PERIOD_DAYS,
7
} from "./constants.mjs";
8
import {
9
    queryRangeAndResolve,
10
    queryPointAndResolve,
11
    createConflictContext,
12
} from "./conflict-resolution.mjs";
13
14
/**
15
 * Base strategy with shared logic for constraint highlighting.
16
 * Mode-specific strategies override methods as needed.
17
 */
18
const BaseStrategy = {
19
    name: "base",
20
21
    /**
22
     * Validate if a start date can be selected.
23
     * Base implementation allows all dates.
24
     */
25
    validateStartDateSelection() {
26
        return false;
27
    },
28
29
    /**
30
     * Handle intermediate dates between start and end.
31
     * Base implementation has no special handling.
32
     */
33
    handleIntermediateDate() {
34
        return null;
35
    },
36
37
    /**
38
     * Enforce end date selection rules.
39
     * Base implementation allows any end date.
40
     */
41
    enforceEndDateSelection() {
42
        return { ok: true };
43
    },
44
45
    /**
46
     * Calculate target end date from circulation rules or options.
47
     * Shared helper for highlighting calculation.
48
     * @protected
49
     */
50
    _calculateTargetEnd(start, circulationRules, constraintOptions) {
51
        // Prefer backend-calculated due date (respects useDaysMode/calendar)
52
        const dueStr = circulationRules?.calculated_due_date;
53
        if (dueStr) {
54
            const due = BookingDate.from(dueStr).toDayjs();
55
            if (!due.isBefore(start, "day")) {
56
                return {
57
                    targetEnd: due,
58
                    maxPeriod:
59
                        Number(circulationRules?.calculated_period_days) ||
60
                        constraintOptions.maxBookingPeriod,
61
                };
62
            }
63
        }
64
65
        // Fall back to maxPeriod arithmetic
66
        let maxPeriod = constraintOptions.maxBookingPeriod;
67
        if (!maxPeriod) {
68
            maxPeriod =
69
                Number(circulationRules?.maxPeriod) ||
70
                Number(circulationRules?.issuelength) ||
71
                DEFAULT_MAX_PERIOD_DAYS;
72
        }
73
        if (!maxPeriod) return null;
74
75
        return {
76
            targetEnd: calculateMaxEndDate(start, maxPeriod),
77
            maxPeriod,
78
        };
79
    },
80
81
    /**
82
     * Get blocked intermediate dates between start and target end.
83
     * Override in strategies that need to block intermediate dates.
84
     * @protected
85
     * @param {import('dayjs').Dayjs} _start - Start date (unused in base)
86
     * @param {import('dayjs').Dayjs} _targetEnd - Target end date (unused in base)
87
     * @returns {Date[]}
88
     */
89
    _getBlockedIntermediateDates(_start, _targetEnd) {
90
        return [];
91
    },
92
93
    /**
94
     * Calculate constraint highlighting for the calendar.
95
     * Uses template method pattern - subclasses override _getBlockedIntermediateDates.
96
     * @param {Date|import('dayjs').Dayjs} startDate
97
     * @param {Object} circulationRules
98
     * @param {import('../../types/bookings').ConstraintOptions} [constraintOptions={}]
99
     * @returns {import('../../types/bookings').ConstraintHighlighting|null}
100
     */
101
    calculateConstraintHighlighting(
102
        startDate,
103
        circulationRules,
104
        constraintOptions = {}
105
    ) {
106
        const start = BookingDate.from(startDate).toDayjs();
107
108
        const result = this._calculateTargetEnd(
109
            start,
110
            circulationRules,
111
            constraintOptions
112
        );
113
        if (!result) return null;
114
115
        const { targetEnd, maxPeriod } = result;
116
117
        return {
118
            startDate: start.toDate(),
119
            targetEndDate: targetEnd.toDate(),
120
            blockedIntermediateDates: this._getBlockedIntermediateDates(
121
                start,
122
                targetEnd
123
            ),
124
            constraintMode: this.name,
125
            maxPeriod,
126
        };
127
    },
128
};
129
130
/**
131
 * Validate start date for end_date_only mode.
132
 * Checks if the entire booking period (start to calculated end) is available.
133
 * @exported for testability
134
 */
135
export function validateEndDateOnlyStartDate(
136
    date,
137
    config,
138
    intervalTree,
139
    selectedItem,
140
    editBookingId,
141
    allItemIds
142
) {
143
    // Determine target end date based on backend due date override when available
144
    let targetEndDate;
145
    const due = config?.calculatedDueDate || null;
146
    if (due && !due.isBefore(date, "day")) {
147
        targetEndDate = due.clone();
148
    } else {
149
        const maxPeriod = Number(config?.maxPeriod) || 0;
150
        targetEndDate =
151
            maxPeriod > 0
152
                ? calculateMaxEndDate(date, maxPeriod).toDate()
153
                : date;
154
    }
155
156
    const ctx = createConflictContext(selectedItem, editBookingId, allItemIds);
157
158
    if (selectedItem) {
159
        // Single item mode: use range query
160
        const result = queryRangeAndResolve(
161
            intervalTree,
162
            date.valueOf(),
163
            targetEndDate.valueOf(),
164
            ctx
165
        );
166
        return result.hasConflict;
167
    } else {
168
        // Any item mode: check each day in the range
169
        // Block if all items are unavailable on any single day
170
        for (
171
            let checkDate = date;
172
            checkDate.isSameOrBefore(targetEndDate, "day");
173
            checkDate = checkDate.add(1, "day")
174
        ) {
175
            const result = queryPointAndResolve(
176
                intervalTree,
177
                checkDate.valueOf(),
178
                ctx
179
            );
180
            if (result.hasConflict) {
181
                return true;
182
            }
183
        }
184
        return false;
185
    }
186
}
187
188
/**
189
 * Handle intermediate date clicks for end_date_only mode.
190
 * Returns true to disable, null to allow normal handling.
191
 * @exported for testability
192
 */
193
export function handleEndDateOnlyIntermediateDate(date, selectedDates, config) {
194
    if (!selectedDates || selectedDates.length !== 1) {
195
        return null;
196
    }
197
198
    const startDate = BookingDate.from(selectedDates[0]).toDayjs();
199
200
    // Prefer backend due date when provided
201
    const due = config?.calculatedDueDate;
202
    if (due && !due.isBefore(startDate, "day")) {
203
        const expectedEndDate = due.clone();
204
        if (date.isSame(expectedEndDate, "day")) return null;
205
        if (date.isAfter(expectedEndDate, "day")) return true;
206
        return null; // intermediate left to UI highlighting + click prevention
207
    }
208
209
    // Fall back to maxPeriod handling
210
    const maxPeriod = Number(config?.maxPeriod) || 0;
211
    if (!maxPeriod) return null;
212
213
    const expectedEndDate = calculateMaxEndDate(startDate, maxPeriod);
214
    if (date.isSame(expectedEndDate, "day")) return null;
215
    if (date.isAfter(expectedEndDate, "day")) return true;
216
    return null;
217
}
218
219
/**
220
 * Strategy for end_date_only constraint mode.
221
 * Users must select the exact end date calculated from start + period.
222
 */
223
const EndDateOnlyStrategy = {
224
    ...BaseStrategy,
225
    name: CONSTRAINT_MODE_END_DATE_ONLY,
226
227
    validateStartDateSelection(
228
        dayjsDate,
229
        config,
230
        intervalTree,
231
        selectedItem,
232
        editBookingId,
233
        allItemIds,
234
        selectedDates
235
    ) {
236
        if (!selectedDates || selectedDates.length === 0) {
237
            return validateEndDateOnlyStartDate(
238
                dayjsDate,
239
                config,
240
                intervalTree,
241
                selectedItem,
242
                editBookingId,
243
                allItemIds
244
            );
245
        }
246
        return false;
247
    },
248
249
    handleIntermediateDate(dayjsDate, selectedDates, config) {
250
        return handleEndDateOnlyIntermediateDate(
251
            dayjsDate,
252
            selectedDates,
253
            config
254
        );
255
    },
256
257
    /**
258
     * Generate blocked dates between start and target end.
259
     * @override
260
     */
261
    _getBlockedIntermediateDates(start, targetEnd) {
262
        const diffDays = Math.max(0, targetEnd.diff(start, "day"));
263
        const blockedDates = [];
264
        for (let i = 1; i < diffDays; i++) {
265
            blockedDates.push(addDays(start, i).toDate());
266
        }
267
        return blockedDates;
268
    },
269
270
    enforceEndDateSelection(dayjsStart, dayjsEnd, circulationRules) {
271
        if (!dayjsEnd) return { ok: true };
272
273
        const dueStr = circulationRules?.calculated_due_date;
274
        let targetEnd;
275
        if (dueStr) {
276
            const due = BookingDate.from(dueStr).toDayjs();
277
            if (!due.isBefore(dayjsStart, "day")) {
278
                targetEnd = due;
279
            }
280
        }
281
        if (!targetEnd) {
282
            const numericMaxPeriod =
283
                Number(circulationRules?.maxPeriod) ||
284
                Number(circulationRules?.issuelength) ||
285
                0;
286
            // Use calculateMaxEndDate for consistency: end = start + (maxPeriod - 1), as start is day 1
287
            targetEnd = calculateMaxEndDate(dayjsStart, Math.max(1, numericMaxPeriod));
288
        }
289
        return {
290
            ok: dayjsEnd.isSame(targetEnd, "day"),
291
            expectedEnd: targetEnd,
292
        };
293
    },
294
};
295
296
/**
297
 * Strategy for normal constraint mode.
298
 * Users can select any valid date range within the max period.
299
 */
300
const NormalStrategy = {
301
    ...BaseStrategy,
302
    name: CONSTRAINT_MODE_NORMAL,
303
    // Uses all base implementations - no overrides needed
304
};
305
306
/**
307
 * Factory function to get the appropriate strategy for a constraint mode.
308
 * @param {string} mode - The constraint mode (CONSTRAINT_MODE_END_DATE_ONLY or CONSTRAINT_MODE_NORMAL)
309
 * @returns {Object} The strategy object
310
 */
311
export function createConstraintStrategy(mode) {
312
    return mode === CONSTRAINT_MODE_END_DATE_ONLY
313
        ? EndDateOnlyStrategy
314
        : NormalStrategy;
315
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation-messages.js (+74 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
    fetch_holidays_failed: params =>
55
        $__("Failed to fetch holidays: %s %s").format(
56
            params.status,
57
            params.statusText
58
        ),
59
    create_booking_failed: params =>
60
        $__("Failed to create booking: %s %s").format(
61
            params.status,
62
            params.statusText
63
        ),
64
    update_booking_failed: params =>
65
        $__("Failed to update booking: %s %s").format(
66
            params.status,
67
            params.statusText
68
        ),
69
};
70
71
// Create the booking validation handler
72
export const bookingValidation = createValidationErrorHandler(
73
    bookingValidationMessages
74
);
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/booking/validation.mjs (+69 lines)
Line 0 Link Here
1
/**
2
 * Pure functions for booking validation logic
3
 * Extracted from BookingValidationService to eliminate store coupling
4
 */
5
6
/**
7
 * Validate if user can proceed to step 3 (period selection)
8
 * @param {Object} validationData - All required data for validation
9
 * @param {boolean} validationData.showPatronSelect - Whether patron selection is required
10
 * @param {Object} validationData.bookingPatron - Selected booking patron
11
 * @param {boolean} validationData.showItemDetailsSelects - Whether item details are required
12
 * @param {boolean} validationData.showPickupLocationSelect - Whether pickup location is required
13
 * @param {string} validationData.pickupLibraryId - Selected pickup library ID
14
 * @param {string} validationData.bookingItemtypeId - Selected item type ID
15
 * @param {Array} validationData.itemtypeOptions - Available item type options
16
 * @param {string} validationData.bookingItemId - Selected item ID
17
 * @param {Array} validationData.bookableItems - Available bookable items
18
 * @returns {boolean} Whether the user can proceed to step 3
19
 */
20
export function canProceedToStep3(validationData) {
21
    const {
22
        showPatronSelect,
23
        bookingPatron,
24
        showItemDetailsSelects,
25
        showPickupLocationSelect,
26
        pickupLibraryId,
27
        bookingItemtypeId,
28
        itemtypeOptions,
29
        bookingItemId,
30
        bookableItems,
31
    } = validationData;
32
33
    if (showPatronSelect && !bookingPatron) {
34
        return false;
35
    }
36
37
    if (showItemDetailsSelects || showPickupLocationSelect) {
38
        if (showPickupLocationSelect && !pickupLibraryId) {
39
            return false;
40
        }
41
        if (showItemDetailsSelects) {
42
            if (!bookingItemtypeId && itemtypeOptions.length > 0) {
43
                return false;
44
            }
45
            if (!bookingItemId && bookableItems.length > 0) {
46
                return false;
47
            }
48
        }
49
    }
50
51
    if (!bookableItems || bookableItems.length === 0) {
52
        return false;
53
    }
54
55
    return true;
56
}
57
58
/**
59
 * Validate if form can be submitted
60
 * @param {Object} validationData - Data required for step 3 validation
61
 * @param {Array} dateRange - Selected date range
62
 * @returns {boolean} Whether the form can be submitted
63
 */
64
export function canSubmitBooking(validationData, dateRange) {
65
    if (!canProceedToStep3(validationData)) return false;
66
    if (!Array.isArray(dateRange) || dateRange.length < 2) return false;
67
68
    return true;
69
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/hover-feedback.mjs (+241 lines)
Line 0 Link Here
1
/**
2
 * Contextual hover feedback messages for booking calendar dates.
3
 *
4
 * Generates user-facing messages explaining why a date is disabled
5
 * or providing context about the current selection mode.
6
 * Mirrors upstream's ~20 contextual messages adapted for the Vue architecture.
7
 *
8
 * @module hover-feedback
9
 */
10
11
import { BookingDate, formatYMD } from "../booking/BookingDate.mjs";
12
import { $__ } from "../../../../i18n/index.js";
13
14
/**
15
 * Generate a contextual feedback message for a hovered calendar date.
16
 *
17
 * @param {Date} date - The date being hovered
18
 * @param {Object} context
19
 * @param {boolean} context.isDisabled - Whether the date is disabled in the calendar
20
 * @param {string[]} context.selectedDateRange - Currently selected dates (ISO strings)
21
 * @param {Object} context.circulationRules - First circulation rule object
22
 * @param {Object} context.unavailableByDate - Unavailability map from store
23
 * @param {string[]} [context.holidays] - Holiday date strings (YYYY-MM-DD)
24
 * @returns {{ message: string, variant: "info"|"warning"|"danger" } | null}
25
 */
26
export function getDateFeedbackMessage(date, context) {
27
    const {
28
        isDisabled,
29
        selectedDateRange,
30
        circulationRules,
31
        unavailableByDate,
32
        holidays,
33
    } = context;
34
35
    const today = BookingDate.today().toDayjs();
36
    const d = BookingDate.from(date).toDayjs();
37
    const dateKey = formatYMD(date);
38
39
    const leadDays = Number(circulationRules?.bookings_lead_period) || 0;
40
    const trailDays = Number(circulationRules?.bookings_trail_period) || 0;
41
    const maxPeriod =
42
        Number(circulationRules?.maxPeriod) ||
43
        Number(circulationRules?.issuelength) ||
44
        0;
45
46
    const hasStart = selectedDateRange && selectedDateRange.length >= 1;
47
    const isSelectingEnd = hasStart;
48
    const isSelectingStart = !hasStart;
49
50
    if (isDisabled) {
51
        const reason = getDisabledReason(d, dateKey, {
52
            today,
53
            leadDays,
54
            trailDays,
55
            maxPeriod,
56
            isSelectingStart,
57
            isSelectingEnd,
58
            selectedDateRange,
59
            unavailableByDate,
60
            holidays,
61
        });
62
        return { message: reason, variant: "danger" };
63
    }
64
65
    const info = getEnabledInfo({
66
        leadDays,
67
        trailDays,
68
        isSelectingStart,
69
        isSelectingEnd,
70
        unavailableByDate,
71
        dateKey,
72
    });
73
    return info ? { message: info, variant: "info" } : null;
74
}
75
76
/**
77
 * Determine the reason a date is disabled.
78
 * Checks conditions in priority order matching upstream logic.
79
 */
80
function getDisabledReason(d, dateKey, ctx) {
81
    // Past date
82
    if (d.isBefore(ctx.today, "day")) {
83
        return $__("Cannot select: date is in the past");
84
    }
85
86
    // Holiday
87
    if (ctx.holidays && ctx.holidays.includes(dateKey)) {
88
        return $__("Cannot select: library is closed on this date");
89
    }
90
91
    // Insufficient lead time from today
92
    if (ctx.isSelectingStart && ctx.leadDays > 0) {
93
        const minStart = ctx.today.add(ctx.leadDays, "day");
94
        if (d.isBefore(minStart, "day")) {
95
            return $__(
96
                "Cannot select: insufficient lead time (%s days required before start)"
97
            ).format(ctx.leadDays);
98
        }
99
    }
100
101
    // Exceeds maximum booking period
102
    if (
103
        ctx.isSelectingEnd &&
104
        ctx.maxPeriod > 0 &&
105
        ctx.selectedDateRange?.[0]
106
    ) {
107
        const start = BookingDate.from(ctx.selectedDateRange[0]).toDayjs();
108
        if (d.isAfter(start.add(ctx.maxPeriod, "day"), "day")) {
109
            return $__(
110
                "Cannot select: exceeds maximum booking period (%s days)"
111
            ).format(ctx.maxPeriod);
112
        }
113
    }
114
115
    // Check markers in unavailableByDate for specific reasons
116
    const markerReasons = collectMarkerReasons(ctx.unavailableByDate, dateKey);
117
118
    if (markerReasons.has("holiday")) {
119
        return $__("Cannot select: library is closed on this date");
120
    }
121
    if (
122
        markerReasons.has("booking") ||
123
        markerReasons.has("booked") ||
124
        markerReasons.has("core")
125
    ) {
126
        return $__(
127
            "Cannot select: this date is part of an existing booking"
128
        );
129
    }
130
    if (
131
        markerReasons.has("checkout") ||
132
        markerReasons.has("checked-out")
133
    ) {
134
        return $__(
135
            "Cannot select: this date is part of an existing checkout"
136
        );
137
    }
138
    if (markerReasons.has("lead")) {
139
        return $__(
140
            "Cannot select: this date is part of an existing booking's lead period"
141
        );
142
    }
143
    if (markerReasons.has("trail")) {
144
        return $__(
145
            "Cannot select: this date is part of an existing booking's trail period"
146
        );
147
    }
148
149
    // Lead period of selected start would conflict
150
    if (ctx.isSelectingStart && ctx.leadDays > 0) {
151
        return $__(
152
            "Cannot select: lead period (%s days before start) conflicts with an existing booking"
153
        ).format(ctx.leadDays);
154
    }
155
156
    // Trail period of selected end would conflict
157
    if (ctx.isSelectingEnd && ctx.trailDays > 0) {
158
        return $__(
159
            "Cannot select: trail period (%s days after return) conflicts with an existing booking"
160
        ).format(ctx.trailDays);
161
    }
162
163
    return $__("Cannot select: conflicts with an existing booking");
164
}
165
166
/**
167
 * Generate info message for an enabled (selectable) date.
168
 */
169
function getEnabledInfo(ctx) {
170
    // Collect context appendages from markers
171
    const appendages = [];
172
    const markerReasons = collectMarkerReasons(
173
        ctx.unavailableByDate,
174
        ctx.dateKey
175
    );
176
    if (markerReasons.has("lead")) {
177
        appendages.push(
178
            $__("hovering an existing booking's lead period")
179
        );
180
    }
181
    if (markerReasons.has("trail")) {
182
        appendages.push(
183
            $__("hovering an existing booking's trail period")
184
        );
185
    }
186
187
    const suffix =
188
        appendages.length > 0 ? " \u2022 " + appendages.join(", ") : "";
189
190
    if (ctx.isSelectingStart) {
191
        const extras = [];
192
        if (ctx.leadDays > 0) {
193
            extras.push(
194
                $__("Lead period: %s days before start").format(ctx.leadDays)
195
            );
196
        }
197
        if (ctx.trailDays > 0) {
198
            extras.push(
199
                $__("Trail period: %s days after return").format(
200
                    ctx.trailDays
201
                )
202
            );
203
        }
204
        const detail = extras.length > 0 ? ". " + extras.join(". ") : "";
205
        return $__("Select a start date") + detail + suffix;
206
    }
207
208
    if (ctx.isSelectingEnd) {
209
        const detail =
210
            ctx.trailDays > 0
211
                ? ". " +
212
                  $__("Trail period: %s days after return").format(
213
                      ctx.trailDays
214
                  )
215
                : "";
216
        return $__("Select an end date") + detail + suffix;
217
    }
218
219
    return null;
220
}
221
222
/**
223
 * Collect all marker reason strings for a date from the unavailableByDate map.
224
 * @param {Object} unavailableByDate
225
 * @param {string} dateKey - YYYY-MM-DD
226
 * @returns {Set<string>}
227
 */
228
function collectMarkerReasons(unavailableByDate, dateKey) {
229
    const reasons = new Set();
230
    const entry = unavailableByDate?.[dateKey];
231
    if (!entry) return reasons;
232
233
    Object.values(entry).forEach(itemReasons => {
234
        if (itemReasons instanceof Set) {
235
            itemReasons.forEach(r => reasons.add(r));
236
        } else if (Array.isArray(itemReasons)) {
237
            itemReasons.forEach(r => reasons.add(r));
238
        }
239
    });
240
    return reasons;
241
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/marker-labels.mjs (+22 lines)
Line 0 Link Here
1
/**
2
 * Marker label utilities for booking calendar display
3
 * @module marker-labels
4
 */
5
6
import { $__ } from "../../../../i18n/index.js";
7
8
/**
9
 * Get the translated display label for a marker type
10
 * @param {string} type - The marker type identifier (e.g., "booked", "checked-out", "lead", "trail", "holiday")
11
 * @returns {string} The translated label or the original type if no translation exists
12
 */
13
export function getMarkerTypeLabel(type) {
14
    const labels = {
15
        booked: $__("Booked"),
16
        "checked-out": $__("Checked out"),
17
        lead: $__("Lead period"),
18
        trail: $__("Trail period"),
19
        holiday: $__("Library closed"),
20
    };
21
    return labels[type] || type;
22
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/selection-message.mjs (+47 lines)
Line 0 Link Here
1
/**
2
 * User-facing message builders for booking selection feedback
3
 * @module selection-message
4
 */
5
6
import { idsEqual } from "../booking/id-utils.mjs";
7
import { $__ } from "../../../../i18n/index.js";
8
9
/**
10
 * Build a localized message explaining why no items are available for booking
11
 * @param {Array<{library_id: string, name: string}>} pickupLocations - Available pickup locations
12
 * @param {Array<{item_type_id: string, description: string}>} itemTypes - Available item types
13
 * @param {string|null} pickupLibraryId - Currently selected pickup location ID
14
 * @param {string|null} itemtypeId - Currently selected item type ID
15
 * @returns {string} Translated message describing the selection criteria
16
 */
17
export function buildNoItemsAvailableMessage(
18
    pickupLocations,
19
    itemTypes,
20
    pickupLibraryId,
21
    itemtypeId
22
) {
23
    const selectionParts = [];
24
    if (pickupLibraryId) {
25
        const location = (pickupLocations || []).find(l =>
26
            idsEqual(l.library_id, pickupLibraryId)
27
        );
28
        selectionParts.push(
29
            $__("pickup location: %s").format(
30
                (location && location.name) || pickupLibraryId
31
            )
32
        );
33
    }
34
    if (itemtypeId) {
35
        const itemType = (itemTypes || []).find(t =>
36
            idsEqual(t.item_type_id, itemtypeId)
37
        );
38
        selectionParts.push(
39
            $__("item type: %s").format(
40
                (itemType && itemType.description) || itemtypeId
41
            )
42
        );
43
    }
44
    return $__(
45
        "No items are available for booking with the selected criteria (%s). Please adjust your selection."
46
    ).format(selectionParts.join(", "));
47
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/lib/ui/steps.mjs (+45 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
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/tsconfig.json (+34 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
        "noUnusedLocals": true,
12
        "noUnusedParameters": true,
13
        "baseUrl": ".",
14
        "paths": {
15
            "@bookingApi": [
16
                "./lib/adapters/api/staff-interface.js",
17
                "./lib/adapters/api/opac.js"
18
            ]
19
        },
20
        "types": ["node"],
21
        "lib": ["ES2021", "DOM", "DOM.Iterable"]
22
    },
23
    "include": [
24
        "./**/*.js",
25
        "./**/*.mjs",
26
        "./**/*.ts",
27
        "./**/*.vue",
28
        "./**/*.d.ts"
29
    ],
30
    "exclude": [
31
        "node_modules",
32
        "dist"
33
    ]
34
}
(-)a/koha-tmpl/intranet-tmpl/prog/js/vue/components/Bookings/types/bookings.d.ts (+291 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
    /** Holiday dates (YYYY-MM-DD format) for constraint highlighting */
139
    holidays?: string[];
140
    /** On-demand loading flag */
141
    onDemand?: boolean;
142
};
143
144
/** Resulting highlighting metadata for calendar UI. */
145
export type ConstraintHighlighting = {
146
    startDate: Date;
147
    targetEndDate: Date;
148
    blockedIntermediateDates: Date[];
149
    constraintMode: string;
150
    maxPeriod: number;
151
    /** Holiday dates (YYYY-MM-DD format) for visual highlighting */
152
    holidays?: string[];
153
};
154
155
/** Minimal shape of the Pinia booking store used by the UI. */
156
export type BookingStoreLike = {
157
    selectedDateRange?: string[];
158
    circulationRules?: CirculationRule[];
159
    bookings?: Booking[];
160
    checkouts?: Checkout[];
161
    bookableItems?: BookableItem[];
162
    bookingItemId?: Id | null;
163
    bookingId?: Id | null;
164
    unavailableByDate?: UnavailableByDate;
165
    /** Holiday dates (YYYY-MM-DD format) */
166
    holidays?: string[];
167
};
168
169
/** Store actions used by composables to interact with backend. */
170
export type BookingStoreActions = {
171
    fetchPickupLocations: (
172
        biblionumber: Id,
173
        patronId: Id
174
    ) => Promise<unknown>;
175
    invalidateCalculatedDue: () => void;
176
    fetchCirculationRules: (
177
        params: Record<string, unknown>
178
    ) => Promise<unknown>;
179
    /** Fetch holidays for a library within a date range */
180
    fetchHolidays?: (
181
        libraryId: string,
182
        startDate: string,
183
        endDate: string
184
    ) => Promise<unknown>;
185
};
186
187
/** Dependencies used for updating external widgets after booking changes. */
188
export type ExternalDependencies = {
189
    timeline: () => any;
190
    bookingsTable: () => any;
191
    patronRenderer: () => any;
192
    domQuery: (selector: string) => NodeListOf<HTMLElement>;
193
    logger: {
194
        warn: (msg: any, data?: any) => void;
195
        error: (msg: any, err?: any) => void;
196
        debug?: (msg: any, data?: any) => void;
197
    };
198
};
199
200
/** Generic Ref-like helper for accepting either Vue Ref or plain `{ value }`. */
201
export type RefLike<T> = import('vue').Ref<T> | { value: T };
202
203
/** Minimal patron shape used by composables. */
204
export type PatronLike = {
205
    patron_id?: number | string;
206
    category_id?: string | number;
207
    library_id?: string;
208
    cardnumber?: string;
209
};
210
211
/** Patron data from API with display label added by transformPatronData. */
212
export type PatronOption = PatronLike & {
213
    surname?: string;
214
    firstname?: string;
215
    /** Display label formatted as "surname firstname (cardnumber)" */
216
    label: string;
217
    library?: {
218
        library_id: string;
219
        name: string;
220
    };
221
};
222
223
/** Options for calendar `createOnChange` handler. */
224
export type OnChangeOptions = {
225
    setError?: (msg: string) => void;
226
    tooltipVisibleRef?: { value: boolean };
227
    /** Ref for constraint options to avoid stale closures */
228
    constraintOptionsRef?: RefLike<ConstraintOptions> | null;
229
};
230
231
/** Minimal parameter set for circulation rules fetching. */
232
export type RulesParams = {
233
    patron_category_id?: string | number;
234
    item_type_id?: Id;
235
    library_id?: string;
236
    start_date?: string;
237
};
238
239
/** Flatpickr instance augmented with a cache for constraint highlighting. */
240
export type FlatpickrInstanceWithHighlighting = {
241
    _constraintHighlighting?: ConstraintHighlighting | null;
242
    _loanBoundaryTimes?: Set<number>;
243
    [key: string]: any;
244
};
245
246
/** Convenience alias for stores passed to fetchers. */
247
export type StoreWithActions = BookingStoreLike & BookingStoreActions;
248
249
/** Common result shape for `constrain*` helpers. */
250
export type ConstraintResult<T> = {
251
    filtered: T[];
252
    filteredOutCount: number;
253
    total: number;
254
    constraintApplied: boolean;
255
};
256
257
/** Navigation target calculation for calendar month navigation. */
258
export type CalendarNavigationTarget = {
259
    shouldNavigate: boolean;
260
    targetMonth?: number;
261
    targetYear?: number;
262
    targetDate?: Date;
263
};
264
265
/** Aggregated counts by marker type for the markers grid. */
266
export type MarkerAggregation = Record<string, number>;
267
268
/**
269
 * Current calendar view boundaries (visible date range) for navigation logic.
270
 */
271
export type CalendarCurrentView = {
272
    visibleStartDate?: Date;
273
    visibleEndDate?: Date;
274
};
275
276
/**
277
 * Common identifier type used across UI (string or number).
278
 */
279
export type Id = string | number;
280
281
/** ISO-8601 date string (YYYY-MM-DD or full ISO as returned by backend). */
282
export type ISODateString = string;
283
284
/** Minimal item type shape used in constraints and selection UI. */
285
export type ItemType = {
286
    item_type_id: string;
287
    /** Display description (used by v-select label) */
288
    description?: string;
289
    /** Alternate name field (for backwards compatibility) */
290
    name?: string;
291
};
(-)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/vue-shims.d.ts (+49 lines)
Line 0 Link Here
1
/**
2
 * Vue component type declarations for template type checking.
3
 */
4
5
import type { ComponentCustomProperties } from "vue";
6
7
/**
8
 * Augment Vue's component custom properties to include $__ for i18n.
9
 * This allows vue-tsc to recognize $__ in templates.
10
 */
11
declare module "vue" {
12
    interface ComponentCustomProperties {
13
        /**
14
         * i18n translation function - translates the given string.
15
         * @param str - The string to translate
16
         * @returns The translated string (with .format() method for placeholders)
17
         */
18
        $__: (
19
            str: string
20
        ) => string & { format: (...args: unknown[]) => string };
21
    }
22
}
23
24
/**
25
 * Global $__ function available via import from i18n module.
26
 */
27
declare global {
28
    /**
29
     * Koha i18n translation function.
30
     */
31
    function $__(
32
        str: string
33
    ): string & { format: (...args: unknown[]) => string };
34
35
    /**
36
     * String prototype extension for i18n formatting.
37
     * Koha extends String.prototype with a format method for placeholder substitution.
38
     */
39
    interface String {
40
        /**
41
         * Format string with placeholder substitution.
42
         * @param args - Values to substitute for placeholders
43
         * @returns Formatted string
44
         */
45
        format(...args: unknown[]): string;
46
    }
47
}
48
49
export {};
(-)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 (+496 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
import {
12
    formatYMD,
13
    addMonths,
14
    addDays,
15
} from "../components/Bookings/lib/booking/BookingDate.mjs";
16
import {
17
    HOLIDAY_PREFETCH_THRESHOLD_DAYS,
18
    HOLIDAY_PREFETCH_MONTHS,
19
} from "../components/Bookings/lib/booking/constants.mjs";
20
21
/**
22
 * Higher-order function to standardize async operation error handling
23
 * Eliminates repetitive try-catch-finally patterns
24
 */
25
function withErrorHandling(operation, loadingKey, errorKey = null) {
26
    return async function (...args) {
27
        // Use errorKey if provided, otherwise derive from loadingKey
28
        const errorField = errorKey || loadingKey;
29
30
        this.loading[loadingKey] = true;
31
        this.error[errorField] = null;
32
33
        try {
34
            const result = await operation.call(this, ...args);
35
            return result;
36
        } catch (error) {
37
            this.error[errorField] = processApiError(error);
38
            // Re-throw to allow caller to handle if needed
39
            throw error;
40
        } finally {
41
            this.loading[loadingKey] = false;
42
        }
43
    };
44
}
45
46
/**
47
 * State shape with improved organization and consistency
48
 * Maintains backward compatibility with existing API
49
 */
50
51
export const useBookingStore = defineStore("bookings", {
52
    state: () => ({
53
        // System state
54
        dataFetched: false,
55
56
        // Collections - consistent naming and organization
57
        bookableItems: [],
58
        bookings: [],
59
        checkouts: [],
60
        pickupLocations: [],
61
        itemTypes: [],
62
        circulationRules: [],
63
        circulationRulesContext: null, // Track the context used for the last rules fetch
64
        unavailableByDate: {},
65
        holidays: [], // Closed days for the selected pickup library
66
        /** @type {{ from: string|null, to: string|null, libraryId: string|null }} */
67
        holidaysFetchedRange: { from: null, to: null, libraryId: null }, // Track fetched range to enable on-demand extension
68
69
        // Current booking state - normalized property names
70
        bookingId: null,
71
        bookingItemId: null, // kept for backward compatibility
72
        bookingPatron: null,
73
        bookingItemtypeId: null, // kept for backward compatibility
74
        patronId: null,
75
        pickupLibraryId: null,
76
        /**
77
         * Canonical date representation for the bookings UI.
78
         * Always store ISO 8601 strings here (e.g., "2025-03-14T00:00:00.000Z").
79
         * - Widgets (Flatpickr) work with Date objects and must convert to ISO when writing
80
         * - Computation utilities convert ISO -> Date close to the boundary
81
         * - API payloads use ISO strings as-is
82
         */
83
        selectedDateRange: [],
84
85
        // Async operation state - organized structure
86
        loading: {
87
            bookableItems: false,
88
            bookings: false,
89
            checkouts: false,
90
            patrons: false,
91
            bookingPatron: false,
92
            pickupLocations: false,
93
            circulationRules: false,
94
            holidays: false,
95
            submit: false,
96
        },
97
        error: {
98
            bookableItems: null,
99
            bookings: null,
100
            checkouts: null,
101
            patrons: null,
102
            bookingPatron: null,
103
            pickupLocations: null,
104
            circulationRules: null,
105
            holidays: null,
106
            submit: null,
107
        },
108
109
        // UI-level error state (validation messages, user feedback)
110
        uiError: {
111
            message: "",
112
            code: null,
113
        },
114
    }),
115
116
    getters: {
117
        /**
118
         * Returns true if any data fetching operation is in progress
119
         */
120
        isAnyLoading: state => {
121
            return Object.values(state.loading).some(Boolean);
122
        },
123
        /**
124
         * Returns true if core booking data is loaded (bookableItems, bookings, checkouts)
125
         */
126
        isCoreDataReady: state => {
127
            return (
128
                !state.loading.bookableItems &&
129
                !state.loading.bookings &&
130
                !state.loading.checkouts &&
131
                state.bookableItems.length > 0
132
            );
133
        },
134
        /**
135
         * Returns true if all required data for the modal is loaded
136
         */
137
        isDataReady: state => {
138
            return (
139
                !state.loading.bookableItems &&
140
                !state.loading.bookings &&
141
                !state.loading.checkouts &&
142
                !state.loading.pickupLocations &&
143
                state.dataFetched
144
            );
145
        },
146
        /**
147
         * Returns list of currently loading operations
148
         */
149
        loadingOperations: state => {
150
            return Object.entries(state.loading)
151
                .filter(([, isLoading]) => isLoading)
152
                .map(([key]) => key);
153
        },
154
        /**
155
         * Returns true if there are any errors
156
         */
157
        hasErrors: state => {
158
            return Object.values(state.error).some(Boolean);
159
        },
160
        /**
161
         * Returns all current errors as an array
162
         */
163
        allErrors: state => {
164
            return Object.entries(state.error)
165
                .filter(([, error]) => error)
166
                .map(([key, error]) => ({ source: key, error }));
167
        },
168
        /**
169
         * Returns first circulation rule or empty object
170
         */
171
        effectiveCirculationRules: state => {
172
            return state.circulationRules?.[0] || {};
173
        },
174
        /**
175
         * Returns true if there is a UI error message
176
         */
177
        hasUiError: state => {
178
            return !!state.uiError.message;
179
        },
180
    },
181
182
    actions: {
183
        /**
184
         * Invalidate backend-calculated due values to avoid stale UI when inputs change.
185
         * Keeps the rules object shape but removes calculated fields so consumers
186
         * fall back to maxPeriod-based logic until fresh rules arrive.
187
         */
188
        invalidateCalculatedDue() {
189
            if (
190
                Array.isArray(this.circulationRules) &&
191
                this.circulationRules.length > 0
192
            ) {
193
                const first = { ...this.circulationRules[0] };
194
                if ("calculated_due_date" in first)
195
                    delete first.calculated_due_date;
196
                if ("calculated_period_days" in first)
197
                    delete first.calculated_period_days;
198
                this.circulationRules = [first];
199
            }
200
        },
201
        resetErrors() {
202
            Object.keys(this.error).forEach(key => {
203
                this.error[key] = null;
204
            });
205
        },
206
        /**
207
         * Set UI-level error message
208
         * @param {string} message - Error message to display
209
         * @param {string} code - Error code for categorization (e.g., 'api', 'validation', 'no_items')
210
         */
211
        setUiError(message, code = "general") {
212
            this.uiError = {
213
                message: message || "",
214
                code: message ? code : null,
215
            };
216
        },
217
        /**
218
         * Clear UI-level error
219
         */
220
        clearUiError() {
221
            this.uiError = { message: "", code: null };
222
        },
223
        /**
224
         * Clear all errors (both API errors and UI errors)
225
         */
226
        clearAllErrors() {
227
            this.resetErrors();
228
            this.clearUiError();
229
        },
230
        setUnavailableByDate(unavailableByDate) {
231
            this.unavailableByDate = unavailableByDate;
232
        },
233
        /**
234
         * Fetch bookable items for a biblionumber
235
         */
236
        fetchBookableItems: withErrorHandling(async function (biblionumber) {
237
            const data = await bookingApi.fetchBookableItems(biblionumber);
238
            this.bookableItems = data;
239
            return data;
240
        }, "bookableItems"),
241
        /**
242
         * Fetch bookings for a biblionumber
243
         */
244
        fetchBookings: withErrorHandling(async function (biblionumber) {
245
            const data = await bookingApi.fetchBookings(biblionumber);
246
            this.bookings = data;
247
            return data;
248
        }, "bookings"),
249
        /**
250
         * Fetch checkouts for a biblionumber
251
         */
252
        fetchCheckouts: withErrorHandling(async function (biblionumber) {
253
            const data = await bookingApi.fetchCheckouts(biblionumber);
254
            this.checkouts = data;
255
            return data;
256
        }, "checkouts"),
257
        /**
258
         * Fetch patrons by search term and page
259
         */
260
        fetchPatron: withErrorHandling(async function (patronId) {
261
            const data = await bookingApi.fetchPatron(patronId);
262
            return transformPatronData(Array.isArray(data) ? data[0] : data);
263
        }, "bookingPatron"),
264
        /**
265
         * Fetch patrons by search term and page
266
         */
267
        fetchPatrons: withErrorHandling(async function (term, page = 1) {
268
            const data = await bookingApi.fetchPatrons(term, page);
269
            return transformPatronsData(data);
270
        }, "patrons"),
271
        /**
272
         * Fetch pickup locations for a biblionumber (optionally filtered by patron)
273
         */
274
        fetchPickupLocations: withErrorHandling(async function (
275
            biblionumber,
276
            patron_id
277
        ) {
278
            const data = await bookingApi.fetchPickupLocations(
279
                biblionumber,
280
                patron_id
281
            );
282
            this.pickupLocations = data;
283
            return data;
284
        }, "pickupLocations"),
285
        /**
286
         * Fetch circulation rules for given context
287
         */
288
        fetchCirculationRules: withErrorHandling(async function (params) {
289
            // Only include defined (non-null, non-undefined) params
290
            const filteredParams = {};
291
            for (const key in params) {
292
                if (
293
                    params[key] !== null &&
294
                    params[key] !== undefined &&
295
                    params[key] !== ""
296
                ) {
297
                    filteredParams[key] = params[key];
298
                }
299
            }
300
            const data = await bookingApi.fetchCirculationRules(filteredParams);
301
            this.circulationRules = data;
302
            // Store the context we requested so we know what specificity we have
303
            this.circulationRulesContext = {
304
                patron_category_id: filteredParams.patron_category_id ?? null,
305
                item_type_id: filteredParams.item_type_id ?? null,
306
                library_id: filteredParams.library_id ?? null,
307
            };
308
            return data;
309
        }, "circulationRules"),
310
        /**
311
         * Fetch holidays (closed days) for a library.
312
         * Tracks fetched range and accumulates holidays to support on-demand extension.
313
         * @param {string} libraryId - The library branchcode
314
         * @param {string} [from] - Start date (ISO format), defaults to today
315
         * @param {string} [to] - End date (ISO format), defaults to 1 year from start
316
         */
317
        fetchHolidays: withErrorHandling(async function (libraryId, from, to) {
318
            if (!libraryId) {
319
                this.holidays = [];
320
                this.holidaysFetchedRange = {
321
                    from: null,
322
                    to: null,
323
                    libraryId: null,
324
                };
325
                return [];
326
            }
327
328
            // If library changed, reset and fetch fresh
329
            const fetchedRange = this.holidaysFetchedRange || {
330
                from: null,
331
                to: null,
332
                libraryId: null,
333
            };
334
            if (fetchedRange.libraryId !== libraryId) {
335
                this.holidays = [];
336
                this.holidaysFetchedRange = {
337
                    from: null,
338
                    to: null,
339
                    libraryId: null,
340
                };
341
            }
342
343
            const data = await bookingApi.fetchHolidays(libraryId, from, to);
344
345
            // Accumulate holidays using Set to avoid duplicates
346
            const existingSet = new Set(this.holidays);
347
            data.forEach(date => existingSet.add(date));
348
            this.holidays = Array.from(existingSet).sort();
349
350
            // Update fetched range (expand to cover new range)
351
            const currentFrom = this.holidaysFetchedRange.from;
352
            const currentTo = this.holidaysFetchedRange.to;
353
            this.holidaysFetchedRange = {
354
                libraryId,
355
                from: !currentFrom || from < currentFrom ? from : currentFrom,
356
                to: !currentTo || to > currentTo ? to : currentTo,
357
            };
358
359
            return data;
360
        }, "holidays"),
361
        /**
362
         * Extend holidays range if the visible calendar range exceeds fetched data.
363
         * Also prefetches upcoming months when approaching the edge of fetched data.
364
         * @param {string} libraryId - The library branchcode
365
         * @param {Date} visibleStart - Start of visible calendar range
366
         * @param {Date} visibleEnd - End of visible calendar range
367
         */
368
        async extendHolidaysIfNeeded(libraryId, visibleStart, visibleEnd) {
369
            if (!libraryId) return;
370
371
            const visibleFrom = formatYMD(visibleStart);
372
            const visibleTo = formatYMD(visibleEnd);
373
374
            const {
375
                from: fetchedFrom,
376
                to: fetchedTo,
377
                libraryId: fetchedLib,
378
            } = this.holidaysFetchedRange;
379
380
            // If different library or no data yet, fetch visible range + prefetch buffer
381
            if (fetchedLib !== libraryId || !fetchedFrom || !fetchedTo) {
382
                const prefetchEnd = formatYMD(addMonths(visibleEnd, 6));
383
                await this.fetchHolidays(libraryId, visibleFrom, prefetchEnd);
384
                return;
385
            }
386
387
            // Check if we need to extend for current view (YYYY-MM-DD strings are lexicographically sortable)
388
            const needsExtensionBefore = visibleFrom < fetchedFrom;
389
            const needsExtensionAfter = visibleTo > fetchedTo;
390
391
            if (needsExtensionBefore) {
392
                const prefetchStart = formatYMD(addMonths(visibleStart, -3));
393
                // End at day before fetchedFrom to avoid overlap
394
                const extensionEnd = formatYMD(addDays(fetchedFrom, -1));
395
                await this.fetchHolidays(
396
                    libraryId,
397
                    prefetchStart,
398
                    extensionEnd
399
                );
400
            }
401
            if (needsExtensionAfter) {
402
                // Start at day after fetchedTo to avoid overlap
403
                const extensionStart = formatYMD(addDays(fetchedTo, 1));
404
                const prefetchEnd = formatYMD(addMonths(visibleEnd, 6));
405
                await this.fetchHolidays(
406
                    libraryId,
407
                    extensionStart,
408
                    prefetchEnd
409
                );
410
            }
411
412
            // Prefetch ahead if approaching the edge
413
            if (!needsExtensionAfter && fetchedTo) {
414
                const daysToEdge = addDays(fetchedTo, 0).diff(
415
                    visibleEnd,
416
                    "day"
417
                );
418
                if (daysToEdge < HOLIDAY_PREFETCH_THRESHOLD_DAYS) {
419
                    // Start at day after fetchedTo to avoid overlap
420
                    const extensionStart = formatYMD(addDays(fetchedTo, 1));
421
                    const prefetchEnd = formatYMD(
422
                        addMonths(fetchedTo, HOLIDAY_PREFETCH_MONTHS)
423
                    );
424
                    // Fire and forget - don't await to avoid blocking, but catch errors
425
                    this.fetchHolidays(
426
                        libraryId,
427
                        extensionStart,
428
                        prefetchEnd
429
                    ).catch(() => {});
430
                }
431
            }
432
433
            if (!needsExtensionBefore && fetchedFrom) {
434
                const daysToEdge = addDays(visibleStart, 0).diff(
435
                    fetchedFrom,
436
                    "day"
437
                );
438
                if (daysToEdge < HOLIDAY_PREFETCH_THRESHOLD_DAYS) {
439
                    const prefetchStart = formatYMD(
440
                        addMonths(fetchedFrom, -HOLIDAY_PREFETCH_MONTHS)
441
                    );
442
                    // End at day before fetchedFrom to avoid overlap
443
                    const extensionEnd = formatYMD(addDays(fetchedFrom, -1));
444
                    // Fire and forget - don't await to avoid blocking, but catch errors
445
                    this.fetchHolidays(
446
                        libraryId,
447
                        prefetchStart,
448
                        extensionEnd
449
                    ).catch(() => {});
450
                }
451
            }
452
        },
453
        /**
454
         * Derive item types from bookableItems
455
         */
456
        deriveItemTypesFromBookableItems() {
457
            const typesMap = {};
458
            this.bookableItems.forEach(item => {
459
                // Use effective_item_type_id if present, fallback to item_type_id
460
                const typeId = item.effective_item_type_id || item.item_type_id;
461
                if (typeId) {
462
                    // Use the human-readable string if available
463
                    const label = item._strings?.item_type_id?.str ?? typeId;
464
                    typesMap[typeId] = label;
465
                }
466
            });
467
            this.itemTypes = Object.entries(typesMap).map(
468
                ([item_type_id, description]) => ({
469
                    item_type_id,
470
                    description,
471
                })
472
            );
473
        },
474
        /**
475
         * Save (POST) or update (PUT) a booking
476
         * If bookingId is present, update; else, create
477
         */
478
        saveOrUpdateBooking: withErrorHandling(async function (bookingData) {
479
            let result;
480
            if (bookingData.bookingId || bookingData.booking_id) {
481
                // Use bookingId from either field
482
                const id = bookingData.bookingId || bookingData.booking_id;
483
                result = await bookingApi.updateBooking(id, bookingData);
484
                // Update in store
485
                const idx = this.bookings.findIndex(
486
                    b => b.booking_id === result.booking_id
487
                );
488
                if (idx !== -1) this.bookings[idx] = result;
489
            } else {
490
                result = await bookingApi.createBooking(bookingData);
491
                this.bookings.push(result);
492
            }
493
            return result;
494
        }, "submit"),
495
    },
496
});
(-)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/functions.mjs (+40 lines)
Line 0 Link Here
1
/**
2
 * Generic utility functions for Vue components
3
 */
4
5
/**
6
 * Creates a debounced version of a function that delays invocation
7
 * until after `delay` milliseconds have elapsed since the last call.
8
 *
9
 * @template {(...args: any[]) => any} T
10
 * @param {T} fn - The function to debounce
11
 * @param {number} delay - Delay in milliseconds
12
 * @returns {(...args: Parameters<T>) => void}
13
 */
14
export function debounce(fn, delay) {
15
    let timeout;
16
    return function (...args) {
17
        clearTimeout(timeout);
18
        timeout = setTimeout(() => fn.apply(this, args), delay);
19
    };
20
}
21
22
/**
23
 * Creates a throttled version of a function that only invokes
24
 * at most once per `limit` milliseconds.
25
 *
26
 * @template {(...args: any[]) => any} T
27
 * @param {T} fn - The function to throttle
28
 * @param {number} limit - Minimum time between invocations in milliseconds
29
 * @returns {(...args: Parameters<T>) => void}
30
 */
31
export function throttle(fn, limit) {
32
    let inThrottle;
33
    return function (...args) {
34
        if (!inThrottle) {
35
            fn.apply(this, args);
36
            inThrottle = true;
37
            setTimeout(() => (inThrottle = false), limit);
38
        }
39
    };
40
}
(-)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 (+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":
(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (-584 / +289 lines)
Lines 3-8 const dayjs = require("dayjs"); Link Here
3
describe("Booking Modal Basic Tests", () => {
3
describe("Booking Modal Basic Tests", () => {
4
    let testData = {};
4
    let testData = {};
5
5
6
    // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests
7
    Cypress.on("uncaught:exception", () => false);
8
6
    // Ensure RESTBasicAuth is enabled before running tests
9
    // Ensure RESTBasicAuth is enabled before running tests
7
    before(() => {
10
    before(() => {
8
        cy.task("query", {
11
        cy.task("query", {
Lines 21-47 describe("Booking Modal Basic Tests", () => { Link Here
21
            .then(objects => {
24
            .then(objects => {
22
                testData = objects;
25
                testData = objects;
23
26
24
                // Update items to have different itemtypes and control API ordering
27
                // Update items to be bookable with different itemtypes
25
                // API orders by: homebranch.branchname, enumchron, dateaccessioned DESC
28
                return cy.task("query", {
26
                const itemUpdates = [
29
                    sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
27
                    // First in API order: homebranch='CPL', enumchron='A', dateaccessioned=newest
30
                    values: [objects.items[0].item_id],
28
                    cy.task("query", {
31
                }).then(() => cy.task("query", {
29
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
32
                    sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
30
                        values: [objects.items[0].item_id],
33
                    values: [objects.items[1].item_id],
31
                    }),
34
                })).then(() => cy.task("query", {
32
                    // Second in API order: homebranch='CPL', enumchron='B', dateaccessioned=older
35
                    sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'C', dateaccessioned = '2024-12-01' WHERE itemnumber = ?",
33
                    cy.task("query", {
36
                    values: [objects.items[2].item_id],
34
                        sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
37
                }));
35
                        values: [objects.items[1].item_id],
36
                    }),
37
                    // Third in API order: homebranch='CPL', enumchron='C', dateaccessioned=oldest
38
                    cy.task("query", {
39
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'C', dateaccessioned = '2024-12-01' WHERE itemnumber = ?",
40
                        values: [objects.items[2].item_id],
41
                    }),
42
                ];
43
44
                return Promise.all(itemUpdates);
45
            })
38
            })
46
            .then(() => {
39
            .then(() => {
47
                // Create a test patron using upstream pattern
40
                // Create a test patron using upstream pattern
Lines 99-156 describe("Booking Modal Basic Tests", () => { Link Here
99
        cy.get("#catalog_detail").should("be.visible");
92
        cy.get("#catalog_detail").should("be.visible");
100
93
101
        // The "Place booking" button should appear for bookable items
94
        // The "Place booking" button should appear for bookable items
102
        cy.get('[data-bs-target="#placeBookingModal"]')
95
        cy.get("[data-booking-modal]")
103
            .should("exist")
96
            .should("exist")
104
            .and("be.visible");
97
            .and("be.visible");
105
98
106
        // Click to open the booking modal
99
        // Click to open the booking modal
107
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
100
        cy.get("booking-modal-island .modal").should("exist");
101
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
108
102
109
        // Wait for modal to appear
103
        // Wait for modal to appear
110
        cy.get("#placeBookingModal").should("be.visible");
104
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
111
        cy.get("#placeBookingLabel")
105
            "be.visible"
106
        );
107
        cy.get("booking-modal-island .modal-title")
112
            .should("be.visible")
108
            .should("be.visible")
113
            .and("contain.text", "Place booking");
109
            .and("contain.text", "Place booking");
114
110
115
        // Verify modal structure and initial field states
111
        // Verify modal structure and initial field states
116
        cy.get("#booking_patron_id").should("exist").and("not.be.disabled");
112
        // Patron field should be enabled
113
        cy.vueSelectShouldBeEnabled("booking_patron");
117
114
118
        cy.get("#pickup_library_id").should("exist").and("be.disabled");
115
        // Pickup library should be disabled initially
116
        cy.vueSelectShouldBeDisabled("pickup_library_id");
119
117
120
        cy.get("#booking_itemtype").should("exist").and("be.disabled");
118
        // Item type should be disabled initially
119
        cy.vueSelectShouldBeDisabled("booking_itemtype");
121
120
122
        cy.get("#booking_item_id")
121
        // Item should be disabled initially
123
            .should("exist")
122
        cy.vueSelectShouldBeDisabled("booking_item_id");
124
            .and("be.disabled")
125
            .find("option[value='0']")
126
            .should("contain.text", "Any item");
127
123
128
        cy.get("#period")
124
        // Period should be disabled initially
125
        cy.get("#booking_period")
129
            .should("exist")
126
            .should("exist")
130
            .and("be.disabled")
127
            .and("be.disabled");
131
            .and("have.attr", "data-flatpickr-futuredate", "true");
132
133
        // Verify hidden fields exist
134
        cy.get("#booking_biblio_id").should("exist");
135
        cy.get("#booking_start_date").should("exist");
136
        cy.get("#booking_end_date").should("exist");
137
        cy.get("#booking_id").should("exist");
138
139
        // Check hidden fields with actual biblio_id from upstream data
140
        cy.get("#booking_biblio_id").should(
141
            "have.value",
142
            testData.biblio.biblio_id
143
        );
144
        cy.get("#booking_start_date").should("have.value", "");
145
        cy.get("#booking_end_date").should("have.value", "");
146
128
147
        // Verify form buttons
129
        // Verify form and submit button exist
148
        cy.get("#placeBookingForm button[type='submit']")
130
        cy.get('button[form="form-booking"][type="submit"]')
149
            .should("exist")
131
            .should("exist");
150
            .and("contain.text", "Submit");
151
132
152
        cy.get(".btn-close").should("exist");
133
        cy.get(".btn-close").should("exist");
153
        cy.get("[data-bs-dismiss='modal']").should("exist");
154
    });
134
    });
155
135
156
    it("should enable fields progressively based on user selections", () => {
136
    it("should enable fields progressively based on user selections", () => {
Lines 168-226 describe("Booking Modal Basic Tests", () => { Link Here
168
        );
148
        );
169
149
170
        // Open the modal
150
        // Open the modal
171
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
151
        cy.get("booking-modal-island .modal").should("exist");
172
        cy.get("#placeBookingModal").should("be.visible");
152
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
153
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
154
            "be.visible"
155
        );
173
156
174
        // Step 1: Initially only patron field should be enabled
157
        // Step 1: Initially only patron field should be enabled
175
        cy.get("#booking_patron_id").should("not.be.disabled");
158
        cy.vueSelectShouldBeEnabled("booking_patron");
176
        cy.get("#pickup_library_id").should("be.disabled");
159
        cy.vueSelectShouldBeDisabled("pickup_library_id");
177
        cy.get("#booking_itemtype").should("be.disabled");
160
        cy.vueSelectShouldBeDisabled("booking_itemtype");
178
        cy.get("#booking_item_id").should("be.disabled");
161
        cy.vueSelectShouldBeDisabled("booking_item_id");
179
        cy.get("#period").should("be.disabled");
162
        cy.get("#booking_period").should("be.disabled");
180
163
181
        // Step 2: Select patron - this triggers pickup locations API call
164
        // Step 2: Select patron - this triggers pickup locations API call
182
        cy.selectFromSelect2(
165
        cy.vueSelect(
183
            "#booking_patron_id",
166
            "booking_patron",
184
            `${testData.patron.surname}, ${testData.patron.firstname}`,
167
            testData.patron.cardnumber,
185
            testData.patron.cardnumber
168
            `${testData.patron.surname} ${testData.patron.firstname}`
186
        );
169
        );
187
170
188
        // Wait for pickup locations API call to complete
171
        // Wait for pickup locations API call to complete
189
        cy.wait("@getPickupLocations");
172
        cy.wait("@getPickupLocations");
190
173
191
        // Step 3: After patron selection and pickup locations load, other fields should become enabled
174
        // Step 3: After patron selection and pickup locations load, other fields should become enabled
192
        cy.get("#pickup_library_id").should("not.be.disabled");
175
        cy.vueSelectShouldBeEnabled("pickup_library_id");
193
        cy.get("#booking_itemtype").should("not.be.disabled");
176
        cy.vueSelectShouldBeEnabled("booking_itemtype");
194
        cy.get("#booking_item_id").should("not.be.disabled");
177
        cy.vueSelectShouldBeEnabled("booking_item_id");
195
        cy.get("#period").should("be.disabled"); // Still disabled until itemtype/item selected
178
        cy.get("#booking_period").should("be.disabled"); // Still disabled until itemtype/item selected
196
179
197
        // Step 4: Select pickup location
180
        // Step 4: Select pickup location
198
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
181
        cy.vueSelectByIndex("pickup_library_id", 0);
199
182
200
        // Step 5: Select item type - this triggers circulation rules API call
183
        // Step 5: Select item type - this triggers circulation rules API call
201
        cy.selectFromSelect2ByIndex("#booking_itemtype", 0); // Select first available itemtype
184
        cy.vueSelectByIndex("booking_itemtype", 0); // Select first available itemtype
202
185
203
        // Wait for circulation rules API call to complete
186
        // Wait for circulation rules API call to complete
204
        cy.wait("@getCirculationRules");
187
        cy.wait("@getCirculationRules");
205
188
206
        // After itemtype selection and circulation rules load, period should be enabled
189
        // After itemtype selection and circulation rules load, period should be enabled
207
        cy.get("#period").should("not.be.disabled");
190
        cy.get("#booking_period").should("not.be.disabled");
208
191
209
        // Step 6: Test clearing item type disables period again (comprehensive workflow)
192
        // Step 6: Test clearing item type disables period again (comprehensive workflow)
210
        cy.clearSelect2("#booking_itemtype");
193
        cy.vueSelectClear("booking_itemtype");
211
        cy.get("#period").should("be.disabled");
194
        cy.get("#booking_period").should("be.disabled");
212
195
213
        // Step 7: Select item instead of itemtype - this also triggers circulation rules
196
        // Step 7: Select item instead of itemtype - this also triggers circulation rules
214
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option
197
        cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option
215
198
216
        // Wait for circulation rules API call (item selection also triggers this)
199
        // Wait for circulation rules API call (item selection also triggers this)
217
        cy.wait("@getCirculationRules");
200
        cy.wait("@getCirculationRules");
218
201
219
        // Period should be enabled after item selection and circulation rules load
202
        // Period should be enabled after item selection and circulation rules load
220
        cy.get("#period").should("not.be.disabled");
203
        cy.get("#booking_period").should("not.be.disabled");
221
204
222
        // Verify that patron selection is now disabled (as per the modal's behavior)
223
        cy.get("#booking_patron_id").should("be.disabled");
224
    });
205
    });
225
206
226
    it("should handle item type and item dependencies correctly", () => {
207
    it("should handle item type and item dependencies correctly", () => {
Lines 238-357 describe("Booking Modal Basic Tests", () => { Link Here
238
        );
219
        );
239
220
240
        // Open the modal
221
        // Open the modal
241
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
222
        cy.get("booking-modal-island .modal").should("exist");
242
        cy.get("#placeBookingModal").should("be.visible");
223
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
224
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
225
            "be.visible"
226
        );
243
227
244
        // Setup: Select patron and pickup location first
228
        // Setup: Select patron and pickup location first
245
        cy.selectFromSelect2(
229
        cy.vueSelect(
246
            "#booking_patron_id",
230
            "booking_patron",
247
            `${testData.patron.surname}, ${testData.patron.firstname}`,
231
            testData.patron.cardnumber,
248
            testData.patron.cardnumber
232
            `${testData.patron.surname} ${testData.patron.firstname}`
249
        );
233
        );
250
        cy.wait("@getPickupLocations");
234
        cy.wait("@getPickupLocations");
251
235
252
        cy.get("#pickup_library_id").should("not.be.disabled");
236
        cy.vueSelectShouldBeEnabled("pickup_library_id");
253
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
237
        cy.vueSelectByIndex("pickup_library_id", 0);
254
238
255
        // Test Case 1: Select item first → should auto-populate and disable itemtype
239
        // Test Case 1: Select item first → should auto-populate and disable itemtype
256
        // Index 1 = first item in API order = enumchron='A' = BK itemtype
240
        cy.vueSelectByIndex("booking_item_id", 1);
257
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
258
        cy.wait("@getCirculationRules");
241
        cy.wait("@getCirculationRules");
259
242
260
        // Verify that item type gets selected automatically based on the item
243
        // Verify that item type gets auto-populated (value depends on which item the API returns first)
261
        cy.get("#booking_itemtype").should("have.value", "BK"); // enumchron='A' item
244
        cy.get("input#booking_itemtype")
262
245
            .closest(".v-select")
263
        // Verify that item type gets disabled when item is selected first
246
            .find(".vs__selected")
264
        cy.get("#booking_itemtype").should("be.disabled");
247
            .should("exist");
265
248
266
        // Verify that period field gets enabled after item selection
249
        // Verify that period field gets enabled after item selection
267
        cy.get("#period").should("not.be.disabled");
250
        cy.get("#booking_period").should("not.be.disabled");
268
251
269
        // Test Case 2: Reset item selection to "Any item" → itemtype should re-enable
252
        // Test Case 2: Reset item selection to "Any item" → itemtype should re-enable
270
        cy.selectFromSelect2ByIndex("#booking_item_id", 0);
253
        cy.vueSelectByIndex("booking_item_id", 0);
271
254
272
        // Wait for itemtype to become enabled (this is what we're actually waiting for)
255
        // Wait for itemtype to become enabled (this is what we're actually waiting for)
273
        cy.get("#booking_itemtype").should("not.be.disabled");
256
        cy.vueSelectShouldBeEnabled("booking_itemtype");
274
275
        // Verify that itemtype retains the value from the previously selected item
276
        cy.get("#booking_itemtype").should("have.value", "BK");
277
278
        // Period should be disabled again until itemtype/item is selected
279
        //cy.get("#period").should("be.disabled");
280
257
281
        // Test Case 3: Now select itemtype first → different workflow
258
        // Test Case 3: Now select itemtype first → different workflow
282
        cy.clearSelect2("#booking_itemtype");
259
        cy.vueSelectClear("booking_itemtype");
283
        cy.selectFromSelect2("#booking_itemtype", "Books"); // Select BK itemtype explicitly
260
        cy.vueSelectByIndex("booking_itemtype", 0); // Select first itemtype (BK)
284
        cy.wait("@getCirculationRules");
261
        cy.wait("@getCirculationRules");
285
262
286
        // Verify itemtype remains enabled when selected first
263
        // Verify itemtype remains enabled when selected first
287
        cy.get("#booking_itemtype").should("not.be.disabled");
264
        cy.vueSelectShouldBeEnabled("booking_itemtype");
288
        cy.get("#booking_itemtype").should("have.value", "BK");
289
265
290
        // Period should be enabled after itemtype selection
266
        // Period should be enabled after itemtype selection
291
        cy.get("#period").should("not.be.disabled");
267
        cy.get("#booking_period").should("not.be.disabled");
292
268
293
        // Test Case 3b: Verify that only 'Any item' option and items of selected type are enabled
269
        // Test Case 3b: Verify that only items of selected type are shown in dropdown
294
        // Since we selected 'BK' itemtype, verify only BK items and "Any item" are enabled
270
        // Open the item dropdown and check options
295
        cy.get("#booking_item_id > option").then($options => {
271
        cy.get("input#booking_item_id")
296
            const enabledOptions = $options.filter(":not(:disabled)");
272
            .closest(".v-select")
297
            enabledOptions.each(function () {
273
            .find(".vs__dropdown-toggle")
298
                const $option = cy.wrap(this);
274
            .click();
299
                // Get both the value and the data-itemtype attribute to make decisions
275
300
                $option.invoke("val").then(value => {
276
        cy.get("input#booking_item_id")
301
                    if (value === "0") {
277
            .closest(".v-select")
302
                        // We need to re-wrap the element since invoke('val') changed the subject
278
            .find(".vs__dropdown-menu")
303
                        cy.wrap(this).should("contain.text", "Any item");
279
            .should("be.visible")
304
                    } else {
280
            .find(".vs__dropdown-option")
305
                        // Re-wrap the element again for this assertion
281
            .should("have.length.at.least", 1);
306
                        // Should only be BK items (we have item 1 and item 3 as BK, item 2 as CF)
307
                        cy.wrap(this).should(
308
                            "have.attr",
309
                            "data-itemtype",
310
                            "BK"
311
                        );
312
                    }
313
                });
314
            });
315
        });
316
282
317
        // Test Case 4: Select item after itemtype → itemtype selection should become disabled
283
        // Close dropdown by clicking the modal title
318
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
284
        cy.get("booking-modal-island .modal-title").click();
319
285
320
        // Itemtype is now fixed, item should be selected
286
        // Test Case 4: Select item after itemtype → itemtype auto-populated
321
        cy.get("#booking_itemtype").should("be.disabled");
287
        cy.vueSelectByIndex("booking_item_id", 1);
322
        cy.get("#booking_item_id").should("not.have.value", "0"); // Not "Any item"
323
288
324
        // Period should still be enabled
289
        // Period should still be enabled
325
        cy.get("#period").should("not.be.disabled");
290
        cy.get("#booking_period").should("not.be.disabled");
326
291
327
        // Test Case 5: Reset item to "Any item", itemtype selection should be re-enabled
292
        // Test Case 5: Reset item to "Any item", itemtype selection should be re-enabled
328
        cy.selectFromSelect2ByIndex("#booking_item_id", 0);
293
        cy.vueSelectByIndex("booking_item_id", 0);
329
294
330
        // Wait for itemtype to become enabled (no item selected, so itemtype should be available)
295
        // Wait for itemtype to become enabled (no item selected, so itemtype should be available)
331
        cy.get("#booking_itemtype").should("not.be.disabled");
296
        cy.vueSelectShouldBeEnabled("booking_itemtype");
332
333
        // Verify both fields are in expected state
334
        cy.get("#booking_item_id").should("have.value", "0"); // Back to "Any item"
335
        cy.get("#period").should("not.be.disabled");
336
297
337
        // Test Case 6: Clear itemtype and verify all items become available again
298
        // Test Case 6: Clear itemtype and verify all items become available again
338
        cy.clearSelect2("#booking_itemtype");
299
        cy.vueSelectClear("booking_itemtype");
339
300
340
        // Both fields should be enabled
301
        // Both fields should be enabled
341
        cy.get("#booking_itemtype").should("not.be.disabled");
302
        cy.vueSelectShouldBeEnabled("booking_itemtype");
342
        cy.get("#booking_item_id").should("not.be.disabled");
303
        cy.vueSelectShouldBeEnabled("booking_item_id");
343
304
344
        // Open item dropdown to verify all items are now available (not filtered by itemtype)
305
        // Open item dropdown to verify items are available
345
        cy.get("#booking_item_id + .select2-container").click();
306
        cy.get("input#booking_item_id")
307
            .closest(".v-select")
308
            .find(".vs__dropdown-toggle")
309
            .click();
346
310
347
        // Should show "Any item" + all bookable items (not filtered by itemtype)
311
        // Should show options (not filtered by itemtype)
348
        cy.get(".select2-results__option").should("have.length.at.least", 2); // "Any item" + bookable items
312
        cy.get("input#booking_item_id")
349
        cy.get(".select2-results__option")
313
            .closest(".v-select")
350
            .first()
314
            .find(".vs__dropdown-menu")
351
            .should("contain.text", "Any item");
315
            .should("be.visible")
316
            .find(".vs__dropdown-option")
317
            .should("have.length.at.least", 2);
352
318
353
        // Close dropdown
319
        // Close dropdown
354
        cy.get("#placeBookingLabel").click();
320
        cy.get("booking-modal-island .modal-title").click();
355
    });
321
    });
356
322
357
    it("should handle form validation correctly", () => {
323
    it("should handle form validation correctly", () => {
Lines 360-378 describe("Booking Modal Basic Tests", () => { Link Here
360
        );
326
        );
361
327
362
        // Open the modal
328
        // Open the modal
363
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
329
        cy.get("booking-modal-island .modal").should("exist");
364
        cy.get("#placeBookingModal").should("be.visible");
330
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
365
331
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
366
        // Try to submit without filling required fields
332
            "be.visible"
367
        cy.get("#placeBookingForm button[type='submit']").click();
333
        );
368
334
369
        // Form should not submit and validation should prevent it
335
        // Submit button should be disabled without required fields
370
        cy.get("#placeBookingModal").should("be.visible");
336
        cy.get('button[form="form-booking"][type="submit"]').should(
337
            "be.disabled"
338
        );
371
339
372
        // Check for HTML5 validation attributes
340
        // Modal should still be visible
373
        cy.get("#booking_patron_id").should("have.attr", "required");
341
        cy.get("booking-modal-island .modal").should("be.visible");
374
        cy.get("#pickup_library_id").should("have.attr", "required");
375
        cy.get("#period").should("have.attr", "required");
376
    });
342
    });
377
343
378
    it("should successfully submit a booking", () => {
344
    it("should successfully submit a booking", () => {
Lines 381-424 describe("Booking Modal Basic Tests", () => { Link Here
381
        );
347
        );
382
348
383
        // Open the modal
349
        // Open the modal
384
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
350
        cy.get("booking-modal-island .modal").should("exist");
385
        cy.get("#placeBookingModal").should("be.visible");
351
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
352
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
353
            "be.visible"
354
        );
386
355
387
        // Fill in the form using real data from the database
356
        // Fill in the form using real data from the database
388
357
389
        // Step 1: Select patron
358
        // Step 1: Select patron
390
        cy.selectFromSelect2(
359
        cy.vueSelect(
391
            "#booking_patron_id",
360
            "booking_patron",
392
            `${testData.patron.surname}, ${testData.patron.firstname}`,
361
            testData.patron.cardnumber,
393
            testData.patron.cardnumber
362
            `${testData.patron.surname} ${testData.patron.firstname}`
394
        );
363
        );
395
364
396
        // Step 2: Select pickup location
365
        // Step 2: Select pickup location
397
        cy.get("#pickup_library_id").should("not.be.disabled");
366
        cy.vueSelectShouldBeEnabled("pickup_library_id");
398
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
367
        cy.vueSelectByIndex("pickup_library_id", 0);
399
368
400
        // Step 3: Select item (first bookable item)
369
        // Step 3: Select item (first bookable item)
401
        cy.get("#booking_item_id").should("not.be.disabled");
370
        cy.vueSelectShouldBeEnabled("booking_item_id");
402
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option
371
        cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option
403
372
404
        // Step 4: Set dates using flatpickr
373
        // Step 4: Set dates using flatpickr
405
        cy.get("#period").should("not.be.disabled");
374
        cy.get("#booking_period").should("not.be.disabled");
406
375
407
        // Use the flatpickr helper to select date range
376
        // Use the flatpickr helper to select date range
408
        // Note: Add enough days to account for lead period (3 days) to avoid past-date constraint
377
        // Note: Add enough days to account for lead period (3 days) to avoid past-date constraint
409
        const startDate = dayjs().add(5, "day");
378
        const startDate = dayjs().add(5, "day");
410
        const endDate = dayjs().add(10, "days");
379
        const endDate = dayjs().add(10, "days");
411
380
412
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
381
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
413
382
414
        // Step 5: Submit the form
383
        // Step 5: Submit the form
415
        cy.get("#placeBookingForm button[type='submit']")
384
        cy.get('button[form="form-booking"][type="submit"]')
416
            .should("not.be.disabled")
385
            .should("not.be.disabled")
417
            .click();
386
            .click();
418
387
419
        // Verify success - either success message or modal closure
388
        // Verify success - either success message or modal closure
420
        // (The exact success indication depends on the booking modal implementation)
389
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
421
        cy.get("#placeBookingModal", { timeout: 10000 }).should(
422
            "not.be.visible"
390
            "not.be.visible"
423
        );
391
        );
424
    });
392
    });
Lines 431-451 describe("Booking Modal Basic Tests", () => { Link Here
431
         * 1. "Any item" bookings can be successfully submitted with itemtype_id
399
         * 1. "Any item" bookings can be successfully submitted with itemtype_id
432
         * 2. The server performs optimal item selection based on future availability
400
         * 2. The server performs optimal item selection based on future availability
433
         * 3. An appropriate item is automatically assigned by the server
401
         * 3. An appropriate item is automatically assigned by the server
434
         *
435
         * When submitting an "any item" booking, the client sends itemtype_id
436
         * (or item_id if only one item is available) and the server selects
437
         * the optimal item with the longest future availability.
438
         *
439
         * Fixed Date Setup:
440
         * ================
441
         * - Today: June 10, 2026 (Wednesday)
442
         * - Timezone: Europe/London
443
         * - Start Date: June 15, 2026 (5 days from today)
444
         * - End Date: June 20, 2026 (10 days from today)
445
         */
402
         */
446
403
447
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
404
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
448
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
405
        // Using ["Date"] to avoid freezing timers which breaks async operations
449
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
406
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
450
        cy.clock(fixedToday, ["Date"]);
407
        cy.clock(fixedToday, ["Date"]);
451
408
Lines 458-505 describe("Booking Modal Basic Tests", () => { Link Here
458
        );
415
        );
459
416
460
        // Open the modal
417
        // Open the modal
461
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
418
        cy.get("booking-modal-island .modal").should("exist");
462
        cy.get("#placeBookingModal").should("be.visible");
419
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
420
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
421
            "be.visible"
422
        );
463
423
464
        // Step 1: Select patron
424
        // Step 1: Select patron
465
        cy.selectFromSelect2(
425
        cy.vueSelect(
466
            "#booking_patron_id",
426
            "booking_patron",
467
            `${testData.patron.surname}, ${testData.patron.firstname}`,
427
            testData.patron.cardnumber,
468
            testData.patron.cardnumber
428
            `${testData.patron.surname} ${testData.patron.firstname}`
469
        );
429
        );
470
430
471
        // Step 2: Select pickup location
431
        // Step 2: Select pickup location
472
        cy.get("#pickup_library_id").should("not.be.disabled");
432
        cy.vueSelectShouldBeEnabled("pickup_library_id");
473
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
433
        cy.vueSelectByIndex("pickup_library_id", 0);
474
434
475
        // Step 3: Select itemtype (to enable "Any item" for that type)
435
        // Step 3: Select itemtype (to enable "Any item" for that type)
476
        cy.get("#booking_itemtype").should("not.be.disabled");
436
        cy.vueSelectShouldBeEnabled("booking_itemtype");
477
        cy.selectFromSelect2ByIndex("#booking_itemtype", 0); // Select first itemtype
437
        cy.vueSelectByIndex("booking_itemtype", 0); // Select first itemtype
478
438
479
        // Step 4: Select "Any item" option (index 0)
439
        // Step 4: Select "Any item" option (index 0)
480
        cy.get("#booking_item_id").should("not.be.disabled");
440
        cy.vueSelectShouldBeEnabled("booking_item_id");
481
        cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item" option
441
        cy.vueSelectByIndex("booking_item_id", 0); // "Any item" option
482
483
        // Verify "Any item" is selected
484
        cy.get("#booking_item_id").should("have.value", "0");
485
442
486
        // Step 5: Set dates using flatpickr
443
        // Step 5: Set dates using flatpickr
487
        cy.get("#period").should("not.be.disabled");
444
        cy.get("#booking_period").should("not.be.disabled");
488
445
489
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
446
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
490
447
491
        // Wait a moment for onChange handlers to populate hidden fields
448
        // Wait a moment for onChange handlers to process
492
        cy.wait(500);
449
        cy.wait(500);
493
450
494
        // Step 6: Submit the form
451
        // Step 6: Submit the form
495
        // This will send either item_id (if only one available) or itemtype_id
452
        cy.get('button[form="form-booking"][type="submit"]')
496
        // to the server for optimal item selection
497
        cy.get("#placeBookingForm button[type='submit']")
498
            .should("not.be.disabled")
453
            .should("not.be.disabled")
499
            .click();
454
            .click();
500
455
501
        // Verify success - modal should close without errors
456
        // Verify success - modal should close without errors
502
        cy.get("#placeBookingModal", { timeout: 10000 }).should(
457
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
503
            "not.be.visible"
458
            "not.be.visible"
504
        );
459
        );
505
460
Lines 549-754 describe("Booking Modal Basic Tests", () => { Link Here
549
        );
504
        );
550
505
551
        // Open the modal
506
        // Open the modal
552
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
507
        cy.get("booking-modal-island .modal").should("exist");
553
        cy.get("#placeBookingModal").should("be.visible");
508
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
509
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
510
            "be.visible"
511
        );
554
512
555
        // Test basic form interactions without complex flatpickr scenarios
513
        // Test basic form interactions without complex flatpickr scenarios
556
514
557
        // Step 1: Select patron
515
        // Step 1: Select patron
558
        cy.selectFromSelect2(
516
        cy.vueSelect(
559
            "#booking_patron_id",
517
            "booking_patron",
560
            `${testData.patron.surname}, ${testData.patron.firstname}`,
518
            testData.patron.cardnumber,
561
            testData.patron.cardnumber
519
            `${testData.patron.surname} ${testData.patron.firstname}`
562
        );
520
        );
563
521
564
        // Step 2: Select pickup location
522
        // Step 2: Select pickup location
565
        cy.get("#pickup_library_id").should("not.be.disabled");
523
        cy.vueSelectShouldBeEnabled("pickup_library_id");
566
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
524
        cy.vueSelectByIndex("pickup_library_id", 0);
567
525
568
        // Step 3: Select an item
526
        // Step 3: Select an item
569
        cy.get("#booking_item_id").should("not.be.disabled");
527
        cy.vueSelectShouldBeEnabled("booking_item_id");
570
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option
528
        cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option
571
529
572
        // Step 4: Verify period field becomes enabled
530
        // Step 4: Verify period field becomes enabled
573
        cy.get("#period").should("not.be.disabled");
531
        cy.get("#booking_period").should("not.be.disabled");
574
532
575
        // Step 5: Verify we can close the modal
533
        // Step 5: Verify we can close the modal
576
        cy.get("#placeBookingModal .btn-close").first().click();
534
        cy.get("booking-modal-island .modal .btn-close").first().click();
577
        cy.get("#placeBookingModal").should("not.be.visible");
535
        cy.get("booking-modal-island .modal").should("not.be.visible");
578
    });
536
    });
579
537
580
    it("should handle visible and hidden fields on date selection", () => {
538
    it("should handle date selection and API submission correctly", () => {
581
        /**
539
        /**
582
         * Field Visibility and Format Validation Test
540
         * Date Selection and API Submission Test
583
         * ==========================================
541
         * =======================================
584
         *
542
         *
585
         * This test validates the dual-format system for date handling:
543
         * In the Vue version, there are no hidden fields for dates.
586
         * - Visible field: User-friendly display format (YYYY-MM-DD to YYYY-MM-DD)
544
         * Instead, dates are stored in the pinia store and sent via API.
587
         * - Hidden fields: Precise ISO timestamps for API submission
545
         * We verify dates via API intercept body assertions.
588
         *
589
         * Key functionality:
590
         * 1. Date picker shows readable format to users
591
         * 2. Hidden form fields store precise ISO timestamps
592
         * 3. Proper timezone handling and date boundary calculations
593
         * 4. Field visibility management during date selection
594
         */
546
         */
595
547
596
        // Set up authentication (using pattern from successful tests)
597
        cy.task("query", {
598
            sql: "UPDATE systempreferences SET value = '1' WHERE variable = 'RESTBasicAuth'",
599
        });
600
601
        // Create fresh test data using upstream pattern
602
        cy.task("insertSampleBiblio", {
603
            item_count: 1,
604
        })
605
            .then(objects => {
606
                testData = objects;
607
608
                // Update item to be bookable
609
                return cy.task("query", {
610
                    sql: "UPDATE items SET bookable = 1, itype = 'BK' WHERE itemnumber = ?",
611
                    values: [objects.items[0].item_id],
612
                });
613
            })
614
            .then(() => {
615
                // Create test patron
616
                return cy.task("buildSampleObject", {
617
                    object: "patron",
618
                    values: {
619
                        firstname: "Format",
620
                        surname: "Tester",
621
                        cardnumber: `FORMAT${Date.now()}`,
622
                        category_id: "PT",
623
                        library_id: testData.libraries[0].library_id,
624
                    },
625
                });
626
            })
627
            .then(mockPatron => {
628
                testData.patron = mockPatron;
629
630
                // Insert patron into database
631
                return cy.task("query", {
632
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
633
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
634
                    values: [
635
                        mockPatron.patron_id,
636
                        mockPatron.firstname,
637
                        mockPatron.surname,
638
                        mockPatron.cardnumber,
639
                        mockPatron.category_id,
640
                        mockPatron.library_id,
641
                        "1990-01-01",
642
                    ],
643
                });
644
            });
645
646
        // Set up API intercepts
548
        // Set up API intercepts
647
        cy.intercept(
549
        cy.intercept(
648
            "GET",
550
            "GET",
649
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
551
            `/api/v1/biblios/${testData.biblio.biblio_id}/pickup_locations*`
650
        ).as("getPickupLocations");
552
        ).as("getPickupLocations");
651
        cy.intercept("GET", "/api/v1/circulation_rules*", {
553
        cy.intercept("GET", "/api/v1/circulation_rules*").as(
652
            body: [
554
            "getCirculationRules"
653
                {
555
        );
654
                    branchcode: testData.libraries[0].library_id,
556
        cy.intercept("POST", "/api/v1/bookings").as("createBooking");
655
                    categorycode: "PT",
656
                    itemtype: "BK",
657
                    issuelength: 14,
658
                    renewalsallowed: 1,
659
                    renewalperiod: 7,
660
                },
661
            ],
662
        }).as("getCirculationRules");
663
557
664
        // Visit the page and open booking modal
558
        // Visit the page and open booking modal
665
        cy.visit(
559
        cy.visit(
666
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
560
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
667
        );
561
        );
668
        cy.title().should("contain", "Koha");
669
562
670
        // Open booking modal
563
        // Open booking modal
671
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
564
        cy.get("booking-modal-island .modal").should("exist");
672
        cy.get("#placeBookingModal").should("be.visible");
565
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
566
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
567
            "be.visible"
568
        );
673
569
674
        // Fill required fields progressively
570
        // Fill required fields progressively
675
        cy.selectFromSelect2(
571
        cy.vueSelect(
676
            "#booking_patron_id",
572
            "booking_patron",
677
            `${testData.patron.surname}, ${testData.patron.firstname}`,
573
            testData.patron.cardnumber,
678
            testData.patron.cardnumber
574
            `${testData.patron.surname} ${testData.patron.firstname}`
679
        );
575
        );
680
        cy.wait("@getPickupLocations");
576
        cy.wait("@getPickupLocations");
681
577
682
        cy.get("#pickup_library_id").should("not.be.disabled");
578
        cy.vueSelectShouldBeEnabled("pickup_library_id");
683
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
579
        cy.vueSelectByIndex("pickup_library_id", 0);
684
580
685
        cy.get("#booking_item_id").should("not.be.disabled");
581
        cy.vueSelectShouldBeEnabled("booking_item_id");
686
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select actual item (not "Any item")
582
        cy.vueSelectByIndex("booking_item_id", 1); // Select actual item (not "Any item")
687
        cy.wait("@getCirculationRules");
583
        cy.wait("@getCirculationRules");
688
584
689
        // Verify date picker is enabled
585
        // Verify date picker is enabled
690
        cy.get("#period").should("not.be.disabled");
586
        cy.get("#booking_period").should("not.be.disabled");
691
692
        // ========================================================================
693
        // TEST: Date Selection and Field Format Validation
694
        // ========================================================================
695
587
696
        // Define test dates
588
        // Define test dates
697
        const startDate = dayjs().add(3, "day");
589
        const startDate = dayjs().add(3, "day");
698
        const endDate = dayjs().add(6, "day");
590
        const endDate = dayjs().add(6, "day");
699
591
700
        // Select date range in flatpickr
592
        // Select date range in flatpickr
701
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
593
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
702
594
703
        // ========================================================================
595
        // Verify the dates were selected correctly via the flatpickr instance (format-agnostic)
704
        // VERIFY: Visible Field Format (User-Friendly Display)
596
        cy.get("#booking_period").should($el => {
705
        // ========================================================================
597
            const fp = $el[0]._flatpickr;
706
598
            expect(fp.selectedDates.length).to.eq(2);
707
        // The visible #period field should show user-friendly format
599
            expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(startDate.format("YYYY-MM-DD"));
708
        const expectedDisplayValue = `${startDate.format("YYYY-MM-DD")} to ${endDate.format("YYYY-MM-DD")}`;
600
            expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(endDate.format("YYYY-MM-DD"));
709
        cy.get("#period").should("have.value", expectedDisplayValue);
601
        });
710
        cy.log(`✓ Visible field format: ${expectedDisplayValue}`);
711
712
        // ========================================================================
713
        // VERIFY: Hidden Fields Format (ISO Timestamps for API)
714
        // ========================================================================
715
716
        // Hidden start date field: beginning of day in ISO format
717
        cy.get("#booking_start_date").should(
718
            "have.value",
719
            startDate.startOf("day").toISOString()
720
        );
721
        cy.log(
722
            `✓ Hidden start date: ${startDate.startOf("day").toISOString()}`
723
        );
724
725
        // Hidden end date field: end of day in ISO format
726
        cy.get("#booking_end_date").should(
727
            "have.value",
728
            endDate.endOf("day").toISOString()
729
        );
730
        cy.log(`✓ Hidden end date: ${endDate.endOf("day").toISOString()}`);
731
732
        // ========================================================================
733
        // VERIFY: Field Visibility Management
734
        // ========================================================================
735
602
736
        // Verify all required fields exist and are populated
603
        // Verify the period field is populated
737
        cy.get("#period").should("exist").and("not.have.value", "");
604
        cy.get("#booking_period").should("exist").and("not.have.value", "");
738
        cy.get("#booking_start_date").should("exist").and("not.have.value", "");
739
        cy.get("#booking_end_date").should("exist").and("not.have.value", "");
740
605
741
        cy.log("✓ CONFIRMED: Dual-format system working correctly");
606
        cy.log("✓ CONFIRMED: Date selection working correctly");
742
        cy.log(
607
        cy.log(
743
            "✓ User-friendly display format with precise ISO timestamps for API"
608
            "✓ User-friendly display format with dates stored in component state for API submission"
744
        );
609
        );
745
746
        // Clean up test data
747
        cy.task("deleteSampleObjects", testData);
748
        cy.task("query", {
749
            sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
750
            values: [testData.patron.patron_id],
751
        });
752
    });
610
    });
753
611
754
    it("should edit an existing booking successfully", () => {
612
    it("should edit an existing booking successfully", () => {
Lines 756-772 describe("Booking Modal Basic Tests", () => { Link Here
756
         * Booking Edit Functionality Test
614
         * Booking Edit Functionality Test
757
         * ==============================
615
         * ==============================
758
         *
616
         *
759
         * This test validates the complete edit booking workflow:
617
         * In the Vue version, edit mode is triggered by setting properties
760
         * - Pre-populating edit modal with existing booking data
618
         * on the booking-modal-island element via window.openBookingModal().
761
         * - Modifying booking details (pickup library, dates)
762
         * - Submitting updates via PUT API
763
         * - Validating success feedback and modal closure
764
         *
765
         * Key functionality:
766
         * 1. Edit modal pre-population from existing booking
767
         * 2. Form modification and validation
768
         * 3. PUT API request with proper payload structure
769
         * 4. Success feedback and UI state management
770
         */
619
         */
771
620
772
        const today = dayjs().startOf("day");
621
        const today = dayjs().startOf("day");
Lines 798-912 describe("Booking Modal Basic Tests", () => { Link Here
798
        });
647
        });
799
648
800
        // Use real API calls for all booking operations since we created real database data
649
        // Use real API calls for all booking operations since we created real database data
801
        // Only mock checkouts if it causes JavaScript errors (bookings API should return our real booking)
650
        // Only mock checkouts if it causes JavaScript errors
802
        cy.intercept("GET", "/api/v1/checkouts*", { body: [] }).as(
651
        cy.intercept("GET", "/api/v1/checkouts*", { body: [] }).as(
803
            "getCheckouts"
652
            "getCheckouts"
804
        );
653
        );
805
654
806
        // Let the PUT request go to the real API - it should work since we created a real booking
807
        // Optionally intercept just to log that it happened, but let it pass through
808
809
        // Visit the page
655
        // Visit the page
810
        cy.visit(
656
        cy.visit(
811
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
657
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
812
        );
658
        );
813
        cy.title().should("contain", "Koha");
659
        cy.title().should("contain", "Koha");
814
660
815
        // ========================================================================
661
        // Open edit modal by calling window.openBookingModal with booking properties
816
        // TEST: Open Edit Modal with Pre-populated Data
662
        cy.get("booking-modal-island .modal").should("exist");
817
        // ========================================================================
818
819
        // Set up edit booking attributes and click to open edit modal (using .then to ensure data is available)
820
        cy.then(() => {
663
        cy.then(() => {
821
            cy.get('[data-bs-target="#placeBookingModal"]')
664
            cy.window().then(win => {
822
                .first()
665
                win.openBookingModal({
823
                .invoke(
666
                    booking: testData.existingBooking.booking_id.toString(),
824
                    "attr",
667
                    patron: testData.patron.patron_id.toString(),
825
                    "data-booking",
668
                    itemnumber: testData.items[0].item_id.toString(),
826
                    testData.existingBooking.booking_id.toString()
669
                    pickup_library: testData.libraries[0].library_id,
827
                )
670
                    start_date: testData.existingBooking.start_date,
828
                .invoke(
671
                    end_date: testData.existingBooking.end_date,
829
                    "attr",
672
                    biblionumber: testData.biblio.biblio_id.toString(),
830
                    "data-patron",
673
                });
831
                    testData.patron.patron_id.toString()
674
            });
832
                )
833
                .invoke(
834
                    "attr",
835
                    "data-itemnumber",
836
                    testData.items[0].item_id.toString()
837
                )
838
                .invoke(
839
                    "attr",
840
                    "data-pickup_library",
841
                    testData.libraries[0].library_id
842
                )
843
                .invoke(
844
                    "attr",
845
                    "data-start_date",
846
                    testData.existingBooking.start_date
847
                )
848
                .invoke(
849
                    "attr",
850
                    "data-end_date",
851
                    testData.existingBooking.end_date
852
                )
853
                .click();
854
        });
675
        });
855
676
856
        // No need to wait for specific API calls since we're using real API responses
677
        // Verify edit modal setup
857
678
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
858
        // ========================================================================
679
            "be.visible"
859
        // VERIFY: Edit Modal Pre-population
680
        );
860
        // ========================================================================
681
        cy.get("booking-modal-island .modal-title").should(
861
682
            "contain",
862
        // Verify edit modal setup and pre-populated values
683
            "Edit booking"
863
        cy.get("#placeBookingLabel").should("contain", "Edit booking");
684
        );
864
865
        // Verify core edit fields exist and are properly pre-populated
866
        cy.then(() => {
867
            cy.get("#booking_id").should(
868
                "have.value",
869
                testData.existingBooking.booking_id.toString()
870
            );
871
            cy.log("✓ Booking ID populated correctly");
872
873
            // These fields will be pre-populated in edit mode
874
            cy.get("#booking_patron_id").should(
875
                "have.value",
876
                testData.patron.patron_id.toString()
877
            );
878
            cy.log("✓ Patron field pre-populated correctly");
879
880
            cy.get("#booking_item_id").should(
881
                "have.value",
882
                testData.items[0].item_id.toString()
883
            );
884
            cy.log("✓ Item field pre-populated correctly");
885
886
            cy.get("#pickup_library_id").should(
887
                "have.value",
888
                testData.libraries[0].library_id
889
            );
890
            cy.log("✓ Pickup library field pre-populated correctly");
891
892
            cy.get("#booking_start_date").should(
893
                "have.value",
894
                testData.existingBooking.start_date
895
            );
896
            cy.log("✓ Start date field pre-populated correctly");
897
898
            cy.get("#booking_end_date").should(
899
                "have.value",
900
                testData.existingBooking.end_date
901
            );
902
            cy.log("✓ End date field pre-populated correctly");
903
        });
904
685
905
        cy.log("✓ Edit modal pre-populated with existing booking data");
686
        cy.log("✓ Edit modal opened with pre-populated data");
906
687
907
        // ========================================================================
688
        // Verify core edit fields are pre-populated
908
        // VERIFY: Real API Integration
689
        cy.vueSelectShouldHaveValue(
909
        // ========================================================================
690
            "booking_patron",
691
            testData.patron.surname
692
        );
693
        cy.log("✓ Patron field pre-populated correctly");
910
694
911
        // Test that the booking can be retrieved via the real API
695
        // Test that the booking can be retrieved via the real API
912
        cy.then(() => {
696
        cy.then(() => {
Lines 955-965 describe("Booking Modal Basic Tests", () => { Link Here
955
        });
739
        });
956
740
957
        cy.log("✓ CONFIRMED: Edit booking functionality working correctly");
741
        cy.log("✓ CONFIRMED: Edit booking functionality working correctly");
958
        cy.log(
959
            "✓ Pre-population, modification, submission, and feedback all validated"
960
        );
961
742
962
        // Clean up the booking we created for this test (shared test data cleanup is handled by afterEach)
743
        // Clean up the booking we created for this test
963
        cy.then(() => {
744
        cy.then(() => {
964
            cy.task("query", {
745
            cy.task("query", {
965
                sql: "DELETE FROM bookings WHERE booking_id = ?",
746
                sql: "DELETE FROM bookings WHERE booking_id = ?",
Lines 971-1027 describe("Booking Modal Basic Tests", () => { Link Here
971
    it("should handle booking failure gracefully", () => {
752
    it("should handle booking failure gracefully", () => {
972
        /**
753
        /**
973
         * Comprehensive Error Handling and Recovery Test
754
         * Comprehensive Error Handling and Recovery Test
974
         * =============================================
975
         *
976
         * This test validates the complete error handling workflow for booking failures:
977
         * - API error response handling for various HTTP status codes (400, 409, 500)
978
         * - Error message display and user feedback
979
         * - Modal state preservation during errors (remains open)
980
         * - Form data preservation during errors (user doesn't lose input)
981
         * - Error recovery workflow (retry after fixing issues)
982
         * - Integration between error handling UI and API error responses
983
         * - User experience during error scenarios and successful recovery
984
         */
755
         */
985
756
986
        const today = dayjs().startOf("day");
757
        const today = dayjs().startOf("day");
987
758
988
        // Test-specific error scenarios to validate comprehensive error handling
759
        const primaryErrorScenario = {
989
        const errorScenarios = [
760
            name: "Validation Error (400)",
990
            {
761
            statusCode: 400,
991
                name: "Validation Error (400)",
762
            body: {
992
                statusCode: 400,
763
                error: "Invalid booking period",
993
                body: {
764
                errors: [
994
                    error: "Invalid booking period",
765
                    {
995
                    errors: [
766
                        message: "End date must be after start date",
996
                        {
767
                        path: "/end_date",
997
                            message: "End date must be after start date",
768
                    },
998
                            path: "/end_date",
769
                ],
999
                        },
1000
                    ],
1001
                },
1002
                expectedMessage: "Failure",
1003
            },
1004
            {
1005
                name: "Conflict Error (409)",
1006
                statusCode: 409,
1007
                body: {
1008
                    error: "Booking conflict",
1009
                    message: "Item is already booked for this period",
1010
                },
1011
                expectedMessage: "Failure",
1012
            },
1013
            {
1014
                name: "Server Error (500)",
1015
                statusCode: 500,
1016
                body: {
1017
                    error: "Internal server error",
1018
                },
1019
                expectedMessage: "Failure",
1020
            },
770
            },
1021
        ];
771
        };
1022
1023
        // Use the first error scenario for detailed testing (400 Validation Error)
1024
        const primaryErrorScenario = errorScenarios[0];
1025
772
1026
        // Setup API intercepts for error testing
773
        // Setup API intercepts for error testing
1027
        cy.intercept(
774
        cy.intercept(
Lines 1051-1148 describe("Booking Modal Basic Tests", () => { Link Here
1051
        cy.visit(
798
        cy.visit(
1052
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
799
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
1053
        );
800
        );
1054
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
801
        cy.get("booking-modal-island .modal").should("exist");
1055
        cy.get("#placeBookingModal").should("be.visible");
802
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
803
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
804
            "be.visible"
805
        );
1056
806
1057
        // ========================================================================
1058
        // PHASE 1: Complete Booking Form with Valid Data
807
        // PHASE 1: Complete Booking Form with Valid Data
1059
        // ========================================================================
1060
        cy.log("=== PHASE 1: Filling booking form with valid data ===");
808
        cy.log("=== PHASE 1: Filling booking form with valid data ===");
1061
809
1062
        // Step 1: Select patron
810
        // Step 1: Select patron
1063
        cy.selectFromSelect2(
811
        cy.vueSelect(
1064
            "#booking_patron_id",
812
            "booking_patron",
1065
            `${testData.patron.surname}, ${testData.patron.firstname}`,
813
            testData.patron.cardnumber,
1066
            testData.patron.cardnumber
814
            `${testData.patron.surname} ${testData.patron.firstname}`
1067
        );
815
        );
1068
        cy.wait("@getPickupLocations");
816
        cy.wait("@getPickupLocations");
1069
817
1070
        // Step 2: Select pickup location
818
        // Step 2: Select pickup location
1071
        cy.get("#pickup_library_id").should("not.be.disabled");
819
        cy.vueSelectShouldBeEnabled("pickup_library_id");
1072
        cy.selectFromSelect2("#pickup_library_id", testData.libraries[0].name);
820
        cy.vueSelectByIndex("pickup_library_id", 0);
1073
821
1074
        // Step 3: Select item (triggers circulation rules)
822
        // Step 3: Select item (triggers circulation rules)
1075
        cy.get("#booking_item_id").should("not.be.disabled");
823
        cy.vueSelectShouldBeEnabled("booking_item_id");
1076
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Skip "Any item" option
824
        cy.vueSelectByIndex("booking_item_id", 1); // Skip "Any item" option
1077
        cy.wait("@getCirculationRules");
825
        cy.wait("@getCirculationRules");
1078
826
1079
        // Step 4: Set booking dates
827
        // Step 4: Set booking dates
1080
        cy.get("#period").should("not.be.disabled");
828
        cy.get("#booking_period").should("not.be.disabled");
1081
        const startDate = today.add(7, "day");
829
        const startDate = today.add(7, "day");
1082
        const endDate = today.add(10, "day");
830
        const endDate = today.add(10, "day");
1083
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
831
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
1084
832
1085
        // Validate form is ready for submission
1086
        cy.get("#booking_patron_id").should(
1087
            "have.value",
1088
            testData.patron.patron_id.toString()
1089
        );
1090
        cy.get("#pickup_library_id").should(
1091
            "have.value",
1092
            testData.libraries[0].library_id
1093
        );
1094
        cy.get("#booking_item_id").should(
1095
            "have.value",
1096
            testData.items[0].item_id.toString()
1097
        );
1098
1099
        // ========================================================================
1100
        // PHASE 2: Submit Form and Trigger Error Response
833
        // PHASE 2: Submit Form and Trigger Error Response
1101
        // ========================================================================
1102
        cy.log(
834
        cy.log(
1103
            "=== PHASE 2: Submitting form and triggering error response ==="
835
            "=== PHASE 2: Submitting form and triggering error response ==="
1104
        );
836
        );
1105
837
1106
        // Submit the form and trigger the error
838
        // Submit the form and trigger the error
1107
        cy.get("#placeBookingForm button[type='submit']").click();
839
        cy.get('button[form="form-booking"][type="submit"]').click();
1108
        cy.wait("@failedBooking");
840
        cy.wait("@failedBooking");
1109
841
1110
        // ========================================================================
1111
        // PHASE 3: Validate Error Handling Behavior
842
        // PHASE 3: Validate Error Handling Behavior
1112
        // ========================================================================
1113
        cy.log("=== PHASE 3: Validating error handling behavior ===");
843
        cy.log("=== PHASE 3: Validating error handling behavior ===");
1114
844
1115
        // Verify error message is displayed
845
        // Verify error feedback is displayed (Vue uses .alert-danger within the modal)
1116
        cy.get("#booking_result").should(
846
        cy.get("booking-modal-island .modal .alert-danger").should("exist");
1117
            "contain",
847
        cy.log("✓ Error message displayed");
1118
            primaryErrorScenario.expectedMessage
1119
        );
1120
        cy.log(
1121
            `✓ Error message displayed: ${primaryErrorScenario.expectedMessage}`
1122
        );
1123
848
1124
        // Verify modal remains open on error (allows user to retry)
849
        // Verify modal remains open on error (allows user to retry)
1125
        cy.get("#placeBookingModal").should("be.visible");
850
        cy.get("booking-modal-island .modal").should("be.visible");
1126
        cy.log("✓ Modal remains open for user to retry");
851
        cy.log("✓ Modal remains open for user to retry");
1127
852
1128
        // Verify form fields remain populated (user doesn't lose their input)
1129
        cy.get("#booking_patron_id").should(
1130
            "have.value",
1131
            testData.patron.patron_id.toString()
1132
        );
1133
        cy.get("#pickup_library_id").should(
1134
            "have.value",
1135
            testData.libraries[0].library_id
1136
        );
1137
        cy.get("#booking_item_id").should(
1138
            "have.value",
1139
            testData.items[0].item_id.toString()
1140
        );
1141
        cy.log("✓ Form data preserved during error (user input not lost)");
1142
1143
        // ========================================================================
1144
        // PHASE 4: Test Error Recovery (Successful Retry)
853
        // PHASE 4: Test Error Recovery (Successful Retry)
1145
        // ========================================================================
1146
        cy.log("=== PHASE 4: Testing error recovery workflow ===");
854
        cy.log("=== PHASE 4: Testing error recovery workflow ===");
1147
855
1148
        // Setup successful booking intercept for retry attempt
856
        // Setup successful booking intercept for retry attempt
Lines 1160-1170 describe("Booking Modal Basic Tests", () => { Link Here
1160
        }).as("successfulRetry");
868
        }).as("successfulRetry");
1161
869
1162
        // Retry the submission (same form, no changes needed)
870
        // Retry the submission (same form, no changes needed)
1163
        cy.get("#placeBookingForm button[type='submit']").click();
871
        cy.get('button[form="form-booking"][type="submit"]').click();
1164
        cy.wait("@successfulRetry");
872
        cy.wait("@successfulRetry");
1165
873
1166
        // Verify successful retry behavior
874
        // Verify successful retry behavior
1167
        cy.get("#placeBookingModal").should("not.be.visible");
875
        cy.get("booking-modal-island .modal").should("not.be.visible");
1168
        cy.log("✓ Modal closes on successful retry");
876
        cy.log("✓ Modal closes on successful retry");
1169
877
1170
        // Check for success feedback (may appear as transient message)
878
        // Check for success feedback (may appear as transient message)
Lines 1183-1190 describe("Booking Modal Basic Tests", () => { Link Here
1183
        cy.log(
891
        cy.log(
1184
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
892
            "✓ CONFIRMED: Error handling and recovery workflow working correctly"
1185
        );
893
        );
1186
        cy.log(
1187
            "✓ Validated: API errors, user feedback, form preservation, and retry functionality"
1188
        );
1189
    });
894
    });
1190
});
895
});
(-)a/t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts (-662 / +383 lines)
Lines 5-10 dayjs.extend(isSameOrBefore); Link Here
5
describe("Booking Modal Date Picker Tests", () => {
5
describe("Booking Modal Date Picker Tests", () => {
6
    let testData = {};
6
    let testData = {};
7
7
8
    // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests
9
    Cypress.on("uncaught:exception", () => false);
10
8
    // Ensure RESTBasicAuth is enabled before running tests
11
    // Ensure RESTBasicAuth is enabled before running tests
9
    before(() => {
12
    before(() => {
10
        cy.task("query", {
13
        cy.task("query", {
Lines 24-43 describe("Booking Modal Date Picker Tests", () => { Link Here
24
                testData = objects;
27
                testData = objects;
25
28
26
                // Update items to be bookable with predictable itemtypes
29
                // Update items to be bookable with predictable itemtypes
27
                const itemUpdates = [
30
                return cy.task("query", {
28
                    // First item: BK (Books)
31
                    sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
29
                    cy.task("query", {
32
                    values: [objects.items[0].item_id],
30
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = 'A', dateaccessioned = '2024-12-03' WHERE itemnumber = ?",
33
                }).then(() => cy.task("query", {
31
                        values: [objects.items[0].item_id],
34
                    sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
32
                    }),
35
                    values: [objects.items[1].item_id],
33
                    // Second item: CF (Computer Files)
36
                }));
34
                    cy.task("query", {
35
                        sql: "UPDATE items SET bookable = 1, itype = 'CF', homebranch = 'CPL', enumchron = 'B', dateaccessioned = '2024-12-02' WHERE itemnumber = ?",
36
                        values: [objects.items[1].item_id],
37
                    }),
38
                ];
39
40
                return Promise.all(itemUpdates);
41
            })
37
            })
42
            .then(() => {
38
            .then(() => {
43
                // Create a test patron using upstream pattern
39
                // Create a test patron using upstream pattern
Lines 101-143 describe("Booking Modal Date Picker Tests", () => { Link Here
101
        );
97
        );
102
98
103
        // Open the modal
99
        // Open the modal
104
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
100
        cy.get("booking-modal-island .modal").should("exist");
105
        cy.get("#placeBookingModal").should("be.visible");
101
        cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
102
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
103
            "be.visible"
104
        );
106
105
107
        // Fill required fields to enable item selection
106
        // Fill required fields to enable item selection
108
        cy.selectFromSelect2(
107
        cy.vueSelect(
109
            "#booking_patron_id",
108
            "booking_patron",
110
            `${testData.patron.surname}, ${testData.patron.firstname}`,
109
            testData.patron.cardnumber,
111
            testData.patron.cardnumber
110
            `${testData.patron.surname} ${testData.patron.firstname}`
112
        );
111
        );
113
        cy.wait("@getPickupLocations");
112
        cy.wait("@getPickupLocations");
114
113
115
        cy.get("#pickup_library_id").should("not.be.disabled");
114
        cy.vueSelectShouldBeEnabled("pickup_library_id");
116
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
115
        cy.vueSelectByIndex("pickup_library_id", 0);
117
116
118
        // Only auto-select item if not overridden
117
        // Only auto-select item if not overridden
119
        if (options.skipItemSelection !== true) {
118
        if (options.skipItemSelection !== true) {
120
            cy.get("#booking_item_id").should("not.be.disabled");
119
            cy.vueSelectShouldBeEnabled("booking_item_id");
121
            cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first item
120
            cy.vueSelectByIndex("booking_item_id", 1); // Select second item (CF)
122
            cy.wait("@getCirculationRules");
121
            cy.wait("@getCirculationRules");
123
122
124
            // Verify date picker is now enabled
123
            // Verify date picker is now enabled
125
            cy.get("#period").should("not.be.disabled");
124
            cy.get("#booking_period").should("not.be.disabled");
126
        }
125
        }
127
    };
126
    };
128
127
129
    it("should initialize flatpickr with correct future-date constraints", () => {
128
    it("should initialize flatpickr with correct future-date constraints", () => {
130
        setupModalForDateTesting();
129
        setupModalForDateTesting();
131
130
132
        // Verify flatpickr is initialized with future-date attribute
133
        cy.get("#period").should(
134
            "have.attr",
135
            "data-flatpickr-futuredate",
136
            "true"
137
        );
138
139
        // Set up the flatpickr alias and open the calendar
131
        // Set up the flatpickr alias and open the calendar
140
        cy.get("#period").as("flatpickrInput");
132
        cy.get("#booking_period").as("flatpickrInput");
141
        cy.get("@flatpickrInput").openFlatpickr();
133
        cy.get("@flatpickrInput").openFlatpickr();
142
134
143
        // Verify past dates are disabled
135
        // Verify past dates are disabled
Lines 183-190 describe("Booking Modal Date Picker Tests", () => { Link Here
183
        ];
175
        ];
184
176
185
        // Create existing bookings in the database for the same item we'll test with
177
        // Create existing bookings in the database for the same item we'll test with
186
        const bookingInsertPromises = existingBookings.map(booking => {
178
        existingBookings.forEach(booking => {
187
            return cy.task("query", {
179
            cy.task("query", {
188
                sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
180
                sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
189
                      VALUES (?, ?, ?, ?, ?, ?, '1')`,
181
                      VALUES (?, ?, ?, ?, ?, ?, '1')`,
190
                values: [
182
                values: [
Lines 198-219 describe("Booking Modal Date Picker Tests", () => { Link Here
198
            });
190
            });
199
        });
191
        });
200
192
201
        // Wait for all bookings to be created
202
        cy.wrap(Promise.all(bookingInsertPromises));
203
204
        // Setup modal but skip auto-item selection so we can control which item to select
193
        // Setup modal but skip auto-item selection so we can control which item to select
205
        setupModalForDateTesting({ skipItemSelection: true });
194
        setupModalForDateTesting({ skipItemSelection: true });
206
195
207
        // Select the specific item that has the existing bookings
196
        // Select the specific item that has the existing bookings (by barcode, not index)
208
        cy.get("#booking_item_id").should("not.be.disabled");
197
        cy.vueSelectShouldBeEnabled("booking_item_id");
209
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item (not "Any item")
198
        cy.vueSelect("booking_item_id", testData.items[0].external_id, testData.items[0].external_id);
210
        cy.wait("@getCirculationRules");
199
        cy.wait("@getCirculationRules");
211
200
212
        // Verify date picker is now enabled
201
        // Verify date picker is now enabled
213
        cy.get("#period").should("not.be.disabled");
202
        cy.get("#booking_period").should("not.be.disabled");
214
203
215
        // Set up flatpickr alias and open the calendar
204
        // Set up flatpickr alias and open the calendar
216
        cy.get("#period").as("flatpickrInput");
205
        cy.get("#booking_period").as("flatpickrInput");
217
        cy.get("@flatpickrInput").openFlatpickr();
206
        cy.get("@flatpickrInput").openFlatpickr();
218
207
219
        cy.log(
208
        cy.log(
Lines 310-327 describe("Booking Modal Date Picker Tests", () => { Link Here
310
        cy.log(
299
        cy.log(
311
            "=== PHASE 4: Testing different item bookings don't conflict ==="
300
            "=== PHASE 4: Testing different item bookings don't conflict ==="
312
        );
301
        );
313
        /*
314
         * DIFFERENT ITEM BOOKING TEST:
315
         * ============================
316
         * Day:  34 35 36 37 38 39 40 41 42
317
         * Our Item (Item 1):   O  O  O  O  O  O  O  O  O
318
         * Other Item (Item 2): -  X  X  X  X  X  X  -  -
319
         *                         ^^^^^^^^^^^^^^^^^
320
         *                         Different item booking
321
         *
322
         * Expected: Days 35-40 should be AVAILABLE for our item even though
323
         *          they're booked for a different item (Item 2)
324
         */
325
302
326
        // Create a booking for the OTHER item (different from the one we're testing)
303
        // Create a booking for the OTHER item (different from the one we're testing)
327
        const differentItemBooking = {
304
        const differentItemBooking = {
Lines 391-410 describe("Booking Modal Date Picker Tests", () => { Link Here
391
        const startDate = dayjs().add(2, "day");
368
        const startDate = dayjs().add(2, "day");
392
        const endDate = dayjs().add(5, "day");
369
        const endDate = dayjs().add(5, "day");
393
370
394
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
371
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
395
372
396
        // Verify the dates were accepted (check that dates were set)
373
        // Verify the dates were accepted (check that period field has value)
397
        cy.get("#booking_start_date").should("not.have.value", "");
374
        cy.get("#booking_period").should("not.have.value", "");
398
        cy.get("#booking_end_date").should("not.have.value", "");
399
375
400
        // Try to submit - should succeed with valid dates
376
        // Try to submit - should succeed with valid dates
401
        cy.get("#placeBookingForm button[type='submit']")
377
        cy.get('button[form="form-booking"][type="submit"]')
402
            .should("not.be.disabled")
378
            .should("not.be.disabled")
403
            .click();
379
            .click();
404
380
405
        // Should either succeed (modal closes) or show specific validation error
381
        // Should either succeed (modal closes) or show specific validation error
406
        cy.get("body").then($body => {
382
        cy.get("body").then($body => {
407
            if ($body.find("#placeBookingModal:visible").length > 0) {
383
            if (
384
                $body.find("booking-modal-island .modal:visible").length > 0
385
            ) {
408
                // If modal is still visible, check for validation messages
386
                // If modal is still visible, check for validation messages
409
                cy.log(
387
                cy.log(
410
                    "Modal still visible - checking for validation feedback"
388
                    "Modal still visible - checking for validation feedback"
Lines 428-434 describe("Booking Modal Date Picker Tests", () => { Link Here
428
         * 1. Maximum date calculation and enforcement [issue period + (renewal period * max renewals)]
406
         * 1. Maximum date calculation and enforcement [issue period + (renewal period * max renewals)]
429
         * 2. Bold date styling for issue length and renewal lengths
407
         * 2. Bold date styling for issue length and renewal lengths
430
         * 3. Date selection limits based on circulation rules
408
         * 3. Date selection limits based on circulation rules
431
         * 4. Visual feedback for different booking period phases
432
         *
409
         *
433
         * CIRCULATION RULES DATE CALCULATION:
410
         * CIRCULATION RULES DATE CALCULATION:
434
         * ==================================
411
         * ==================================
Lines 438-465 describe("Booking Modal Date Picker Tests", () => { Link Here
438
         * - Renewals Allowed: 3 renewals
415
         * - Renewals Allowed: 3 renewals
439
         * - Renewal Period: 5 days each
416
         * - Renewal Period: 5 days each
440
         * - Total Maximum Period: 10 + (3 × 5) = 25 days
417
         * - Total Maximum Period: 10 + (3 × 5) = 25 days
441
         *
442
         * Clear Zone Date Layout (Starting Day 50):
443
         * ==========================================
444
         * Day:    48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
445
         * Period: O  O  S  I  I  I  I  I  I  I  I  I  R1 R1 R1 R1 R1 R2 R2 R2 R2 R2 R3 R3 R3 R3 R3 E  O
446
         *            ↑  ↑                             ↑              ↑              ↑              ↑  ↑
447
         *            │  │                             │              │              │              │  │
448
         *            │  └─ Start Date (Day 50)        │              │              │              │  └─ Available (after max)
449
         *            └─ Available (before start)      │              │              │              └─ Max Date (Day 75)
450
         *                                             │              │              └─ Renewal 3 Period (Days 70-74)
451
         *                                             │              └─ Renewal 2 Period (Days 65-69)
452
         *                                             └─ Renewal 1 Period (Days 60-64)
453
         *
454
         * Expected Visual Styling:
455
         * - Day 50: Bold (start date)
456
         * - Day 59: Bold (issue period)
457
         * - Day 64: Bold (renewal 1 period)
458
         * - Day 69: Bold (renewal 2 period)
459
         * - Day 75: Bold (renewal 3 period, Max selectable date)
460
         * - Day 76+: Not selectable (beyond max date)
461
         *
462
         * Legend: S = Start, I = Issue, R1/R2/R3 = Renewal periods, E = End, O = Available
463
         */
418
         */
464
419
465
        const today = dayjs().startOf("day");
420
        const today = dayjs().startOf("day");
Lines 481-492 describe("Booking Modal Date Picker Tests", () => { Link Here
481
        setupModalForDateTesting({ skipItemSelection: true });
436
        setupModalForDateTesting({ skipItemSelection: true });
482
437
483
        // Select item to get circulation rules
438
        // Select item to get circulation rules
484
        cy.get("#booking_item_id").should("not.be.disabled");
439
        cy.vueSelectShouldBeEnabled("booking_item_id");
485
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
440
        cy.vueSelectByIndex("booking_item_id", 1);
486
        cy.wait("@getDateTestRules");
441
        cy.wait("@getDateTestRules");
487
442
488
        cy.get("#period").should("not.be.disabled");
443
        cy.get("#booking_period").should("not.be.disabled");
489
        cy.get("#period").as("dateTestFlatpickr");
444
        cy.get("#booking_period").as("dateTestFlatpickr");
490
        cy.get("@dateTestFlatpickr").openFlatpickr();
445
        cy.get("@dateTestFlatpickr").openFlatpickr();
491
446
492
        // ========================================================================
447
        // ========================================================================
Lines 496-508 describe("Booking Modal Date Picker Tests", () => { Link Here
496
            "=== TEST 1: Testing maximum date calculation and enforcement ==="
451
            "=== TEST 1: Testing maximum date calculation and enforcement ==="
497
        );
452
        );
498
453
499
        /*
500
         * Maximum Date Calculation Test:
501
         * - Max period = issue (10) + renewals (3 × 5) = 25 days total
502
         * - If start date is Day 50, max end date should be Day 75 (50 + 25)
503
         * - Dates beyond Day 75 should not be selectable
504
         */
505
506
        // Test in clear zone starting at Day 50 to avoid conflicts
454
        // Test in clear zone starting at Day 50 to avoid conflicts
507
        const clearZoneStart = today.add(50, "day");
455
        const clearZoneStart = today.add(50, "day");
508
        const calculatedMaxDate = clearZoneStart.add(
456
        const calculatedMaxDate = clearZoneStart.add(
Lines 554-571 describe("Booking Modal Date Picker Tests", () => { Link Here
554
            "=== TEST 2: Testing bold date styling for issue and renewal periods ==="
502
            "=== TEST 2: Testing bold date styling for issue and renewal periods ==="
555
        );
503
        );
556
504
557
        /*
505
        // Vue version uses "booking-loan-boundary" class instead of "title"
558
         * Bold Date Styling Test:
559
         * Bold dates appear at circulation period endpoints to indicate
560
         * when issue/renewal periods end. We test the "title" class
561
         * applied to these specific dates.
562
         */
563
564
        // Calculate expected bold dates based on circulation rules (like original test)
565
        // Bold dates occur at: the start date itself, plus period endpoints
566
        const expectedBoldDates = [];
506
        const expectedBoldDates = [];
567
507
568
        // Start date is always bold (see place_booking.js boldDates = [new Date(startDate)])
508
        // Start date is always bold
569
        expectedBoldDates.push(clearZoneStart);
509
        expectedBoldDates.push(clearZoneStart);
570
510
571
        // Issue period end (after issuelength days)
511
        // Issue period end (after issuelength days)
Lines 587-593 describe("Booking Modal Date Picker Tests", () => { Link Here
587
            `Expected bold dates: ${expectedBoldDates.map(d => d.format("YYYY-MM-DD")).join(", ")}`
527
            `Expected bold dates: ${expectedBoldDates.map(d => d.format("YYYY-MM-DD")).join(", ")}`
588
        );
528
        );
589
529
590
        // Test each expected bold date has the "title" class (like original test)
530
        // Test each expected bold date has the "booking-loan-boundary" class
591
        expectedBoldDates.forEach(boldDate => {
531
        expectedBoldDates.forEach(boldDate => {
592
            if (
532
            if (
593
                boldDate.month() === clearZoneStart.month() ||
533
                boldDate.month() === clearZoneStart.month() ||
Lines 595-609 describe("Booking Modal Date Picker Tests", () => { Link Here
595
            ) {
535
            ) {
596
                cy.get("@dateTestFlatpickr")
536
                cy.get("@dateTestFlatpickr")
597
                    .getFlatpickrDate(boldDate.toDate())
537
                    .getFlatpickrDate(boldDate.toDate())
598
                    .should("have.class", "title");
538
                    .should("have.class", "booking-loan-boundary");
599
                cy.log(
539
                cy.log(
600
                    `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'title' class (bold)`
540
                    `✓ Day ${boldDate.format("YYYY-MM-DD")}: Has 'booking-loan-boundary' class (bold)`
601
                );
541
                );
602
            }
542
            }
603
        });
543
        });
604
544
605
        // Verify that only expected dates are bold (have "title" class)
545
        // Verify that only expected dates are bold (have "booking-loan-boundary" class)
606
        cy.get(".flatpickr-day.title").each($el => {
546
        cy.get(".flatpickr-day.booking-loan-boundary").each($el => {
607
            const ariaLabel = $el.attr("aria-label");
547
            const ariaLabel = $el.attr("aria-label");
608
            const date = dayjs(ariaLabel, "MMMM D, YYYY");
548
            const date = dayjs(ariaLabel, "MMMM D, YYYY");
609
            const isExpected = expectedBoldDates.some(expected =>
549
            const isExpected = expectedBoldDates.some(expected =>
Lines 623-660 describe("Booking Modal Date Picker Tests", () => { Link Here
623
            "=== TEST 3: Testing date range selection within circulation limits ==="
563
            "=== TEST 3: Testing date range selection within circulation limits ==="
624
        );
564
        );
625
565
626
        /*
627
         * Range Selection Test:
628
         * - Should be able to select valid range within max period
629
         * - Should accept full maximum range (25 days)
630
         * - Should populate start/end date fields correctly
631
         */
632
633
        // Clear the flatpickr selection from previous tests
566
        // Clear the flatpickr selection from previous tests
634
        cy.get("#period").clearFlatpickr();
567
        cy.get("#booking_period").clearFlatpickr();
635
568
636
        // Test selecting a mid-range period (issue + 1 renewal = 15 days)
569
        // Test selecting a mid-range period (issue + 1 renewal = 15 days)
637
        const midRangeEnd = clearZoneStart.add(15, "day");
570
        const midRangeEnd = clearZoneStart.add(15, "day");
638
571
639
        cy.get("#period").selectFlatpickrDateRange(clearZoneStart, midRangeEnd);
572
        cy.get("#booking_period").selectFlatpickrDateRange(
573
            clearZoneStart,
574
            midRangeEnd
575
        );
640
576
641
        // Verify dates were accepted
577
        // Verify dates were accepted (period field has value)
642
        cy.get("#booking_start_date").should("not.have.value", "");
578
        cy.get("#booking_period").should("not.have.value", "");
643
        cy.get("#booking_end_date").should("not.have.value", "");
644
579
645
        cy.log(
580
        cy.log(
646
            `✓ Mid-range selection accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${midRangeEnd.format("YYYY-MM-DD")}`
581
            `✓ Mid-range selection accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${midRangeEnd.format("YYYY-MM-DD")}`
647
        );
582
        );
648
583
649
        // Test selecting full maximum range
584
        // Test selecting full maximum range
650
        cy.get("#period").selectFlatpickrDateRange(
585
        cy.get("#booking_period").selectFlatpickrDateRange(
651
            clearZoneStart,
586
            clearZoneStart,
652
            calculatedMaxDate
587
            calculatedMaxDate
653
        );
588
        );
654
589
655
        // Verify full range was accepted
590
        // Verify full range was accepted
656
        cy.get("#booking_start_date").should("not.have.value", "");
591
        cy.get("#booking_period").should("not.have.value", "");
657
        cy.get("#booking_end_date").should("not.have.value", "");
658
592
659
        cy.log(
593
        cy.log(
660
            `✓ Full maximum range accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${calculatedMaxDate.format("YYYY-MM-DD")}`
594
            `✓ Full maximum range accepted: ${clearZoneStart.format("YYYY-MM-DD")} to ${calculatedMaxDate.format("YYYY-MM-DD")}`
Lines 663-705 describe("Booking Modal Date Picker Tests", () => { Link Here
663
        cy.log(
597
        cy.log(
664
            "✓ CONFIRMED: Circulation rules date calculations and visual feedback working correctly"
598
            "✓ CONFIRMED: Circulation rules date calculations and visual feedback working correctly"
665
        );
599
        );
666
        cy.log(
667
            `✓ Validated: ${dateTestCirculationRules.issuelength}-day issue + ${dateTestCirculationRules.renewalsallowed} renewals × ${dateTestCirculationRules.renewalperiod} days = ${dateTestCirculationRules.issuelength + dateTestCirculationRules.renewalsallowed * dateTestCirculationRules.renewalperiod}-day maximum period`
668
        );
669
    });
600
    });
670
601
671
    it("should handle lead and trail periods", () => {
602
    it("should handle lead and trail periods", () => {
672
        /**
603
        /**
673
         * Lead and Trail Period Behaviour Tests (with Bidirectional Enhancement)
604
         * Lead and Trail Period Behaviour Tests
674
         * ======================================================================
605
         * ======================================================================
675
         *
606
         *
676
         * Test Coverage:
607
         * In the Vue version, lead/trail periods are indicated via:
677
         * 1. Lead period visual hints (CSS classes) in clear zone
608
         * - booking-day--hover-lead / booking-day--hover-trail classes on hover
678
         * 2. Trail period visual hints (CSS classes) in clear zone
609
         * - flatpickr-disabled class for dates that cannot be selected
679
         * 3a. Lead period conflicts with past dates (leadDisable)
610
         * - booking-marker-dot--lead / booking-marker-dot--trail for marker dots
680
         * 3b. Lead period conflicts with existing booking ACTUAL dates (leadDisable)
681
         * 3c. NEW BIDIRECTIONAL: Lead period conflicts with existing booking TRAIL period (leadDisable)
682
         * 4a. Trail period conflicts with existing booking ACTUAL dates (trailDisable)
683
         * 4b. NEW BIDIRECTIONAL: Trail period conflicts with existing booking LEAD period (trailDisable)
684
         * 5. Max date selectable when trail period is clear of existing booking
685
         *
686
         * CRITICAL ENHANCEMENT: Bidirectional Lead/Trail Period Checking
687
         * ==============================================================
688
         * This test validates that lead/trail periods work in BOTH directions:
689
         * - New booking's LEAD period must not conflict with existing booking's TRAIL period
690
         * - New booking's TRAIL period must not conflict with existing booking's LEAD period
691
         * - This ensures full "protected periods" around existing bookings are respected
692
         *
611
         *
693
         * PROTECTED PERIOD CONCEPT:
612
         * The Vue version disables dates with lead/trail conflicts via the
694
         * ========================
613
         * disable function rather than applying leadDisable/trailDisable classes.
695
         * Each existing booking has a "protected period" = Lead + Actual + Trail
696
         * New bookings must ensure their Lead + Actual + Trail does not overlap
697
         * with ANY part of existing bookings' protected periods.
698
         *
614
         *
699
         * Fixed Date Setup:
615
         * Fixed Date Setup:
700
         * ================
616
         * ================
701
         * - Today: June 10, 2026 (Wednesday)
617
         * - Today: June 10, 2026 (Wednesday)
702
         * - Timezone: Europe/London
703
         * - Lead Period: 2 days
618
         * - Lead Period: 2 days
704
         * - Trail Period: 3 days
619
         * - Trail Period: 3 days
705
         * - Issue Length: 3 days
620
         * - Issue Length: 3 days
Lines 708-742 describe("Booking Modal Date Picker Tests", () => { Link Here
708
         * - Max Booking Period: 3 + (2 × 2) = 7 days
623
         * - Max Booking Period: 3 + (2 × 2) = 7 days
709
         *
624
         *
710
         * Blocker Booking: June 25-27, 2026
625
         * Blocker Booking: June 25-27, 2026
711
         * - Blocker's LEAD period: June 23-24 (2 days before start)
712
         * - Blocker's ACTUAL dates: June 25-27
713
         * - Blocker's TRAIL period: June 28-30 (3 days after end)
714
         * - Total PROTECTED period: June 23-30
715
         *
716
         * Timeline:
717
         * =========
718
         * June/July 2026
719
         * Sun Mon Tue Wed Thu Fri Sat
720
         *      8   9  10  11  12  13   ← 10 = TODAY
721
         *  14  15  16  17  18  19  20
722
         *  21  22  23  24  25  26  27   ← 23-24 = BLOCKER LEAD, 25-27 = BLOCKER ACTUAL
723
         *  28  29  30   1   2   3   4   ← 28-30 = BLOCKER TRAIL, July 3 = first clear after
724
         *
725
         * Test Scenarios:
726
         * ==============
727
         * Phase 1: Hover June 13 → Lead June 11-12 (clear) → no leadDisable
728
         * Phase 2: Select June 13, hover June 16 → Trail June 17-19 (clear) → no trailDisable
729
         * Phase 3a: Hover June 11 → Lead June 9-10, June 9 is past → leadDisable
730
         * Phase 3b: Hover June 29 → Lead June 27-28, June 27 is in blocker ACTUAL → leadDisable
731
         * Phase 3c: NEW - Hover July 1 → Lead June 29-30, overlaps blocker TRAIL → leadDisable
732
         * Phase 3d: NEW - Hover July 2 → Lead June 30-July 1, June 30 in blocker TRAIL → leadDisable
733
         * Phase 4a: Select June 20, hover June 23 → Trail June 24-26 overlaps blocker ACTUAL → trailDisable
734
         * Phase 4b: NEW - Select June 13, hover June 21 → Trail June 22-24, overlaps blocker LEAD → trailDisable
735
         * Phase 5: Select June 13, hover June 20 (max) → Trail June 21-23 (clear) → selectable
736
         */
626
         */
737
627
738
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
628
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
739
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
740
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
629
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
741
        cy.clock(fixedToday, ["Date"]);
630
        cy.clock(fixedToday, ["Date"]);
742
        cy.log(`Fixed today: June 10, 2026`);
631
        cy.log(`Fixed today: June 10, 2026`);
Lines 765-771 describe("Booking Modal Date Picker Tests", () => { Link Here
765
654
766
        cy.task("query", {
655
        cy.task("query", {
767
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
656
            sql: `INSERT INTO bookings (biblio_id, item_id, patron_id, start_date, end_date, pickup_library_id, status)
768
                  VALUES (?, ?, ?, ?, ?, ?, '1')`,
657
                  VALUES (?, ?, ?, ?, ?, ?, ?)`,
769
            values: [
658
            values: [
770
                testData.biblio.biblio_id,
659
                testData.biblio.biblio_id,
771
                testData.items[0].item_id,
660
                testData.items[0].item_id,
Lines 773-778 describe("Booking Modal Date Picker Tests", () => { Link Here
773
                blockerStart,
662
                blockerStart,
774
                blockerEnd,
663
                blockerEnd,
775
                testData.libraries[0].library_id,
664
                testData.libraries[0].library_id,
665
                "new",
776
            ],
666
            ],
777
        });
667
        });
778
        cy.log(`Blocker booking created: June 25-27, 2026`);
668
        cy.log(`Blocker booking created: June 25-27, 2026`);
Lines 780-1079 describe("Booking Modal Date Picker Tests", () => { Link Here
780
        // Setup modal
670
        // Setup modal
781
        setupModalForDateTesting({ skipItemSelection: true });
671
        setupModalForDateTesting({ skipItemSelection: true });
782
672
783
        cy.get("#booking_item_id").should("not.be.disabled");
673
        // Select the item that has the blocker booking (items[0] = index 0)
784
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
674
        cy.vueSelectShouldBeEnabled("booking_item_id");
675
        cy.vueSelectByIndex("booking_item_id", 0);
785
        cy.wait("@getFixedDateRules");
676
        cy.wait("@getFixedDateRules");
786
677
787
        cy.get("#period").should("not.be.disabled");
678
        cy.get("#booking_period").should("not.be.disabled");
788
        cy.get("#period").as("fp");
679
        cy.get("#booking_period").as("fp");
789
        cy.get("@fp").openFlatpickr();
680
        cy.get("@fp").openFlatpickr();
790
681
791
        // Helper to get a specific date element by ISO date string
682
        // Helpers that use native events to avoid detached DOM errors from Vue re-renders
683
        const monthNames = ["January", "February", "March", "April", "May", "June",
684
            "July", "August", "September", "October", "November", "December"];
685
        const getDateSelector = (isoDate: string) => {
686
            const d = dayjs(isoDate);
687
            return `.flatpickr-day[aria-label="${monthNames[d.month()]} ${d.date()}, ${d.year()}"]`;
688
        };
689
        const hoverDateByISO = (isoDate: string) => {
690
            cy.get(getDateSelector(isoDate))
691
                .should("be.visible")
692
                .then($el => {
693
                    $el[0].dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
694
                });
695
        };
696
        const clickDateByISO = (isoDate: string) => {
697
            cy.get(getDateSelector(isoDate))
698
                .should("be.visible")
699
                .then($el => $el[0].click());
700
        };
792
        const getDateByISO = (isoDate: string) => {
701
        const getDateByISO = (isoDate: string) => {
793
            const date = new Date(isoDate);
702
            const date = new Date(isoDate);
794
            return cy.get("@fp").getFlatpickrDate(date);
703
            return cy.get("@fp").getFlatpickrDate(date);
795
        };
704
        };
796
705
797
        // ========================================================================
706
        // ========================================================================
798
        // PHASE 1: Lead Period Clear - Visual Classes
707
        // PHASE 1: Lead Period - Hover shows lead markers
799
        // ========================================================================
708
        // ========================================================================
800
        cy.log("=== PHASE 1: Lead period visual hints in clear zone ===");
709
        cy.log("=== PHASE 1: Lead period visual hints on hover ===");
801
802
        /**
803
         * Hover June 13 as potential start date
804
         * Lead period: June 11-12 (both after today June 10, no booking conflict)
805
         * Expected: leadRangeStart on June 11, leadRange on June 12, no leadDisable on June 13
806
         */
807
808
        getDateByISO("2026-06-13").trigger("mouseover");
809
810
        // Check lead period classes
811
        getDateByISO("2026-06-11")
812
            .should("have.class", "leadRangeStart")
813
            .and("have.class", "leadRange");
814
710
815
        getDateByISO("2026-06-12")
711
        // Hover June 13 as potential start date
816
            .should("have.class", "leadRange")
712
        // Lead period: June 11-12 (both after today June 10, no booking conflict)
817
            .and("have.class", "leadRangeEnd");
713
        hoverDateByISO("2026-06-13");
818
714
819
        // Hovered date should NOT have leadDisable (lead period is clear)
715
        // June 11-12 are clear, so June 13 should NOT be disabled
820
        getDateByISO("2026-06-13").should("not.have.class", "leadDisable");
716
        getDateByISO("2026-06-13").should(
717
            "not.have.class",
718
            "flatpickr-disabled"
719
        );
821
720
822
        // ========================================================================
721
        // ========================================================================
823
        // PHASE 2: Trail Period Clear - Visual Classes
722
        // PHASE 2: Trail Period - Hover shows trail markers
824
        // ========================================================================
723
        // ========================================================================
825
        cy.log("=== PHASE 2: Trail period visual hints in clear zone ===");
724
        cy.log("=== PHASE 2: Trail period visual hints on hover ===");
826
725
827
        /**
726
        // Select June 13 as start date
828
         * Select June 13 as start date (lead June 11-12 is clear)
727
        clickDateByISO("2026-06-13");
829
         * Then hover June 16 as potential end date
830
         * Trail period calculation: trailStart = hoverDate + 1, trailEnd = hoverDate + 3
831
         * So: trailStart = June 17, trailEnd = June 19
832
         * Classes: June 17 = trailRangeStart + trailRange, June 18 = trailRange, June 19 = trailRange + trailRangeEnd
833
         */
834
835
        // Select June 13 as start date (same date we just hovered - lead is clear)
836
        getDateByISO("2026-06-13").click();
837
728
838
        // Hover June 16 as potential end date
729
        // Hover June 16 as potential end date
839
        getDateByISO("2026-06-16").trigger("mouseover");
730
        // Trail period: June 17-19 (clear of any bookings)
731
        hoverDateByISO("2026-06-16");
840
732
841
        // Check trail period classes
733
        // June 16 should not be disabled (trail is clear)
842
        getDateByISO("2026-06-17")
734
        getDateByISO("2026-06-16").should(
843
            .should("have.class", "trailRangeStart")
735
            "not.have.class",
844
            .and("have.class", "trailRange");
736
            "flatpickr-disabled"
845
737
        );
846
        getDateByISO("2026-06-18").should("have.class", "trailRange");
847
848
        getDateByISO("2026-06-19")
849
            .should("have.class", "trailRangeEnd")
850
            .and("have.class", "trailRange");
851
852
        // Hovered date (June 16) should NOT have trailDisable (trail period is clear)
853
        getDateByISO("2026-06-16").should("not.have.class", "trailDisable");
854
738
855
        // Clear selection for next phase
739
        // Clear selection for next phase
856
        cy.get("#period").clearFlatpickr();
740
        cy.get("#booking_period").clearFlatpickr();
857
        cy.get("@fp").openFlatpickr();
741
        cy.get("@fp").openFlatpickr();
858
742
859
        // ========================================================================
743
        // ========================================================================
860
        // PHASE 3: Lead Period Conflict - Past Dates and Existing bookings
744
        // PHASE 3: Lead Period Conflict - Existing bookings
861
        // ========================================================================
745
        // ========================================================================
862
        cy.log("=== PHASE 3: Lead period conflicts ===");
746
        cy.log("=== PHASE 3: Lead period conflicts ===");
863
747
864
        /**
748
        // Hover June 11 - Lead period (June 9-10), June 9 is past
865
         * Hover June 11 as potential start date
749
        // Vue version only disables when lead period has BOOKING conflicts,
866
         * Lead period: June 9-10
750
        // not when lead dates are in the past. No bookings on June 9-10.
867
         * June 9 is in the past (before today June 10)
751
        hoverDateByISO("2026-06-11");
868
         * Expected: leadDisable on June 11 because lead period extends into past
752
        getDateByISO("2026-06-11").should(
869
         */
753
            "not.have.class",
870
754
            "flatpickr-disabled"
871
        getDateByISO("2026-06-11").trigger("mouseover");
755
        );
872
873
        // June 11 should have leadDisable because lead period (June 9-10) includes past date
874
        getDateByISO("2026-06-11").should("have.class", "leadDisable");
875
876
        /**
877
         * Hover June 29 as potential start date
878
         * Lead period: June 27-28
879
         * June 27 is in the existing booking (25-27 June)
880
         * Expected: leadDisable on June 29 because lead period extends into existing booking
881
         */
882
883
        getDateByISO("2026-06-29").trigger("mouseover");
884
756
885
        // June 29 should have leadDisable because lead period (June 27-28) includes existing booking date
757
        // Hover June 29 - Lead period (June 27-28), June 27 is in blocker booking
886
        getDateByISO("2026-06-29").should("have.class", "leadDisable");
758
        hoverDateByISO("2026-06-29");
759
        getDateByISO("2026-06-29").should(
760
            "have.class",
761
            "flatpickr-disabled"
762
        );
887
763
888
        // ========================================================================
764
        // ========================================================================
889
        // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
765
        // PHASE 3c: BIDIRECTIONAL - Lead Period Conflicts with Existing Booking TRAIL
890
        // ========================================================================
766
        // ========================================================================
891
767
892
        /**
768
        // July 1: Lead June 29-30 → overlaps blocker trail (June 28-30) → DISABLED
893
         * NEW BIDIRECTIONAL Conflict Scenario:
769
        hoverDateByISO("2026-07-01");
894
         * Blocker booking end: June 27
770
        getDateByISO("2026-07-01").should(
895
         * Blocker's TRAIL period: June 28-30 (3 days after end)
771
            "have.class",
896
         *
772
            "flatpickr-disabled"
897
         * Test start dates where NEW booking's lead overlaps with blocker's TRAIL:
773
        );
898
         * - July 1: Lead June 29-30 → June 29-30 are in blocker trail (June 28-30) → DISABLED
899
         * - July 2: Lead June 30-July 1 → June 30 is in blocker trail → DISABLED
900
         *
901
         * This is the KEY enhancement: respecting existing booking's trail period!
902
         */
903
904
        // Hover July 1 - lead period (June 29-30) overlaps blocker's trail (June 28-30)
905
        getDateByISO("2026-07-01").trigger("mouseover");
906
        getDateByISO("2026-07-01").should("have.class", "leadDisable");
907
774
908
        // Hover July 2 - lead period (June 30-July 1) still overlaps blocker's trail at June 30
775
        // July 2: Lead June 30-July 1 → June 30 in blocker trail → DISABLED
909
        getDateByISO("2026-07-02").trigger("mouseover");
776
        hoverDateByISO("2026-07-02");
910
        getDateByISO("2026-07-02").should("have.class", "leadDisable");
777
        getDateByISO("2026-07-02").should(
778
            "have.class",
779
            "flatpickr-disabled"
780
        );
911
781
912
        // ========================================================================
782
        // ========================================================================
913
        // PHASE 3d: First Clear Start Date After Blocker's Protected Period
783
        // PHASE 3d: First Clear Start Date After Blocker's Protected Period
914
        // ========================================================================
784
        // ========================================================================
915
785
916
        /**
786
        // July 3: Lead July 1-2 → clear of blocker trail → NOT disabled
917
         * Verify that July 3 is the first selectable start date after blocker:
787
        hoverDateByISO("2026-07-03");
918
         * - July 3: Lead July 1-2 → completely clear of blocker trail (ends June 30) → no leadDisable
788
        getDateByISO("2026-07-03").should(
919
         */
789
            "not.have.class",
920
790
            "flatpickr-disabled"
921
        getDateByISO("2026-07-03").trigger("mouseover");
791
        );
922
        getDateByISO("2026-07-03").should("not.have.class", "leadDisable");
923
792
924
        // ========================================================================
793
        // ========================================================================
925
        // PHASE 4a: Trail Period Conflict - Existing Booking ACTUAL Dates
794
        // PHASE 4a: Trail Period Conflict - Existing Booking ACTUAL Dates
926
        // ========================================================================
795
        // ========================================================================
927
796
928
        /**
797
        // Select June 20 as start date (lead June 18-19, both clear)
929
         * Select June 20 as start date (lead June 18-19, both clear)
798
        clickDateByISO("2026-06-20");
930
         * Then hover June 23 as potential end date
931
         * Trail period: June 24-26
932
         * Blocker booking ACTUAL: June 25-27 (partial overlap)
933
         * Expected: trailDisable on June 23
934
         */
935
936
        // Select June 20 as start date
937
        getDateByISO("2026-06-20").click();
938
799
939
        // Hover June 23 as potential end date
800
        // Hover June 23 - Trail (June 24-26) overlaps blocker ACTUAL (June 25-27)
940
        getDateByISO("2026-06-23").trigger("mouseover");
801
        hoverDateByISO("2026-06-23");
941
802
        getDateByISO("2026-06-23").should(
942
        // June 23 should have trailDisable because trail period (June 24-26) overlaps blocker ACTUAL (June 25-27)
803
            "have.class",
943
        getDateByISO("2026-06-23").should("have.class", "trailDisable");
804
            "flatpickr-disabled"
805
        );
944
806
945
        // Clear selection for next phase
807
        // Clear selection for next phase
946
        cy.get("#period").clearFlatpickr();
808
        cy.get("#booking_period").clearFlatpickr();
947
        cy.get("@fp").openFlatpickr();
809
        cy.get("@fp").openFlatpickr();
948
810
949
        // ========================================================================
811
        // ========================================================================
950
        // PHASE 4b: BIDIRECTIONAL - Trail Period Conflicts with Existing Booking LEAD
812
        // PHASE 4b: BIDIRECTIONAL - Trail Period Conflicts with Existing Booking LEAD
951
        // ========================================================================
813
        // ========================================================================
952
814
953
        /**
815
        // Select June 13 as start (lead June 11-12, both clear)
954
         * NEW BIDIRECTIONAL Conflict Scenario:
816
        clickDateByISO("2026-06-13");
955
         * Blocker booking start: June 25
956
         * Blocker's LEAD period: June 23-24 (2 days before start)
957
         *
958
         * Test end dates where NEW booking's trail overlaps with blocker's LEAD:
959
         * - Select June 13 as start, hover June 21 as end
960
         * - Trail period: June 22-24 (3 days after June 21)
961
         * - June 23-24 overlap with blocker LEAD (June 23-24) → DISABLED
962
         *
963
         * This is the KEY enhancement: respecting existing booking's lead period!
964
         */
965
966
        // Select June 13 as start date (lead June 11-12, both clear)
967
        getDateByISO("2026-06-13").click();
968
817
969
        // Hover June 21 as potential end date
818
        // Hover June 21 - Trail (June 22-24) overlaps blocker LEAD (June 23-24) → DISABLED
970
        // Trail period: June 22-24, Blocker LEAD: June 23-24
819
        hoverDateByISO("2026-06-21");
971
        // Overlap at June 23-24 → should have trailDisable
820
        getDateByISO("2026-06-21").should(
972
        getDateByISO("2026-06-21").trigger("mouseover");
821
            "have.class",
973
        getDateByISO("2026-06-21").should("have.class", "trailDisable");
822
            "flatpickr-disabled"
823
        );
974
824
975
        // Also test June 20 - trail June 21-23, June 23 overlaps blocker lead
825
        // June 20 - Trail (June 21-23), June 23 overlaps blocker lead → DISABLED
976
        getDateByISO("2026-06-20").trigger("mouseover");
826
        hoverDateByISO("2026-06-20");
977
        getDateByISO("2026-06-20").should("have.class", "trailDisable");
827
        getDateByISO("2026-06-20").should(
828
            "have.class",
829
            "flatpickr-disabled"
830
        );
978
831
979
        // Verify June 19 is clear - trail June 20-22, doesn't reach blocker lead (starts June 23)
832
        // June 19 - Trail (June 20-22) doesn't reach blocker lead (starts June 23) → NOT disabled
980
        getDateByISO("2026-06-19").trigger("mouseover");
833
        hoverDateByISO("2026-06-19");
981
        getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
834
        getDateByISO("2026-06-19").should(
835
            "not.have.class",
836
            "flatpickr-disabled"
837
        );
982
838
983
        // Clear selection for next phase
839
        // Clear selection for next phase
984
        cy.get("#period").clearFlatpickr();
840
        cy.get("#booking_period").clearFlatpickr();
985
        cy.get("@fp").openFlatpickr();
841
        cy.get("@fp").openFlatpickr();
986
842
987
        // ========================================================================
843
        // ========================================================================
988
        // PHASE 5: Max Date Selectable When Trail is Clear
844
        // PHASE 5: Max Date Selectable When Trail is Clear
989
        // ========================================================================
845
        // ========================================================================
990
846
991
        /**
992
         * Select June 13 as start date (lead June 11-12, both clear)
993
         * Max end date by circulation rules: June 20 (13 + 7 days)
994
         * But June 20's trail period (June 21-23) overlaps blocker's lead (June 23-24) at June 23
995
         * So June 20 WILL have trailDisable
996
         *
997
         * June 19's trail period (June 20-22) is clear of blocker's lead (June 23-24)
998
         * So June 19 should be selectable (no trailDisable)
999
         */
1000
1001
        // Select June 13 as start date
847
        // Select June 13 as start date
1002
        getDateByISO("2026-06-13").click();
848
        clickDateByISO("2026-06-13");
1003
1004
        // First, verify June 20 HAS trailDisable (trail June 21-23 overlaps blocker lead June 23-24)
1005
        getDateByISO("2026-06-20").trigger("mouseover");
1006
        getDateByISO("2026-06-20").should("have.class", "trailDisable");
1007
849
1008
        // June 19 should NOT have trailDisable (trail June 20-22 is clear of blocker lead)
850
        // June 20: trail (June 21-23) overlaps blocker lead (June 23-24) → DISABLED
1009
        getDateByISO("2026-06-19").trigger("mouseover");
851
        hoverDateByISO("2026-06-20");
1010
        getDateByISO("2026-06-19").should("not.have.class", "trailDisable");
852
        getDateByISO("2026-06-20").should(
853
            "have.class",
854
            "flatpickr-disabled"
855
        );
1011
856
1012
        // June 19 should not be disabled by flatpickr
857
        // June 19: trail (June 20-22) clear of blocker lead → NOT disabled
858
        hoverDateByISO("2026-06-19");
1013
        getDateByISO("2026-06-19").should(
859
        getDateByISO("2026-06-19").should(
1014
            "not.have.class",
860
            "not.have.class",
1015
            "flatpickr-disabled"
861
            "flatpickr-disabled"
1016
        );
862
        );
1017
863
1018
        // Actually select June 19 to confirm booking can be made
864
        // Actually select June 19 to confirm booking can be made
1019
        getDateByISO("2026-06-19").click();
865
        clickDateByISO("2026-06-19");
1020
866
1021
        // Verify dates were accepted in the form
867
        // Verify dates were accepted in the form
1022
        cy.get("#booking_start_date").should("not.have.value", "");
868
        cy.get("#booking_period").should("not.have.value", "");
1023
        cy.get("#booking_end_date").should("not.have.value", "");
1024
869
1025
        cy.log(
870
        cy.log(
1026
            "✓ CONFIRMED: Lead/trail period behavior with bidirectional conflict detection working correctly"
871
            "✓ CONFIRMED: Lead/trail period behavior with bidirectional conflict detection working correctly"
1027
        );
872
        );
1028
    });
873
    });
1029
874
1030
    it("should show event dots for dates with existing bookings", () => {
875
    it("should show booking marker dots for dates with existing bookings", () => {
1031
        /**
876
        /**
1032
         * Comprehensive Event Dots Visual Indicator Test
877
         * Booking Marker Dots Visual Indicator Test
1033
         * ==============================================
878
         * ==========================================
1034
         *
1035
         * This test validates the visual booking indicators (event dots) displayed on calendar dates
1036
         * to show users which dates already have existing bookings.
1037
         *
1038
         * Test Coverage:
1039
         * 1. Single booking event dots (one dot per date)
1040
         * 2. Multiple bookings on same date (multiple dots)
1041
         * 3. Dates without bookings (no dots)
1042
         * 4. Item-specific dot styling with correct CSS classes
1043
         * 5. Event dot container structure and attributes
1044
         *
1045
         * EVENT DOTS FUNCTIONALITY:
1046
         * =========================
1047
         *
1048
         * Algorithm Overview:
1049
         * 1. Bookings array is processed into bookingsByDate hash (date -> [item_ids])
1050
         * 2. onDayCreate hook checks bookingsByDate[dateString] for each calendar day
1051
         * 3. If bookings exist, creates .event-dots container with .event.item_{id} children
1052
         * 4. Sets data attributes for booking metadata and item-specific information
1053
         *
1054
         * Visual Structure:
1055
         * <span class="flatpickr-day">
1056
         *   <div class="event-dots">
1057
         *     <div class="event item_301" data-item-id="301"></div>
1058
         *     <div class="event item_302" data-item-id="302"></div>
1059
         *   </div>
1060
         * </span>
1061
         *
1062
         * Event Dot Test Layout:
1063
         * ======================
1064
         * Day:     5  6  7  8  9 10 11 12 13 14 15 16 17
1065
         * Booking: MM O  O  O  O  S  S  S  O  O  T  O  O
1066
         * Dots:    •• -  -  -  -  •  •  •  -  -  •  -  -
1067
         *
879
         *
1068
         * Legend: MM = Multiple bookings (items 301+302), S = Single booking (item 303),
880
         * Vue version uses .booking-marker-grid with .booking-marker-dot children
1069
         *         T = Test booking (item 301), O = Available, - = No dots, • = Event dot
881
         * instead of the jQuery .event-dots / .event pattern.
1070
         */
882
         */
1071
883
1072
        const today = dayjs().startOf("day");
884
        const today = dayjs().startOf("day");
1073
885
1074
        // Set up circulation rules for event dots testing
886
        // Set up circulation rules for marker testing
1075
        const eventDotsCirculationRules = {
887
        const markerCirculationRules = {
1076
            bookings_lead_period: 1, // Minimal to avoid conflicts
888
            bookings_lead_period: 1,
1077
            bookings_trail_period: 1,
889
            bookings_trail_period: 1,
1078
            issuelength: 7,
890
            issuelength: 7,
1079
            renewalsallowed: 1,
891
            renewalsallowed: 1,
Lines 1081-1112 describe("Booking Modal Date Picker Tests", () => { Link Here
1081
        };
893
        };
1082
894
1083
        cy.intercept("GET", "/api/v1/circulation_rules*", {
895
        cy.intercept("GET", "/api/v1/circulation_rules*", {
1084
            body: [eventDotsCirculationRules],
896
            body: [markerCirculationRules],
1085
        }).as("getEventDotsRules");
897
        }).as("getMarkerRules");
1086
898
1087
        // Create strategic bookings for event dots testing
899
        // Create strategic bookings for marker testing
1088
        const testBookings = [
900
        const testBookings = [
1089
            // Multiple bookings on same dates (Days 5-6): Items 301 + 302
901
            // Multiple bookings on same dates (Days 5-6): Items 0 + 1
1090
            {
902
            {
1091
                item_id: testData.items[0].item_id, // Will be item 301 equivalent
903
                item_id: testData.items[0].item_id,
1092
                start: today.add(5, "day"),
904
                start: today.add(5, "day"),
1093
                end: today.add(6, "day"),
905
                end: today.add(6, "day"),
1094
                name: "Multi-booking 1",
906
                name: "Multi-booking 1",
1095
            },
907
            },
1096
            {
908
            {
1097
                item_id: testData.items[1].item_id, // Will be item 302 equivalent
909
                item_id: testData.items[1].item_id,
1098
                start: today.add(5, "day"),
910
                start: today.add(5, "day"),
1099
                end: today.add(6, "day"),
911
                end: today.add(6, "day"),
1100
                name: "Multi-booking 2",
912
                name: "Multi-booking 2",
1101
            },
913
            },
1102
            // Single booking spanning multiple days (Days 10-12): Item 303
914
            // Single booking spanning multiple days (Days 10-12): Item 0
1103
            {
915
            {
1104
                item_id: testData.items[0].item_id, // Reuse first item
916
                item_id: testData.items[0].item_id,
1105
                start: today.add(10, "day"),
917
                start: today.add(10, "day"),
1106
                end: today.add(12, "day"),
918
                end: today.add(12, "day"),
1107
                name: "Single span booking",
919
                name: "Single span booking",
1108
            },
920
            },
1109
            // Isolated single booking (Day 15): Item 301
921
            // Isolated single booking (Day 15): Item 0
1110
            {
922
            {
1111
                item_id: testData.items[0].item_id,
923
                item_id: testData.items[0].item_id,
1112
                start: today.add(15, "day"),
924
                start: today.add(15, "day"),
Lines 1133-1152 describe("Booking Modal Date Picker Tests", () => { Link Here
1133
945
1134
        setupModalForDateTesting({ skipItemSelection: true });
946
        setupModalForDateTesting({ skipItemSelection: true });
1135
947
1136
        // Select item to trigger event dots loading
948
        // Select item to trigger marker loading
1137
        cy.get("#booking_item_id").should("not.be.disabled");
949
        cy.vueSelectShouldBeEnabled("booking_item_id");
1138
        cy.selectFromSelect2ByIndex("#booking_item_id", 1); // Select first actual item
950
        cy.vueSelectByIndex("booking_item_id", 1); // Select first actual item
1139
        cy.wait("@getEventDotsRules");
951
        cy.wait("@getMarkerRules");
1140
952
1141
        cy.get("#period").should("not.be.disabled");
953
        cy.get("#booking_period").should("not.be.disabled");
1142
        cy.get("#period").as("eventDotsFlatpickr");
954
        cy.get("#booking_period").as("markerFlatpickr");
1143
        cy.get("@eventDotsFlatpickr").openFlatpickr();
955
        cy.get("@markerFlatpickr").openFlatpickr();
1144
956
1145
        // ========================================================================
957
        // ========================================================================
1146
        // TEST 1: Single Booking Event Dots (Days 10, 11, 12)
958
        // TEST 1: Single Booking Marker Dots (Days 10, 11, 12)
1147
        // ========================================================================
959
        // ========================================================================
1148
960
1149
        // Days 10-12 have single booking from same item - should create one event dot each
1150
        const singleDotDates = [
961
        const singleDotDates = [
1151
            today.add(10, "day"),
962
            today.add(10, "day"),
1152
            today.add(11, "day"),
963
            today.add(11, "day"),
Lines 1158-1172 describe("Booking Modal Date Picker Tests", () => { Link Here
1158
                date.month() === today.month() ||
969
                date.month() === today.month() ||
1159
                date.month() === today.add(1, "month").month()
970
                date.month() === today.add(1, "month").month()
1160
            ) {
971
            ) {
1161
                cy.get("@eventDotsFlatpickr")
972
                cy.get("@markerFlatpickr")
1162
                    .getFlatpickrDate(date.toDate())
973
                    .getFlatpickrDate(date.toDate())
1163
                    .within(() => {
974
                    .within(() => {
1164
                        cy.get(".event-dots")
975
                        cy.get(".booking-marker-grid")
1165
                            .should("exist")
976
                            .should("exist")
1166
                            .and("have.length", 1);
977
                            .and("have.length", 1);
1167
                        cy.get(".event-dots .event")
978
                        cy.get(".booking-marker-grid .booking-marker-dot")
1168
                            .should("exist")
979
                            .should("exist")
1169
                            .and("have.length", 1);
980
                            .and("have.length.at.least", 1);
1170
                    });
981
                    });
1171
            }
982
            }
1172
        });
983
        });
Lines 1175-1181 describe("Booking Modal Date Picker Tests", () => { Link Here
1175
        // TEST 2: Multiple Bookings on Same Date (Days 5-6)
986
        // TEST 2: Multiple Bookings on Same Date (Days 5-6)
1176
        // ========================================================================
987
        // ========================================================================
1177
988
1178
        // Days 5-6 have TWO different bookings (different items) - should create two dots
1179
        const multipleDotDates = [today.add(5, "day"), today.add(6, "day")];
989
        const multipleDotDates = [today.add(5, "day"), today.add(6, "day")];
1180
990
1181
        multipleDotDates.forEach(date => {
991
        multipleDotDates.forEach(date => {
Lines 1183-1202 describe("Booking Modal Date Picker Tests", () => { Link Here
1183
                date.month() === today.month() ||
993
                date.month() === today.month() ||
1184
                date.month() === today.add(1, "month").month()
994
                date.month() === today.add(1, "month").month()
1185
            ) {
995
            ) {
1186
                cy.get("@eventDotsFlatpickr")
996
                cy.get("@markerFlatpickr")
1187
                    .getFlatpickrDate(date.toDate())
997
                    .getFlatpickrDate(date.toDate())
1188
                    .within(() => {
998
                    .within(() => {
1189
                        cy.get(".event-dots").should("exist");
999
                        cy.get(".booking-marker-grid").should("exist");
1190
                        cy.get(".event-dots .event").should("have.length", 2);
1000
                        // Dots are aggregated by type (booked/checked-out), not per-booking.
1001
                        // 2 bookings of type "booked" = 1 dot with count 2.
1002
                        cy.get(
1003
                            ".booking-marker-grid .booking-marker-dot"
1004
                        ).should("have.length.at.least", 1);
1191
                    });
1005
                    });
1192
            }
1006
            }
1193
        });
1007
        });
1194
1008
1195
        // ========================================================================
1009
        // ========================================================================
1196
        // TEST 3: Dates Without Bookings (No Event Dots)
1010
        // TEST 3: Dates Without Bookings (No Marker Dots)
1197
        // ========================================================================
1011
        // ========================================================================
1198
1012
1199
        // Dates without bookings should have no .event-dots container
1200
        const emptyDates = [
1013
        const emptyDates = [
1201
            today.add(3, "day"), // Before any bookings
1014
            today.add(3, "day"), // Before any bookings
1202
            today.add(8, "day"), // Between booking periods
1015
            today.add(8, "day"), // Between booking periods
Lines 1209-1218 describe("Booking Modal Date Picker Tests", () => { Link Here
1209
                date.month() === today.month() ||
1022
                date.month() === today.month() ||
1210
                date.month() === today.add(1, "month").month()
1023
                date.month() === today.add(1, "month").month()
1211
            ) {
1024
            ) {
1212
                cy.get("@eventDotsFlatpickr")
1025
                cy.get("@markerFlatpickr")
1213
                    .getFlatpickrDate(date.toDate())
1026
                    .getFlatpickrDate(date.toDate())
1214
                    .within(() => {
1027
                    .within(() => {
1215
                        cy.get(".event-dots").should("not.exist");
1028
                        cy.get(".booking-marker-grid").should("not.exist");
1216
                    });
1029
                    });
1217
            }
1030
            }
1218
        });
1031
        });
Lines 1221-1254 describe("Booking Modal Date Picker Tests", () => { Link Here
1221
        // TEST 4: Isolated Single Booking (Day 15) - Boundary Detection
1034
        // TEST 4: Isolated Single Booking (Day 15) - Boundary Detection
1222
        // ========================================================================
1035
        // ========================================================================
1223
1036
1224
        // Day 15 has booking (should have dot), adjacent days 14 and 16 don't (no dots)
1225
        const isolatedBookingDate = today.add(15, "day");
1037
        const isolatedBookingDate = today.add(15, "day");
1226
1038
1227
        if (
1039
        if (
1228
            isolatedBookingDate.month() === today.month() ||
1040
            isolatedBookingDate.month() === today.month() ||
1229
            isolatedBookingDate.month() === today.add(1, "month").month()
1041
            isolatedBookingDate.month() === today.add(1, "month").month()
1230
        ) {
1042
        ) {
1231
            // Verify isolated booking day HAS dot
1043
            // Verify isolated booking day HAS marker dot
1232
            cy.get("@eventDotsFlatpickr")
1044
            cy.get("@markerFlatpickr")
1233
                .getFlatpickrDate(isolatedBookingDate.toDate())
1045
                .getFlatpickrDate(isolatedBookingDate.toDate())
1234
                .within(() => {
1046
                .within(() => {
1235
                    cy.get(".event-dots").should("exist");
1047
                    cy.get(".booking-marker-grid").should("exist");
1236
                    cy.get(".event-dots .event")
1048
                    cy.get(".booking-marker-grid .booking-marker-dot")
1237
                        .should("exist")
1049
                        .should("exist")
1238
                        .and("have.length", 1);
1050
                        .and("have.length.at.least", 1);
1239
                });
1051
                });
1240
1052
1241
            // Verify adjacent dates DON'T have dots
1053
            // Verify adjacent dates DON'T have marker dots
1242
            [today.add(14, "day"), today.add(16, "day")].forEach(
1054
            [today.add(14, "day"), today.add(16, "day")].forEach(
1243
                adjacentDate => {
1055
                adjacentDate => {
1244
                    if (
1056
                    if (
1245
                        adjacentDate.month() === today.month() ||
1057
                        adjacentDate.month() === today.month() ||
1246
                        adjacentDate.month() === today.add(1, "month").month()
1058
                        adjacentDate.month() === today.add(1, "month").month()
1247
                    ) {
1059
                    ) {
1248
                        cy.get("@eventDotsFlatpickr")
1060
                        cy.get("@markerFlatpickr")
1249
                            .getFlatpickrDate(adjacentDate.toDate())
1061
                            .getFlatpickrDate(adjacentDate.toDate())
1250
                            .within(() => {
1062
                            .within(() => {
1251
                                cy.get(".event-dots").should("not.exist");
1063
                                cy.get(".booking-marker-grid").should(
1064
                                    "not.exist"
1065
                                );
1252
                            });
1066
                            });
1253
                    }
1067
                    }
1254
                }
1068
                }
Lines 1256-1262 describe("Booking Modal Date Picker Tests", () => { Link Here
1256
        }
1070
        }
1257
1071
1258
        cy.log(
1072
        cy.log(
1259
            "✓ CONFIRMED: Event dots display correctly (single, multiple, empty dates, boundaries)"
1073
            "✓ CONFIRMED: Booking marker dots display correctly (single, multiple, empty dates, boundaries)"
1260
        );
1074
        );
1261
    });
1075
    });
1262
1076
Lines 1266-1281 describe("Booking Modal Date Picker Tests", () => { Link Here
1266
         *
1080
         *
1267
         * Key principle: Once an item is removed from the pool (becomes unavailable),
1081
         * Key principle: Once an item is removed from the pool (becomes unavailable),
1268
         * it is NEVER re-added even if it becomes available again later.
1082
         * it is NEVER re-added even if it becomes available again later.
1269
         *
1270
         * Booking pattern:
1271
         * - ITEM 0: Booked days 10-15
1272
         * - ITEM 1: Booked days 13-20
1273
         * - ITEM 2: Booked days 18-25
1274
         * - ITEM 3: Booked days 1-7, then 23-30
1275
         */
1083
         */
1276
1084
1277
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
1085
        // Fix the browser Date object to June 10, 2026 at 09:00 Europe/London
1278
        // Using ["Date"] to avoid freezing timers which breaks Select2 async operations
1279
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
1086
        const fixedToday = new Date("2026-06-10T08:00:00Z"); // 09:00 BST (UTC+1)
1280
        cy.clock(fixedToday, ["Date"]);
1087
        cy.clock(fixedToday, ["Date"]);
1281
        const today = dayjs(fixedToday);
1088
        const today = dayjs(fixedToday);
Lines 1299-1316 describe("Booking Modal Date Picker Tests", () => { Link Here
1299
                testBiblio = objects.biblio;
1106
                testBiblio = objects.biblio;
1300
                testItems = objects.items;
1107
                testItems = objects.items;
1301
1108
1302
                const itemUpdates = testItems.map((item, index) => {
1109
                let chain = cy.wrap(null);
1110
                testItems.forEach((item, index) => {
1303
                    const enumchron = String.fromCharCode(65 + index);
1111
                    const enumchron = String.fromCharCode(65 + index);
1304
                    return cy.task("query", {
1112
                    chain = chain.then(() => cy.task("query", {
1305
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1113
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1306
                        values: [
1114
                        values: [
1307
                            enumchron,
1115
                            enumchron,
1308
                            `2024-12-0${4 - index}`,
1116
                            `2024-12-0${4 - index}`,
1309
                            item.item_id,
1117
                            item.item_id,
1310
                        ],
1118
                        ],
1311
                    });
1119
                    }));
1312
                });
1120
                });
1313
                return Promise.all(itemUpdates);
1121
                return chain;
1314
            })
1122
            })
1315
            .then(() => {
1123
            .then(() => {
1316
                return cy.task("buildSampleObject", {
1124
                return cy.task("buildSampleObject", {
Lines 1341-1420 describe("Booking Modal Date Picker Tests", () => { Link Here
1341
                });
1149
                });
1342
            })
1150
            })
1343
            .then(() => {
1151
            .then(() => {
1344
                // Create strategic bookings
1152
                // Create strategic bookings sequentially
1345
                const bookingInserts = [
1153
                const bookings = [
1346
                    // ITEM 0: Booked 10-15
1154
                    { item: testItems[0], start: 10, end: 15 }, // ITEM 0
1347
                    cy.task("query", {
1155
                    { item: testItems[1], start: 13, end: 20 }, // ITEM 1
1348
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1156
                    { item: testItems[2], start: 18, end: 25 }, // ITEM 2
1349
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1157
                    { item: testItems[3], start: 1, end: 7 },   // ITEM 3
1350
                        values: [
1158
                    { item: testItems[3], start: 23, end: 30 }, // ITEM 3
1351
                            testBiblio.biblio_id,
1159
                ];
1352
                            testPatron.patron_id,
1160
                let chain = cy.wrap(null);
1353
                            testItems[0].item_id,
1161
                bookings.forEach(b => {
1354
                            "CPL",
1162
                    chain = chain.then(() => cy.task("query", {
1355
                            today.add(10, "day").format("YYYY-MM-DD"),
1356
                            today.add(15, "day").format("YYYY-MM-DD"),
1357
                            "new",
1358
                        ],
1359
                    }),
1360
                    // ITEM 1: Booked 13-20
1361
                    cy.task("query", {
1362
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1363
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1364
                        values: [
1365
                            testBiblio.biblio_id,
1366
                            testPatron.patron_id,
1367
                            testItems[1].item_id,
1368
                            "CPL",
1369
                            today.add(13, "day").format("YYYY-MM-DD"),
1370
                            today.add(20, "day").format("YYYY-MM-DD"),
1371
                            "new",
1372
                        ],
1373
                    }),
1374
                    // ITEM 2: Booked 18-25
1375
                    cy.task("query", {
1376
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1377
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1378
                        values: [
1379
                            testBiblio.biblio_id,
1380
                            testPatron.patron_id,
1381
                            testItems[2].item_id,
1382
                            "CPL",
1383
                            today.add(18, "day").format("YYYY-MM-DD"),
1384
                            today.add(25, "day").format("YYYY-MM-DD"),
1385
                            "new",
1386
                        ],
1387
                    }),
1388
                    // ITEM 3: Booked 1-7
1389
                    cy.task("query", {
1390
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1391
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1392
                        values: [
1393
                            testBiblio.biblio_id,
1394
                            testPatron.patron_id,
1395
                            testItems[3].item_id,
1396
                            "CPL",
1397
                            today.add(1, "day").format("YYYY-MM-DD"),
1398
                            today.add(7, "day").format("YYYY-MM-DD"),
1399
                            "new",
1400
                        ],
1401
                    }),
1402
                    // ITEM 3: Booked 23-30
1403
                    cy.task("query", {
1404
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1163
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1405
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1164
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1406
                        values: [
1165
                        values: [
1407
                            testBiblio.biblio_id,
1166
                            testBiblio.biblio_id,
1408
                            testPatron.patron_id,
1167
                            testPatron.patron_id,
1409
                            testItems[3].item_id,
1168
                            b.item.item_id,
1410
                            "CPL",
1169
                            "CPL",
1411
                            today.add(23, "day").format("YYYY-MM-DD"),
1170
                            today.add(b.start, "day").format("YYYY-MM-DD"),
1412
                            today.add(30, "day").format("YYYY-MM-DD"),
1171
                            today.add(b.end, "day").format("YYYY-MM-DD"),
1413
                            "new",
1172
                            "new",
1414
                        ],
1173
                        ],
1415
                    }),
1174
                    }));
1416
                ];
1175
                });
1417
                return Promise.all(bookingInserts);
1176
                return chain;
1418
            })
1177
            })
1419
            .then(() => {
1178
            .then(() => {
1420
                cy.intercept(
1179
                cy.intercept(
Lines 1429-1456 describe("Booking Modal Date Picker Tests", () => { Link Here
1429
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1188
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1430
                );
1189
                );
1431
1190
1432
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1191
                cy.get("booking-modal-island .modal").should("exist");
1433
                cy.get("#placeBookingModal").should("be.visible");
1192
                cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1193
                cy.get("booking-modal-island .modal", {
1194
                    timeout: 10000,
1195
                }).should("be.visible");
1434
1196
1435
                cy.selectFromSelect2(
1197
                cy.vueSelect(
1436
                    "#booking_patron_id",
1198
                    "booking_patron",
1437
                    `${testPatron.surname}, ${testPatron.firstname}`,
1199
                    testPatron.cardnumber,
1438
                    testPatron.cardnumber
1200
                    `${testPatron.surname} ${testPatron.firstname}`
1439
                );
1201
                );
1440
                cy.wait("@getPickupLocations");
1202
                cy.wait("@getPickupLocations");
1441
1203
1442
                cy.get("#pickup_library_id").should("not.be.disabled");
1204
                cy.vueSelectShouldBeEnabled("pickup_library_id");
1443
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1205
                cy.vueSelectByIndex("pickup_library_id", 0);
1444
1206
1445
                cy.get("#booking_itemtype").should("not.be.disabled");
1207
                cy.vueSelectShouldBeEnabled("booking_itemtype");
1446
                cy.selectFromSelect2ByIndex("#booking_itemtype", 0);
1208
                cy.vueSelectByIndex("booking_itemtype", 0);
1447
                cy.wait("@getCirculationRules");
1209
                cy.wait("@getCirculationRules");
1448
1210
1449
                cy.selectFromSelect2ByIndex("#booking_item_id", 0); // "Any item"
1211
                // "Any item" = no item selected (null) = leave dropdown at placeholder
1450
                cy.get("#period").should("not.be.disabled");
1212
                cy.get("#booking_period").should("not.be.disabled");
1451
                cy.get("#period").as("flatpickrInput");
1213
                cy.get("#booking_period").as("flatpickrInput");
1452
1214
1453
                // Helper to check date availability - checks boundaries + random middle date
1215
                // Helper to check date availability
1454
                const checkDatesAvailable = (fromDay, toDay) => {
1216
                const checkDatesAvailable = (fromDay, toDay) => {
1455
                    const daysToCheck = [fromDay, toDay];
1217
                    const daysToCheck = [fromDay, toDay];
1456
                    if (toDay - fromDay > 1) {
1218
                    if (toDay - fromDay > 1) {
Lines 1484-1524 describe("Booking Modal Date Picker Tests", () => { Link Here
1484
                };
1246
                };
1485
1247
1486
                // SCENARIO 1: Start day 5
1248
                // SCENARIO 1: Start day 5
1487
                // Pool starts: ITEM0, ITEM1, ITEM2 (ITEM3 booked 1-7)
1488
                // Day 10: lose ITEM0, Day 13: lose ITEM1, Day 18: lose ITEM2 → disabled
1489
                cy.log("=== Scenario 1: Start day 5 ===");
1249
                cy.log("=== Scenario 1: Start day 5 ===");
1490
                cy.get("@flatpickrInput").openFlatpickr();
1250
                cy.get("@flatpickrInput").openFlatpickr();
1491
                cy.get("@flatpickrInput")
1251
                cy.get("@flatpickrInput")
1492
                    .getFlatpickrDate(today.add(5, "day").toDate())
1252
                    .selectFlatpickrDate(today.add(5, "day").toDate());
1493
                    .click();
1494
1253
1495
                checkDatesAvailable(6, 17); // Available through day 17
1254
                checkDatesAvailable(6, 17);
1496
                checkDatesDisabled(18, 20); // Disabled from day 18
1255
                checkDatesDisabled(18, 20);
1497
1256
1498
                // SCENARIO 2: Start day 8
1257
                // SCENARIO 2: Start day 8
1499
                // Pool starts: ALL 4 items (ITEM3 booking 1-7 ended)
1258
                cy.log(
1500
                // Progressive reduction until day 23 when ITEM3's second booking starts
1259
                    "=== Scenario 2: Start day 8 (all items available) ==="
1501
                cy.log("=== Scenario 2: Start day 8 (all items available) ===");
1260
                );
1502
                cy.get("@flatpickrInput").clearFlatpickr();
1261
                cy.get("@flatpickrInput").clearFlatpickr();
1503
                cy.get("@flatpickrInput").openFlatpickr();
1262
                cy.get("@flatpickrInput").openFlatpickr();
1504
                cy.get("@flatpickrInput")
1263
                cy.get("@flatpickrInput")
1505
                    .getFlatpickrDate(today.add(8, "day").toDate())
1264
                    .selectFlatpickrDate(today.add(8, "day").toDate());
1506
                    .click();
1507
1265
1508
                checkDatesAvailable(9, 22); // Can book through day 22
1266
                checkDatesAvailable(9, 22);
1509
                checkDatesDisabled(23, 25); // Disabled from day 23
1267
                checkDatesDisabled(23, 25);
1510
1268
1511
                // SCENARIO 3: Start day 19
1269
                // SCENARIO 3: Start day 19
1512
                // Pool starts: ITEM0 (booking ended day 15), ITEM3
1513
                // ITEM0 stays available indefinitely, ITEM3 loses at day 23
1514
                cy.log("=== Scenario 3: Start day 19 ===");
1270
                cy.log("=== Scenario 3: Start day 19 ===");
1515
                cy.get("@flatpickrInput").clearFlatpickr();
1271
                cy.get("@flatpickrInput").clearFlatpickr();
1516
                cy.get("@flatpickrInput").openFlatpickr();
1272
                cy.get("@flatpickrInput").openFlatpickr();
1517
                cy.get("@flatpickrInput")
1273
                cy.get("@flatpickrInput")
1518
                    .getFlatpickrDate(today.add(19, "day").toDate())
1274
                    .selectFlatpickrDate(today.add(19, "day").toDate());
1519
                    .click();
1520
1275
1521
                // ITEM0 remains in pool, so dates stay available past day 23
1522
                checkDatesAvailable(20, 25);
1276
                checkDatesAvailable(20, 25);
1523
            });
1277
            });
1524
1278
Lines 1546-1588 describe("Booking Modal Date Picker Tests", () => { Link Here
1546
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1300
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1547
        /**
1301
        /**
1548
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1302
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1549
         * ========================================================================
1550
         *
1551
         * This test validates that lead/trail period conflict detection works correctly
1552
         * when "any item of itemtype X" is selected. The key principle is:
1553
         *
1554
         * - Only block date selection when ALL items of the itemtype have conflicts
1555
         * - Allow selection when at least one item is free from lead/trail conflicts
1556
         *
1557
         * The bug occurred because the mouseover handler was checking conflicts against
1558
         * ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
1559
         *
1560
         * Test Setup:
1561
         * ===========
1562
         * - Fixed date: June 1, 2026 (keeps all test dates in same month)
1563
         * - 3 items of itemtype BK
1564
         * - Lead period: 2 days, Trail period: 2 days
1565
         * - ITEM 0: Booking on days 10-12 (June 11-13, trail period: June 14-15)
1566
         * - ITEM 1: Booking on days 10-12 (same as item 0)
1567
         * - ITEM 2: No bookings (always available)
1568
         *
1569
         * Test Scenarios:
1570
         * ==============
1571
         * 1. Hover day 15 (June 16): ITEM 0 and ITEM 1 have trail period conflict
1572
         *    (lead period June 14-15 overlaps their trail June 14-15), but ITEM 2 is free
1573
         *    → Should NOT be blocked (at least one item available)
1574
         *
1575
         * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
1576
         *    → ALL items now have trail period conflicts
1577
         *    → Should BE blocked
1578
         *
1579
         * 3. Visual feedback: Check existingBookingTrail on days 13-14 (June 14-15)
1580
         *
1581
         * 4. Visual feedback: Check existingBookingLead on days 8-9 (June 9-10)
1582
         */
1303
         */
1583
1304
1584
        // Fix the browser Date object to June 1, 2026 at 09:00 Europe/London
1305
        // Fix the browser Date object to June 1, 2026 at 09:00 Europe/London
1585
        // This ensures all test dates (days 5-17) fall within June
1586
        const fixedToday = new Date("2026-06-01T08:00:00Z"); // 09:00 BST (UTC+1)
1306
        const fixedToday = new Date("2026-06-01T08:00:00Z"); // 09:00 BST (UTC+1)
1587
        cy.clock(fixedToday, ["Date"]);
1307
        cy.clock(fixedToday, ["Date"]);
1588
1308
Lines 1609-1626 describe("Booking Modal Date Picker Tests", () => { Link Here
1609
                testLibraries = objects.libraries;
1329
                testLibraries = objects.libraries;
1610
1330
1611
                // Make all items the same itemtype (BK)
1331
                // Make all items the same itemtype (BK)
1612
                const itemUpdates = testItems.map((item, index) => {
1332
                let chain = cy.wrap(null);
1333
                testItems.forEach((item, index) => {
1613
                    const enumchron = String.fromCharCode(65 + index);
1334
                    const enumchron = String.fromCharCode(65 + index);
1614
                    return cy.task("query", {
1335
                    chain = chain.then(() => cy.task("query", {
1615
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1336
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1616
                        values: [
1337
                        values: [
1617
                            enumchron,
1338
                            enumchron,
1618
                            `2024-12-0${4 - index}`,
1339
                            `2024-12-0${4 - index}`,
1619
                            item.item_id,
1340
                            item.item_id,
1620
                        ],
1341
                        ],
1621
                    });
1342
                    }));
1622
                });
1343
                });
1623
                return Promise.all(itemUpdates);
1344
                return chain;
1624
            })
1345
            })
1625
            .then(() => {
1346
            .then(() => {
1626
                return cy.task("buildSampleObject", {
1347
                return cy.task("buildSampleObject", {
Lines 1652-1690 describe("Booking Modal Date Picker Tests", () => { Link Here
1652
            })
1373
            })
1653
            .then(() => {
1374
            .then(() => {
1654
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1375
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1655
                // ITEM 2 remains free
1376
                return cy.task("query", {
1656
                const bookingInserts = [
1377
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1657
                    // ITEM 0: Booked days 10-12
1378
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1658
                    cy.task("query", {
1379
                    values: [
1659
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1380
                        testBiblio.biblio_id,
1660
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1381
                        testPatron.patron_id,
1661
                        values: [
1382
                        testItems[0].item_id,
1662
                            testBiblio.biblio_id,
1383
                        "CPL",
1663
                            testPatron.patron_id,
1384
                        today.add(10, "day").format("YYYY-MM-DD"),
1664
                            testItems[0].item_id,
1385
                        today.add(12, "day").format("YYYY-MM-DD"),
1665
                            "CPL",
1386
                        "new",
1666
                            today.add(10, "day").format("YYYY-MM-DD"),
1387
                    ],
1667
                            today.add(12, "day").format("YYYY-MM-DD"),
1388
                }).then(() => cy.task("query", {
1668
                            "new",
1389
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1669
                        ],
1390
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1670
                    }),
1391
                    values: [
1671
                    // ITEM 1: Booked days 10-12 (same period)
1392
                        testBiblio.biblio_id,
1672
                    cy.task("query", {
1393
                        testPatron.patron_id,
1673
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1394
                        testItems[1].item_id,
1674
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1395
                        "CPL",
1675
                        values: [
1396
                        today.add(10, "day").format("YYYY-MM-DD"),
1676
                            testBiblio.biblio_id,
1397
                        today.add(12, "day").format("YYYY-MM-DD"),
1677
                            testPatron.patron_id,
1398
                        "new",
1678
                            testItems[1].item_id,
1399
                    ],
1679
                            "CPL",
1400
                }));
1680
                            today.add(10, "day").format("YYYY-MM-DD"),
1681
                            today.add(12, "day").format("YYYY-MM-DD"),
1682
                            "new",
1683
                        ],
1684
                    }),
1685
                    // ITEM 2: No booking - remains free
1686
                ];
1687
                return Promise.all(bookingInserts);
1688
            })
1401
            })
1689
            .then(() => {
1402
            .then(() => {
1690
                cy.intercept(
1403
                cy.intercept(
Lines 1699-1728 describe("Booking Modal Date Picker Tests", () => { Link Here
1699
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1412
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1700
                );
1413
                );
1701
1414
1702
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1415
                cy.get("booking-modal-island .modal").should("exist");
1703
                cy.get("#placeBookingModal").should("be.visible");
1416
                cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1417
                cy.get("booking-modal-island .modal", {
1418
                    timeout: 10000,
1419
                }).should("be.visible");
1704
1420
1705
                cy.selectFromSelect2(
1421
                cy.vueSelect(
1706
                    "#booking_patron_id",
1422
                    "booking_patron",
1707
                    `${testPatron.surname}, ${testPatron.firstname}`,
1423
                    testPatron.cardnumber,
1708
                    testPatron.cardnumber
1424
                    `${testPatron.surname} ${testPatron.firstname}`
1709
                );
1425
                );
1710
                cy.wait("@getPickupLocations");
1426
                cy.wait("@getPickupLocations");
1711
1427
1712
                cy.get("#pickup_library_id").should("not.be.disabled");
1428
                cy.vueSelectShouldBeEnabled("pickup_library_id");
1713
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1429
                cy.vueSelectByIndex("pickup_library_id", 0);
1714
1430
1715
                // Select itemtype BK
1431
                // Select itemtype BK
1716
                cy.get("#booking_itemtype").should("not.be.disabled");
1432
                cy.vueSelectShouldBeEnabled("booking_itemtype");
1717
                cy.selectFromSelect2("#booking_itemtype", "Books");
1433
                cy.vueSelectByIndex("booking_itemtype", 0);
1718
                cy.wait("@getCirculationRules");
1434
                cy.wait("@getCirculationRules");
1719
1435
1720
                // Select "Any item" (index 0)
1436
                // "Any item" = no item selected (null) = leave dropdown at placeholder
1721
                cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1722
                cy.get("#booking_item_id").should("have.value", "0");
1723
1437
1724
                cy.get("#period").should("not.be.disabled");
1438
                cy.get("#booking_period").should("not.be.disabled");
1725
                cy.get("#period").as("flatpickrInput");
1439
                cy.get("#booking_period").as("flatpickrInput");
1726
1440
1727
                // ================================================================
1441
                // ================================================================
1728
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
1442
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
Lines 1733-1756 describe("Booking Modal Date Picker Tests", () => { Link Here
1733
1447
1734
                cy.get("@flatpickrInput").openFlatpickr();
1448
                cy.get("@flatpickrInput").openFlatpickr();
1735
                cy.get("@flatpickrInput")
1449
                cy.get("@flatpickrInput")
1736
                    .getFlatpickrDate(today.add(15, "day").toDate())
1450
                    .hoverFlatpickrDate(today.add(15, "day").toDate());
1737
                    .trigger("mouseover");
1738
1451
1739
                // Day 15 should NOT have leadDisable class (at least one item is free)
1452
                // Day 15 should NOT be disabled (at least one item is free)
1740
                cy.get("@flatpickrInput")
1453
                cy.get("@flatpickrInput")
1741
                    .getFlatpickrDate(today.add(15, "day").toDate())
1454
                    .getFlatpickrDate(today.add(15, "day").toDate())
1742
                    .should("not.have.class", "leadDisable");
1455
                    .should("not.have.class", "flatpickr-disabled");
1743
1456
1744
                // Actually click day 15 to verify it's selectable
1457
                // Actually click day 15 to verify it's selectable
1745
                cy.get("@flatpickrInput")
1458
                cy.get("@flatpickrInput")
1746
                    .getFlatpickrDate(today.add(15, "day").toDate())
1459
                    .selectFlatpickrDate(today.add(15, "day").toDate());
1747
                    .should("not.have.class", "flatpickr-disabled")
1748
                    .click();
1749
1750
                // Verify day 15 was selected as start date
1751
                cy.get("@flatpickrInput")
1752
                    .getFlatpickrDate(today.add(15, "day").toDate())
1753
                    .should("have.class", "selected");
1754
1460
1755
                // Reset for next scenario
1461
                // Reset for next scenario
1756
                cy.get("@flatpickrInput").clearFlatpickr();
1462
                cy.get("@flatpickrInput").clearFlatpickr();
Lines 1781-1856 describe("Booking Modal Date Picker Tests", () => { Link Here
1781
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1487
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1782
                    );
1488
                    );
1783
1489
1784
                    cy.get('[data-bs-target="#placeBookingModal"]')
1490
                    cy.get("booking-modal-island .modal").should("exist");
1785
                        .first()
1491
                    cy.get("[data-booking-modal]").first().then($btn => $btn[0].click());
1786
                        .click();
1492
                    cy.get("booking-modal-island .modal", {
1787
                    cy.get("#placeBookingModal").should("be.visible");
1493
                        timeout: 10000,
1494
                    }).should("be.visible");
1788
1495
1789
                    cy.selectFromSelect2(
1496
                    cy.vueSelect(
1790
                        "#booking_patron_id",
1497
                        "booking_patron",
1791
                        `${testPatron.surname}, ${testPatron.firstname}`,
1498
                        testPatron.cardnumber,
1792
                        testPatron.cardnumber
1499
                        `${testPatron.surname} ${testPatron.firstname}`
1793
                    );
1500
                    );
1794
                    cy.wait("@getPickupLocations");
1501
                    cy.wait("@getPickupLocations");
1795
1502
1796
                    cy.get("#pickup_library_id").should("not.be.disabled");
1503
                    cy.vueSelectShouldBeEnabled("pickup_library_id");
1797
                    cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1504
                    cy.vueSelectByIndex("pickup_library_id", 0);
1798
1505
1799
                    // Select itemtype BK
1506
                    // Select itemtype BK
1800
                    cy.get("#booking_itemtype").should("not.be.disabled");
1507
                    cy.vueSelectShouldBeEnabled("booking_itemtype");
1801
                    cy.selectFromSelect2("#booking_itemtype", "Books");
1508
                    cy.vueSelectByIndex("booking_itemtype", 0);
1802
                    cy.wait("@getCirculationRules");
1509
                    cy.wait("@getCirculationRules");
1803
1510
1804
                    // Select "Any item" (index 0)
1511
                    // "Any item" = no item selected (null) = leave dropdown at placeholder
1805
                    cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1806
                    cy.get("#booking_item_id").should("have.value", "0");
1807
1512
1808
                    cy.get("#period").should("not.be.disabled");
1513
                    cy.get("#booking_period").should("not.be.disabled");
1809
                    cy.get("#period").as("flatpickrInput2");
1514
                    cy.get("#booking_period").as("flatpickrInput2");
1810
1515
1811
                    cy.get("@flatpickrInput2").openFlatpickr();
1516
                    cy.get("@flatpickrInput2").openFlatpickr();
1812
                    cy.get("@flatpickrInput2")
1517
                    cy.get("@flatpickrInput2")
1813
                        .getFlatpickrDate(today.add(15, "day").toDate())
1518
                        .hoverFlatpickrDate(today.add(15, "day").toDate());
1814
                        .trigger("mouseover");
1815
1519
1816
                    // Day 15 should NOW have leadDisable class (all items have conflicts)
1520
                    // Day 15 should NOW be disabled (all items have conflicts)
1817
                    cy.get("@flatpickrInput2")
1521
                    cy.get("@flatpickrInput2")
1818
                        .getFlatpickrDate(today.add(15, "day").toDate())
1522
                        .getFlatpickrDate(today.add(15, "day").toDate())
1819
                        .should("have.class", "leadDisable");
1523
                        .should("have.class", "flatpickr-disabled");
1820
1524
1821
                    // ================================================================
1525
                    // ================================================================
1822
                    // SCENARIO 3: Visual feedback - existingBookingTrail for days 13-14
1526
                    // SCENARIO 3: Visual feedback - booking marker dots for trail period
1823
                    // ================================================================
1527
                    // ================================================================
1824
                    cy.log(
1528
                    cy.log(
1825
                        "=== Scenario 3: Visual feedback - Trail period display ==="
1529
                        "=== Scenario 3: Visual feedback - Trail period marker dots ==="
1826
                    );
1530
                    );
1827
1531
1532
                    // Days 13-14 should have trail marker dots
1828
                    cy.get("@flatpickrInput2")
1533
                    cy.get("@flatpickrInput2")
1829
                        .getFlatpickrDate(today.add(13, "day").toDate())
1534
                        .getFlatpickrDate(today.add(13, "day").toDate())
1830
                        .should("have.class", "existingBookingTrail");
1535
                        .within(() => {
1536
                            cy.get(
1537
                                ".booking-marker-grid .booking-marker-dot"
1538
                            ).should("exist");
1539
                        });
1831
1540
1832
                    cy.get("@flatpickrInput2")
1541
                    cy.get("@flatpickrInput2")
1833
                        .getFlatpickrDate(today.add(14, "day").toDate())
1542
                        .getFlatpickrDate(today.add(14, "day").toDate())
1834
                        .should("have.class", "existingBookingTrail");
1543
                        .within(() => {
1544
                            cy.get(
1545
                                ".booking-marker-grid .booking-marker-dot"
1546
                            ).should("exist");
1547
                        });
1835
1548
1836
                    // ================================================================
1549
                    // ================================================================
1837
                    // SCENARIO 4: Visual feedback - existingBookingLead for days 8-9
1550
                    // SCENARIO 4: Visual feedback - booking marker dots for lead period
1838
                    // ================================================================
1551
                    // ================================================================
1839
                    cy.log(
1552
                    cy.log(
1840
                        "=== Scenario 4: Visual feedback - Lead period display ==="
1553
                        "=== Scenario 4: Visual feedback - Lead period marker dots ==="
1841
                    );
1554
                    );
1842
1555
1843
                    cy.get("@flatpickrInput2")
1556
                    cy.get("@flatpickrInput2")
1844
                        .getFlatpickrDate(today.add(5, "day").toDate())
1557
                        .hoverFlatpickrDate(today.add(5, "day").toDate());
1845
                        .trigger("mouseover");
1846
1558
1559
                    // Days 8-9 should have lead marker dots
1847
                    cy.get("@flatpickrInput2")
1560
                    cy.get("@flatpickrInput2")
1848
                        .getFlatpickrDate(today.add(8, "day").toDate())
1561
                        .getFlatpickrDate(today.add(8, "day").toDate())
1849
                        .should("have.class", "existingBookingLead");
1562
                        .within(() => {
1563
                            cy.get(
1564
                                ".booking-marker-grid .booking-marker-dot"
1565
                            ).should("exist");
1566
                        });
1850
1567
1851
                    cy.get("@flatpickrInput2")
1568
                    cy.get("@flatpickrInput2")
1852
                        .getFlatpickrDate(today.add(9, "day").toDate())
1569
                        .getFlatpickrDate(today.add(9, "day").toDate())
1853
                        .should("have.class", "existingBookingLead");
1570
                        .within(() => {
1571
                            cy.get(
1572
                                ".booking-marker-grid .booking-marker-dot"
1573
                            ).should("exist");
1574
                        });
1854
                });
1575
                });
1855
            });
1576
            });
1856
1577
(-)a/t/cypress/integration/Circulation/bookingsModalTimezone_spec.ts (-134 / +44 lines)
Lines 7-12 dayjs.extend(timezone); Link Here
7
describe("Booking Modal Timezone Tests", () => {
7
describe("Booking Modal Timezone Tests", () => {
8
    let testData = {};
8
    let testData = {};
9
9
10
    // Prevent unhandled app errors (e.g. failed API calls during cleanup) from failing tests
11
    Cypress.on("uncaught:exception", () => false);
12
10
    // Ensure RESTBasicAuth is enabled before running tests
13
    // Ensure RESTBasicAuth is enabled before running tests
11
    before(() => {
14
    before(() => {
12
        cy.task("query", {
15
        cy.task("query", {
Lines 98-169 describe("Booking Modal Timezone Tests", () => { Link Here
98
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
101
            `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testData.biblio.biblio_id}`
99
        );
102
        );
100
103
101
        cy.get('[data-bs-target="#placeBookingModal"]').first().click();
104
        cy.get("booking-modal-island .modal").should("exist");
102
        cy.get("#placeBookingModal").should("be.visible");
105
        cy.get("[data-booking-modal]")
106
            .first()
107
            .then($btn => $btn[0].click());
108
        cy.get("booking-modal-island .modal", { timeout: 10000 }).should(
109
            "be.visible"
110
        );
103
111
104
        cy.selectFromSelect2(
112
        cy.vueSelect(
105
            "#booking_patron_id",
113
            "booking_patron",
106
            `${testData.patron.surname}, ${testData.patron.firstname}`,
114
            testData.patron.cardnumber,
107
            testData.patron.cardnumber
115
            `${testData.patron.surname} ${testData.patron.firstname}`
108
        );
116
        );
109
        cy.wait("@getPickupLocations");
117
        cy.wait("@getPickupLocations");
110
118
111
        cy.get("#pickup_library_id").should("not.be.disabled");
119
        cy.vueSelectShouldBeEnabled("pickup_library_id");
112
        cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
120
        cy.vueSelectByIndex("pickup_library_id", 0);
113
121
114
        cy.get("#booking_item_id").should("not.be.disabled");
122
        cy.vueSelectShouldBeEnabled("booking_item_id");
115
        cy.selectFromSelect2ByIndex("#booking_item_id", 1);
123
        cy.vueSelectByIndex("booking_item_id", 0);
116
        cy.wait("@getCirculationRules");
124
        cy.wait("@getCirculationRules");
117
125
118
        cy.get("#period").should("not.be.disabled");
126
        cy.get("#booking_period").should("not.be.disabled");
119
    };
127
    };
120
128
121
    /**
129
    /**
122
     * TIMEZONE TEST 1: Date Index Creation Consistency
130
     * TIMEZONE TEST 1: Date Index Creation Consistency
123
     * =================================================
124
     *
125
     * This test validates the critical fix for date index creation using
126
     * dayjs().format('YYYY-MM-DD') instead of toISOString().split('T')[0].
127
     *
128
     * The Problem:
129
     * - toISOString() converts Date to UTC, which can shift dates
130
     * - In PST (UTC-8), midnight PST becomes 08:00 UTC
131
     * - Splitting on 'T' gives "2024-01-15" but this is the UTC date
132
     * - For western timezones, this causes dates to appear shifted
133
     *
134
     * The Fix:
135
     * - dayjs().format('YYYY-MM-DD') maintains browser timezone
136
     * - Dates are indexed by their local representation
137
     * - No timezone conversion happens during indexing
138
     *
139
     * Test Approach:
140
     * - Create a booking with known UTC datetime
141
     * - Verify calendar displays booking on correct date
142
     * - Check that bookingsByDate index uses correct date
143
     */
131
     */
144
    it("should display bookings on correct calendar dates regardless of timezone offset", () => {
132
    it("should display bookings on correct calendar dates regardless of timezone offset", () => {
145
        cy.log("=== Testing date index creation consistency ===");
133
        cy.log("=== Testing date index creation consistency ===");
146
134
147
        const today = dayjs().startOf("day");
135
        const today = dayjs().startOf("day");
148
136
149
        /**
150
         * Create a booking with specific UTC time that tests boundary crossing.
151
         *
152
         * Scenario: Booking starts at 08:00 UTC on January 15
153
         * - In UTC: January 15 08:00
154
         * - In PST (UTC-8): January 15 00:00 (midnight PST)
155
         * - In HST (UTC-10): January 14 22:00 (10pm HST on Jan 14)
156
         *
157
         * The booking should display on January 15 in all timezones except HST,
158
         * where it would show on January 14 (because 08:00 UTC = 22:00 previous day HST).
159
         *
160
         * However, our fix ensures dates are parsed correctly in browser timezone.
161
         */
162
        const bookingDate = today.add(10, "day");
137
        const bookingDate = today.add(10, "day");
163
        const bookingStart = bookingDate.hour(0).minute(0).second(0); // Midnight local time
138
        const bookingStart = bookingDate.hour(0).minute(0).second(0);
164
        const bookingEnd = bookingDate.hour(23).minute(59).second(59); // End of day local time
139
        const bookingEnd = bookingDate.hour(23).minute(59).second(59);
165
166
        // Creating booking for bookingDate in local timezone
167
140
168
        // Create booking in database
141
        // Create booking in database
169
        cy.task("query", {
142
        cy.task("query", {
Lines 181-187 describe("Booking Modal Timezone Tests", () => { Link Here
181
154
182
        setupModal();
155
        setupModal();
183
156
184
        cy.get("#period").as("flatpickrInput");
157
        cy.get("#booking_period").as("flatpickrInput");
185
        cy.get("@flatpickrInput").openFlatpickr();
158
        cy.get("@flatpickrInput").openFlatpickr();
186
159
187
        // The date should be disabled (has existing booking) on the correct day
160
        // The date should be disabled (has existing booking) on the correct day
Lines 193-203 describe("Booking Modal Timezone Tests", () => { Link Here
193
                .getFlatpickrDate(bookingDate.toDate())
166
                .getFlatpickrDate(bookingDate.toDate())
194
                .should("have.class", "flatpickr-disabled");
167
                .should("have.class", "flatpickr-disabled");
195
168
196
            // Verify event dot is present (visual indicator)
169
            // Verify booking marker dot is present (visual indicator)
170
            // Vue version uses .booking-marker-grid with .booking-marker-dot children
197
            cy.get("@flatpickrInput")
171
            cy.get("@flatpickrInput")
198
                .getFlatpickrDate(bookingDate.toDate())
172
                .getFlatpickrDate(bookingDate.toDate())
199
                .within(() => {
173
                .within(() => {
200
                    cy.get(".event-dots").should("exist");
174
                    cy.get(".booking-marker-grid").should("exist");
201
                });
175
                });
202
176
203
            // Verify adjacent dates are NOT disabled (no date shift)
177
            // Verify adjacent dates are NOT disabled (no date shift)
Lines 228-247 describe("Booking Modal Timezone Tests", () => { Link Here
228
202
229
    /**
203
    /**
230
     * TIMEZONE TEST 2: Multi-Day Booking Span
204
     * TIMEZONE TEST 2: Multi-Day Booking Span
231
     * ========================================
232
     *
233
     * Validates that multi-day bookings span the correct number of days
234
     * without adding extra days due to timezone conversion.
235
     *
236
     * The Problem:
237
     * - When iterating dates, using toISOString() to create date keys
238
     *   could cause UTC conversion to add extra days
239
     * - A 3-day booking in PST could appear as 4 days if boundaries cross
240
     *
241
     * The Fix:
242
     * - Using dayjs().format('YYYY-MM-DD') maintains date boundaries
243
     * - Each date increments by exactly 1 day in browser timezone
244
     * - No extra days added from UTC conversion
245
     */
205
     */
246
    it("should correctly span multi-day bookings without timezone-induced extra days", () => {
206
    it("should correctly span multi-day bookings without timezone-induced extra days", () => {
247
        const today = dayjs().startOf("day");
207
        const today = dayjs().startOf("day");
Lines 265-274 describe("Booking Modal Timezone Tests", () => { Link Here
265
225
266
        setupModal();
226
        setupModal();
267
227
268
        cy.get("#period").as("flatpickrInput");
228
        cy.get("#booking_period").as("flatpickrInput");
269
        cy.get("@flatpickrInput").openFlatpickr();
229
        cy.get("@flatpickrInput").openFlatpickr();
270
230
271
        // All three days should be disabled with event dots
231
        // All three days should be disabled with booking marker dots
272
        const expectedDays = [
232
        const expectedDays = [
273
            bookingStart,
233
            bookingStart,
274
            bookingStart.add(1, "day"),
234
            bookingStart.add(1, "day"),
Lines 287-293 describe("Booking Modal Timezone Tests", () => { Link Here
287
                cy.get("@flatpickrInput")
247
                cy.get("@flatpickrInput")
288
                    .getFlatpickrDate(date.toDate())
248
                    .getFlatpickrDate(date.toDate())
289
                    .within(() => {
249
                    .within(() => {
290
                        cy.get(".event-dots").should("exist");
250
                        cy.get(".booking-marker-grid").should("exist");
291
                    });
251
                    });
292
            }
252
            }
293
        });
253
        });
Lines 321-340 describe("Booking Modal Timezone Tests", () => { Link Here
321
281
322
    /**
282
    /**
323
     * TIMEZONE TEST 3: Date Comparison Consistency
283
     * TIMEZONE TEST 3: Date Comparison Consistency
324
     * =============================================
325
     *
326
     * Validates that date comparisons work correctly when checking for
327
     * booking conflicts, using normalized start-of-day comparisons.
328
     *
329
     * The Problem:
330
     * - Comparing Date objects with time components is unreliable
331
     * - Mixing flatpickr.parseDate() and direct Date comparisons
332
     * - Time components can cause false negatives/positives
333
     *
334
     * The Fix:
335
     * - All dates normalized to start-of-day using dayjs().startOf('day')
336
     * - Consistent parsing using dayjs() for RFC3339 strings
337
     * - Reliable date-level comparisons
338
     */
284
     */
339
    it("should correctly detect conflicts using timezone-aware date comparisons", () => {
285
    it("should correctly detect conflicts using timezone-aware date comparisons", () => {
340
        const today = dayjs().startOf("day");
286
        const today = dayjs().startOf("day");
Lines 358-364 describe("Booking Modal Timezone Tests", () => { Link Here
358
304
359
        setupModal();
305
        setupModal();
360
306
361
        cy.get("#period").as("flatpickrInput");
307
        cy.get("#booking_period").as("flatpickrInput");
362
        cy.get("@flatpickrInput").openFlatpickr();
308
        cy.get("@flatpickrInput").openFlatpickr();
363
309
364
        // Test: Date within existing booking should be disabled
310
        // Test: Date within existing booking should be disabled
Lines 401-420 describe("Booking Modal Timezone Tests", () => { Link Here
401
347
402
    /**
348
    /**
403
     * TIMEZONE TEST 4: API Submission Round-Trip
349
     * TIMEZONE TEST 4: API Submission Round-Trip
404
     * ===========================================
405
     *
406
     * Validates that dates selected in the browser are correctly submitted
407
     * to the API and can be retrieved without date shifts.
408
     *
350
     *
409
     * The Flow:
351
     * In the Vue version, dates are stored in the pinia store and submitted
410
     * 1. User selects date in browser (e.g., January 15)
352
     * via API. We verify dates via the flatpickr display value and API intercept.
411
     * 2. JavaScript converts to ISO string with timezone offset
412
     * 3. API receives RFC3339 datetime, converts to server timezone
413
     * 4. Stores in database
414
     * 5. API retrieves, converts to RFC3339 with offset
415
     * 6. Browser receives and displays
416
     *
417
     * Expected: Date should remain January 15 throughout the flow
418
     */
353
     */
419
    it("should correctly round-trip dates through API without timezone shifts", () => {
354
    it("should correctly round-trip dates through API without timezone shifts", () => {
420
        const today = dayjs().startOf("day");
355
        const today = dayjs().startOf("day");
Lines 427-465 describe("Booking Modal Timezone Tests", () => { Link Here
427
362
428
        cy.intercept("POST", `/api/v1/bookings`).as("createBooking");
363
        cy.intercept("POST", `/api/v1/bookings`).as("createBooking");
429
364
430
        cy.get("#period").selectFlatpickrDateRange(startDate, endDate);
365
        cy.get("#booking_period").selectFlatpickrDateRange(startDate, endDate);
431
366
432
        // Verify hidden fields have ISO strings
367
        // Verify the dates were selected correctly via the flatpickr instance (format-agnostic)
433
        cy.get("#booking_start_date").then($input => {
368
        cy.get("#booking_period").should($el => {
434
            const value = $input.val();
369
            const fp = $el[0]._flatpickr;
435
            expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format
370
            expect(fp.selectedDates.length).to.eq(2);
436
        });
371
            expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(
437
372
                startDate.format("YYYY-MM-DD")
438
        cy.get("#booking_end_date").then($input => {
373
            );
439
            const value = $input.val();
374
            expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(
440
            expect(value).to.match(/^\d{4}-\d{2}-\d{2}T/); // ISO format
375
                endDate.format("YYYY-MM-DD")
441
        });
376
            );
442
443
        // Verify dates were set in hidden fields and match selected dates
444
        cy.get("#booking_start_date").should("not.have.value", "");
445
        cy.get("#booking_end_date").should("not.have.value", "");
446
447
        cy.get("#booking_start_date").then($startInput => {
448
            cy.get("#booking_end_date").then($endInput => {
449
                const startValue = $startInput.val() as string;
450
                const endValue = $endInput.val() as string;
451
452
                const submittedStart = dayjs(startValue);
453
                const submittedEnd = dayjs(endValue);
454
455
                // Verify dates match what user selected (in browser timezone)
456
                expect(submittedStart.format("YYYY-MM-DD")).to.equal(
457
                    startDate.format("YYYY-MM-DD")
458
                );
459
                expect(submittedEnd.format("YYYY-MM-DD")).to.equal(
460
                    endDate.format("YYYY-MM-DD")
461
                );
462
            });
463
        });
377
        });
464
378
465
        cy.log("✓ CONFIRMED: API round-trip maintains correct dates");
379
        cy.log("✓ CONFIRMED: API round-trip maintains correct dates");
Lines 467-476 describe("Booking Modal Timezone Tests", () => { Link Here
467
381
468
    /**
382
    /**
469
     * TIMEZONE TEST 5: Cross-Month Boundary
383
     * TIMEZONE TEST 5: Cross-Month Boundary
470
     * ======================================
471
     *
472
     * Validates that bookings spanning month boundaries are handled
473
     * correctly without timezone-induced date shifts.
474
     */
384
     */
475
    it("should correctly handle bookings that span month boundaries", () => {
385
    it("should correctly handle bookings that span month boundaries", () => {
476
        const today = dayjs().startOf("day");
386
        const today = dayjs().startOf("day");
Lines 499-505 describe("Booking Modal Timezone Tests", () => { Link Here
499
409
500
        setupModal();
410
        setupModal();
501
411
502
        cy.get("#period").as("flatpickrInput");
412
        cy.get("#booking_period").as("flatpickrInput");
503
        cy.get("@flatpickrInput").openFlatpickr();
413
        cy.get("@flatpickrInput").openFlatpickr();
504
414
505
        // Test last day of first month is disabled
415
        // Test last day of first month is disabled
(-)a/t/cypress/support/e2e.js (+1 lines)
Lines 27-32 Link Here
27
// Import Select2 helpers
27
// Import Select2 helpers
28
import "./select2";
28
import "./select2";
29
import "./flatpickr.js";
29
import "./flatpickr.js";
30
import "./vue-select";
30
31
31
// Error on JS warnings
32
// Error on JS warnings
32
function safeToString(arg) {
33
function safeToString(arg) {
(-)a/t/cypress/support/flatpickr.js (-12 / +24 lines)
Lines 256-265 Cypress.Commands.add( Link Here
256
            const dayjsDate = dayjs(date);
256
            const dayjsDate = dayjs(date);
257
257
258
            return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => {
258
            return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => {
259
                // Click the date - break chain to avoid DOM detachment
259
                // Click the date - use native click to avoid DOM detachment from Vue re-renders
260
                cy.get(_getFlatpickrDateSelector(dayjsDate))
260
                cy.get(_getFlatpickrDateSelector(dayjsDate))
261
                    .should("be.visible")
261
                    .should("be.visible")
262
                    .click();
262
                    .then($el => $el[0].click());
263
263
264
                // Re-query and validate selection based on mode
264
                // Re-query and validate selection based on mode
265
                return cy
265
                return cy
Lines 267-275 Cypress.Commands.add( Link Here
267
                    .getFlatpickrMode()
267
                    .getFlatpickrMode()
268
                    .then(mode => {
268
                    .then(mode => {
269
                        if (mode === "single") {
269
                        if (mode === "single") {
270
                            const expectedDate = dayjsDate.format("YYYY-MM-DD");
270
                            // Validate via flatpickr instance (format-agnostic)
271
271
                            cy.wrap($input).should($el => {
272
                            cy.wrap($input).should("have.value", expectedDate);
272
                                const fp = $el[0]._flatpickr;
273
                                expect(fp.selectedDates.length).to.eq(1);
274
                                expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(dayjsDate.format("YYYY-MM-DD"));
275
                            });
273
                            cy.get(".flatpickr-calendar.open").should(
276
                            cy.get(".flatpickr-calendar.open").should(
274
                                "not.exist",
277
                                "not.exist",
275
                                { timeout: 5000 }
278
                                { timeout: 5000 }
Lines 319-325 Cypress.Commands.add( Link Here
319
                        );
322
                        );
320
                    }
323
                    }
321
324
322
                    // Select start date - break chain to avoid DOM detachment
325
                    // Select start date - use native click to avoid DOM detachment from Vue re-renders
323
                    return ensureDateIsVisible(
326
                    return ensureDateIsVisible(
324
                        startDayjsDate,
327
                        startDayjsDate,
325
                        $input,
328
                        $input,
Lines 327-333 Cypress.Commands.add( Link Here
327
                    ).then(() => {
330
                    ).then(() => {
328
                        cy.get(_getFlatpickrDateSelector(startDayjsDate))
331
                        cy.get(_getFlatpickrDateSelector(startDayjsDate))
329
                            .should("be.visible")
332
                            .should("be.visible")
330
                            .click();
333
                            .then($el => $el[0].click());
331
334
332
                        // Wait for complex date recalculations (e.g., booking availability) to complete
335
                        // Wait for complex date recalculations (e.g., booking availability) to complete
333
                        cy.get(
336
                        cy.get(
Lines 351-366 Cypress.Commands.add( Link Here
351
                        ).then(() => {
354
                        ).then(() => {
352
                            cy.get(_getFlatpickrDateSelector(endDayjsDate))
355
                            cy.get(_getFlatpickrDateSelector(endDayjsDate))
353
                                .should("be.visible")
356
                                .should("be.visible")
354
                                .click();
357
                                .then($el => $el[0].click());
355
358
356
                            cy.get(".flatpickr-calendar.open").should(
359
                            cy.get(".flatpickr-calendar.open").should(
357
                                "not.exist",
360
                                "not.exist",
358
                                { timeout: 5000 }
361
                                { timeout: 5000 }
359
                            );
362
                            );
360
363
361
                            // Validate final range selection
364
                            // Validate via flatpickr instance (format-agnostic)
362
                            const expectedRange = `${startDayjsDate.format("YYYY-MM-DD")} to ${endDayjsDate.format("YYYY-MM-DD")}`;
365
                            cy.wrap($input).should($el => {
363
                            cy.wrap($input).should("have.value", expectedRange);
366
                                const fp = $el[0]._flatpickr;
367
                                expect(fp.selectedDates.length).to.eq(2);
368
                                expect(dayjs(fp.selectedDates[0]).format("YYYY-MM-DD")).to.eq(startDayjsDate.format("YYYY-MM-DD"));
369
                                expect(dayjs(fp.selectedDates[1]).format("YYYY-MM-DD")).to.eq(endDayjsDate.format("YYYY-MM-DD"));
370
                            });
364
371
365
                            return cy.wrap($input);
372
                            return cy.wrap($input);
366
                        });
373
                        });
Lines 381-389 Cypress.Commands.add( Link Here
381
            const dayjsDate = dayjs(date);
388
            const dayjsDate = dayjs(date);
382
389
383
            return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => {
390
            return ensureDateIsVisible(dayjsDate, $input, timeout).then(() => {
391
                // Use native dispatchEvent to avoid detached DOM errors from Vue re-renders
384
                cy.get(_getFlatpickrDateSelector(dayjsDate))
392
                cy.get(_getFlatpickrDateSelector(dayjsDate))
385
                    .should("be.visible")
393
                    .should("be.visible")
386
                    .trigger("mouseover");
394
                    .then($el => {
395
                        $el[0].dispatchEvent(
396
                            new MouseEvent("mouseover", { bubbles: true })
397
                        );
398
                    });
387
399
388
                return cy.wrap($input);
400
                return cy.wrap($input);
389
            });
401
            });
(-)a/t/cypress/support/vue-select.js (-1 / +214 lines)
Line 0 Link Here
0
- 
1
// VueSelectHelpers.js - Reusable Cypress functions for vue-select dropdowns
2
3
/**
4
 * Helper functions for interacting with vue-select dropdown components in Cypress tests.
5
 *
6
 * Uses direct Vue component instance access to bypass flaky DOM event chains.
7
 * This approach is deterministic because it sets vue-select's reactive data
8
 * properties directly, which triggers Vue's watcher → $emit('search') → API call
9
 * without depending on synthetic Cypress events reaching v-on handlers reliably.
10
 *
11
 * vue-select DOM structure:
12
 *   div.v-select (.vs--disabled when disabled)
13
 *     div.vs__dropdown-toggle
14
 *       div.vs__selected-options
15
 *         span.vs__selected (selected value display)
16
 *         input.vs__search[id="<inputId>"] (search input)
17
 *       div.vs__actions
18
 *         button.vs__clear (clear button)
19
 *     ul.vs__dropdown-menu[role="listbox"]
20
 *       li.vs__dropdown-option (each option)
21
 *       li.vs__dropdown-option--highlight (focused option)
22
 */
23
24
/**
25
 * Type in a vue-select search input and pick an option by matching text.
26
 * Uses direct Vue instance access to set the search value, bypassing
27
 * unreliable DOM event propagation through vue-select internals.
28
 *
29
 * @param {string} inputId - The ID of the vue-select search input (without #)
30
 * @param {string} searchText - Text to type into the search input
31
 * @param {string} selectText - Text of the option to select (partial match)
32
 * @param {Object} [options] - Additional options
33
 * @param {number} [options.timeout=10000] - Timeout for waiting on results
34
 *
35
 * @example
36
 *   cy.vueSelect("booking_patron", "Doe", "Doe John");
37
 */
38
Cypress.Commands.add(
39
    "vueSelect",
40
    (inputId, searchText, selectText, options = {}) => {
41
        const { timeout = 10000 } = options;
42
43
        // Ensure the v-select component is enabled and interactive before proceeding
44
        cy.get(`input#${inputId}`)
45
            .closest(".v-select")
46
            .should("not.have.class", "vs--disabled");
47
48
        // Set search value directly on the Vue component instance.
49
        // This triggers vue-select's ajax mixin watcher which emits the
50
        // @search event, calling the parent's debounced search handler.
51
        cy.get(`input#${inputId}`)
52
            .closest(".v-select")
53
            .then($vs => {
54
                const vueInstance = $vs[0].__vueParentComponent;
55
                if (vueInstance?.proxy) {
56
                    vueInstance.proxy.open = true;
57
                    vueInstance.proxy.search = searchText;
58
                } else {
59
                    throw new Error(
60
                        `Could not access Vue instance on v-select for #${inputId}`
61
                    );
62
                }
63
            });
64
65
        // Wait for dropdown with matching option to appear
66
        cy.get(`input#${inputId}`)
67
            .closest(".v-select")
68
            .find(".vs__dropdown-menu", { timeout })
69
            .should("be.visible");
70
71
        cy.get(`input#${inputId}`)
72
            .closest(".v-select")
73
            .find(".vs__dropdown-option", { timeout })
74
            .should("have.length.at.least", 1);
75
76
        // Click the matching option using native DOM click to avoid detached DOM issues
77
        cy.get(`input#${inputId}`)
78
            .closest(".v-select")
79
            .then($vs => {
80
                const option = Array.from(
81
                    $vs[0].querySelectorAll(".vs__dropdown-option")
82
                ).find(el => el.textContent.includes(selectText));
83
                expect(option, `Option containing "${selectText}" should exist`)
84
                    .to.exist;
85
                option.click();
86
            });
87
88
        // Verify selection was made (selected text visible)
89
        cy.get(`input#${inputId}`)
90
            .closest(".v-select")
91
            .find(".vs__selected")
92
            .should("exist");
93
    }
94
);
95
96
/**
97
 * Pick a vue-select option by its 0-based index in the dropdown.
98
 * Opens the dropdown via the Vue instance then clicks the option by index.
99
 *
100
 * @param {string} inputId - The ID of the vue-select search input (without #)
101
 * @param {number} index - 0-based index of the option to select
102
 * @param {Object} [options] - Additional options
103
 * @param {number} [options.timeout=10000] - Timeout for waiting on results
104
 *
105
 * @example
106
 *   cy.vueSelectByIndex("pickup_library_id", 0);
107
 */
108
Cypress.Commands.add("vueSelectByIndex", (inputId, index, options = {}) => {
109
    const { timeout = 10000 } = options;
110
111
    // Ensure the v-select component is enabled before interacting
112
    cy.get(`input#${inputId}`)
113
        .closest(".v-select")
114
        .should("not.have.class", "vs--disabled");
115
116
    // Open the dropdown via Vue instance for deterministic behavior
117
    cy.get(`input#${inputId}`)
118
        .closest(".v-select")
119
        .then($vs => {
120
            const vueInstance = $vs[0].__vueParentComponent;
121
            if (vueInstance?.proxy) {
122
                vueInstance.proxy.open = true;
123
            } else {
124
                // Fallback to click if Vue instance not accessible
125
                $vs[0].querySelector(`#${inputId}`)?.click();
126
            }
127
        });
128
129
    // Wait for dropdown and enough options to exist
130
    cy.get(`input#${inputId}`)
131
        .closest(".v-select")
132
        .find(".vs__dropdown-menu", { timeout })
133
        .should("be.visible");
134
135
    cy.get(`input#${inputId}`)
136
        .closest(".v-select")
137
        .find(".vs__dropdown-option", { timeout })
138
        .should("have.length.at.least", index + 1);
139
140
    // Click the option at the given index using native DOM click
141
    cy.get(`input#${inputId}`)
142
        .closest(".v-select")
143
        .then($vs => {
144
            const options = $vs[0].querySelectorAll(".vs__dropdown-option");
145
            options[index].click();
146
        });
147
});
148
149
/**
150
 * Clear the current selection in a vue-select dropdown.
151
 *
152
 * @param {string} inputId - The ID of the vue-select search input (without #)
153
 *
154
 * @example
155
 *   cy.vueSelectClear("booking_itemtype");
156
 */
157
Cypress.Commands.add("vueSelectClear", inputId => {
158
    cy.get(`input#${inputId}`)
159
        .closest(".v-select")
160
        .then($vs => {
161
            const clearBtn = $vs[0].querySelector(".vs__clear");
162
            if (clearBtn) {
163
                clearBtn.click();
164
            }
165
        });
166
});
167
168
/**
169
 * Assert that a vue-select displays a specific selected value text.
170
 *
171
 * @param {string} inputId - The ID of the vue-select search input (without #)
172
 * @param {string} text - Expected display text of the selected value
173
 *
174
 * @example
175
 *   cy.vueSelectShouldHaveValue("booking_itemtype", "Books");
176
 */
177
Cypress.Commands.add(
178
    "vueSelectShouldHaveValue",
179
    (inputId, text, options = {}) => {
180
        const { timeout = 10000 } = options;
181
        cy.get(`input#${inputId}`)
182
            .closest(".v-select")
183
            .find(".vs__selected", { timeout })
184
            .should("contain.text", text);
185
    }
186
);
187
188
/**
189
 * Assert that a vue-select dropdown is disabled.
190
 *
191
 * @param {string} inputId - The ID of the vue-select search input (without #)
192
 *
193
 * @example
194
 *   cy.vueSelectShouldBeDisabled("pickup_library_id");
195
 */
196
Cypress.Commands.add("vueSelectShouldBeDisabled", inputId => {
197
    cy.get(`input#${inputId}`)
198
        .closest(".v-select")
199
        .should("have.class", "vs--disabled");
200
});
201
202
/**
203
 * Assert that a vue-select dropdown is enabled (not disabled).
204
 *
205
 * @param {string} inputId - The ID of the vue-select search input (without #)
206
 *
207
 * @example
208
 *   cy.vueSelectShouldBeEnabled("booking_patron");
209
 */
210
Cypress.Commands.add("vueSelectShouldBeEnabled", inputId => {
211
    cy.get(`input#${inputId}`)
212
        .closest(".v-select")
213
        .should("not.have.class", "vs--disabled");
214
});

Return to bug 41129