|
Lines 93-121
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 93 |
let $patron = $("<span></span>") |
93 |
let $patron = $("<span></span>") |
| 94 |
.append( |
94 |
.append( |
| 95 |
"" + |
95 |
"" + |
| 96 |
(patron.surname |
96 |
(patron.surname |
| 97 |
? escape_str(patron.surname) + ", " |
97 |
? escape_str(patron.surname) + ", " |
| 98 |
: "") + |
98 |
: "") + |
| 99 |
(patron.firstname |
99 |
(patron.firstname |
| 100 |
? escape_str(patron.firstname) + " " |
100 |
? escape_str(patron.firstname) + " " |
| 101 |
: "") + |
101 |
: "") + |
| 102 |
(patron.cardnumber |
102 |
(patron.cardnumber |
| 103 |
? " (" + escape_str(patron.cardnumber) + ")" |
103 |
? " (" + escape_str(patron.cardnumber) + ")" |
| 104 |
: "") + |
104 |
: "") + |
| 105 |
"<small>" + |
105 |
"<small>" + |
| 106 |
(patron.date_of_birth |
106 |
(patron.date_of_birth |
| 107 |
? ' <span class="age_years">' + |
107 |
? ' <span class="age_years">' + |
| 108 |
$get_age(patron.date_of_birth) + |
108 |
$get_age(patron.date_of_birth) + |
| 109 |
" " + |
109 |
" " + |
| 110 |
__("years") + |
110 |
__("years") + |
| 111 |
"</span>" |
111 |
"</span>" |
| 112 |
: "") + |
112 |
: "") + |
| 113 |
(patron.library |
113 |
(patron.library |
| 114 |
? ' <span class="ac-library">' + |
114 |
? ' <span class="ac-library">' + |
| 115 |
escape_str(patron.library.name) + |
115 |
escape_str(patron.library.name) + |
| 116 |
"</span>" |
116 |
"</span>" |
| 117 |
: "") + |
117 |
: "") + |
| 118 |
"</small>" |
118 |
"</small>" |
| 119 |
) |
119 |
) |
| 120 |
.addClass(loggedInClass); |
120 |
.addClass(loggedInClass); |
| 121 |
return $patron; |
121 |
return $patron; |
|
Lines 212-221
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 212 |
) { |
212 |
) { |
| 213 |
let option = $( |
213 |
let option = $( |
| 214 |
'<option value="' + |
214 |
'<option value="' + |
| 215 |
pickup_location.library_id + |
215 |
pickup_location.library_id + |
| 216 |
'">' + |
216 |
'">' + |
| 217 |
pickup_location.name + |
217 |
pickup_location.name + |
| 218 |
"</option>" |
218 |
"</option>" |
| 219 |
); |
219 |
); |
| 220 |
|
220 |
|
| 221 |
option.attr( |
221 |
option.attr( |
|
Lines 414-421
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 414 |
if ( |
414 |
if ( |
| 415 |
!$("#booking_itemtype").find( |
415 |
!$("#booking_itemtype").find( |
| 416 |
"option[value='" + |
416 |
"option[value='" + |
| 417 |
item.item_type.item_type_id + |
417 |
item.item_type.item_type_id + |
| 418 |
"']" |
418 |
"']" |
| 419 |
).length |
419 |
).length |
| 420 |
) { |
420 |
) { |
| 421 |
// Create a DOM Option and de-select by default |
421 |
// Create a DOM Option and de-select by default |
|
Lines 440-454
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 440 |
// set local copy of selectedDates |
440 |
// set local copy of selectedDates |
| 441 |
let selectedDates = periodPicker.selectedDates; |
441 |
let selectedDates = periodPicker.selectedDates; |
| 442 |
|
442 |
|
| 443 |
// set booked counter |
|
|
| 444 |
let booked = 0; |
| 445 |
|
| 446 |
// reset the unavailable items array |
| 447 |
let unavailable_items = []; |
| 448 |
|
| 449 |
// reset the biblio level bookings array |
| 450 |
let biblio_bookings = []; |
| 451 |
|
| 452 |
// disable dates before selected date |
443 |
// disable dates before selected date |
| 453 |
if ( |
444 |
if ( |
| 454 |
!selectedDates[1] && |
445 |
!selectedDates[1] && |
|
Lines 458-616
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 458 |
return true; |
449 |
return true; |
| 459 |
} |
450 |
} |
| 460 |
|
451 |
|
| 461 |
// iterate existing bookings |
452 |
// We should always have an itemtype selected and either specific item or "any item" |
| 462 |
for (let booking of bookings) { |
453 |
if (!booking_itemtype_id) { |
| 463 |
// Skip if we're editing this booking |
454 |
return true; // No itemtype selected, disable everything |
| 464 |
if ( |
455 |
} |
| 465 |
booking_id && |
|
|
| 466 |
booking_id == booking.booking_id |
| 467 |
) { |
| 468 |
continue; |
| 469 |
} |
| 470 |
|
456 |
|
| 471 |
let start_date = flatpickr.parseDate( |
457 |
// If "any item of itemtype" is selected, use smart window maximization |
| 472 |
booking.start_date |
458 |
if (!booking_item_id) { |
|
|
459 |
return isDateDisabledForItemtype( |
| 460 |
date, |
| 461 |
selectedDates |
| 473 |
); |
462 |
); |
| 474 |
let end_date = flatpickr.parseDate( |
463 |
} |
| 475 |
booking.end_date |
464 |
// If specific item is selected, use item-specific logic |
|
|
465 |
else { |
| 466 |
return isDateDisabledForSpecificItem( |
| 467 |
date, |
| 468 |
selectedDates |
| 476 |
); |
469 |
); |
|
|
470 |
} |
| 471 |
} |
| 472 |
); |
| 473 |
} |
| 477 |
|
474 |
|
| 478 |
// patron has selected a start date (end date checks) |
475 |
/** |
| 479 |
if (selectedDates[0]) { |
476 |
* SMART ITEMTYPE AVAILABILITY CALCULATION |
| 480 |
// new booking start date is between existing booking start and end dates |
477 |
* For "any item of type X" bookings with dynamic item pool reduction |
| 481 |
if ( |
478 |
* |
| 482 |
selectedDates[0] >= start_date && |
479 |
* ALGORITHM OVERVIEW: |
| 483 |
selectedDates[0] <= end_date |
480 |
* This function implements smart window maximization for itemtype bookings by using |
| 484 |
) { |
481 |
* dynamic item pool reduction. The core principle is "never re-add items to pool" - |
| 485 |
if (booking.item_id) { |
482 |
* once an item is removed because it becomes unavailable, it's never re-added even |
| 486 |
if ( |
483 |
* if it becomes available again later. This ensures optimal resource allocation. |
| 487 |
unavailable_items.indexOf( |
484 |
* |
| 488 |
booking.item_id |
485 |
* FLOW: |
| 489 |
) === -1 |
486 |
* 1. For start date selection: Disable if ALL items of itemtype are booked |
| 490 |
) { |
487 |
* 2. For end date selection: Use smart window maximization algorithm |
| 491 |
unavailable_items.push( |
488 |
* |
| 492 |
booking.item_id |
489 |
* SMART WINDOW MAXIMIZATION: |
| 493 |
); |
490 |
* - Start with items available on the selected start date |
| 494 |
} |
491 |
* - Walk through each day from start to target end date |
| 495 |
} else { |
492 |
* - Remove items from pool when they become unavailable |
| 496 |
if ( |
493 |
* - NEVER re-add items even if they become available again later |
| 497 |
biblio_bookings.indexOf( |
494 |
* - Disable date when no items remain in pool |
| 498 |
booking.booking_id |
495 |
* |
| 499 |
) === -1 |
496 |
* EXAMPLE: |
| 500 |
) { |
497 |
* Items: A, B, C |
| 501 |
biblio_bookings.push( |
498 |
* A available: days 1-5, booked 6-10, available again 11+ |
| 502 |
booking.booking_id |
499 |
* B available: days 1-8, booked 9-15, available again 16+ |
| 503 |
); |
500 |
* C available: days 1-12, booked 13-20, available again 21+ |
| 504 |
} |
501 |
* |
| 505 |
} |
502 |
* Start day 3: |
| 506 |
} |
503 |
* - Initial pool: A, B, C |
|
|
504 |
* - Days 3-5: Pool A, B, C (all available) |
| 505 |
* - Day 6: Remove A (becomes booked), Pool now B, C |
| 506 |
* - Day 9: Remove B (becomes booked), Pool now C |
| 507 |
* - Day 13: Remove C (becomes booked), Pool now EMPTY → disable dates |
| 508 |
* - Result: Can book days 3-12, day 13+ disabled |
| 509 |
* - Note: A becomes available on day 11 but is NOT re-added to pool |
| 510 |
* |
| 511 |
* @param {Date} date - The date being checked for availability |
| 512 |
* @param {Array} selectedDates - Array of selected dates from flatpickr [startDate, endDate?] |
| 513 |
* @returns {boolean} - True if date should be disabled, false if available |
| 514 |
*/ |
| 515 |
function isDateDisabledForItemtype(date, selectedDates) { |
| 516 |
// Get items of the selected itemtype |
| 517 |
let itemsOfType = bookable_items.filter( |
| 518 |
item => |
| 519 |
item.effective_item_type_id === booking_itemtype_id |
| 520 |
); |
| 507 |
|
521 |
|
| 508 |
// new booking end date would be between existing booking start and end dates |
522 |
// For start date selection: disable if ALL items of itemtype are booked on this date |
| 509 |
else if ( |
523 |
if (!selectedDates[0]) { |
| 510 |
date >= start_date && |
524 |
return ( |
| 511 |
date <= end_date |
525 |
getAvailableItemsOnDate(date, itemsOfType) |
| 512 |
) { |
526 |
.length === 0 |
| 513 |
if (booking.item_id) { |
527 |
); |
| 514 |
if ( |
528 |
} |
| 515 |
unavailable_items.indexOf( |
|
|
| 516 |
booking.item_id |
| 517 |
) === -1 |
| 518 |
) { |
| 519 |
unavailable_items.push( |
| 520 |
booking.item_id |
| 521 |
); |
| 522 |
} |
| 523 |
} else { |
| 524 |
if ( |
| 525 |
biblio_bookings.indexOf( |
| 526 |
booking.booking_id |
| 527 |
) === -1 |
| 528 |
) { |
| 529 |
biblio_bookings.push( |
| 530 |
booking.booking_id |
| 531 |
); |
| 532 |
} |
| 533 |
} |
| 534 |
} |
| 535 |
|
529 |
|
| 536 |
// new booking would span existing booking |
530 |
// For end date selection: use smart window maximization |
| 537 |
else if ( |
531 |
if (selectedDates[0] && !selectedDates[1]) { |
| 538 |
selectedDates[0] <= start_date && |
532 |
let result = !isDateInMaximumWindow( |
| 539 |
date >= end_date |
533 |
selectedDates[0], |
| 540 |
) { |
534 |
date, |
| 541 |
if (booking.item_id) { |
535 |
itemsOfType |
| 542 |
if ( |
536 |
); |
| 543 |
unavailable_items.indexOf( |
537 |
return result; |
| 544 |
booking.item_id |
538 |
} |
| 545 |
) === -1 |
|
|
| 546 |
) { |
| 547 |
unavailable_items.push( |
| 548 |
booking.item_id |
| 549 |
); |
| 550 |
} |
| 551 |
} else { |
| 552 |
if ( |
| 553 |
biblio_bookings.indexOf( |
| 554 |
booking.booking_id |
| 555 |
) === -1 |
| 556 |
) { |
| 557 |
biblio_bookings.push( |
| 558 |
booking.booking_id |
| 559 |
); |
| 560 |
} |
| 561 |
} |
| 562 |
} |
| 563 |
|
539 |
|
| 564 |
// new booking would not conflict |
540 |
return false; |
| 565 |
else { |
541 |
} |
| 566 |
continue; |
|
|
| 567 |
} |
| 568 |
|
542 |
|
| 569 |
// check that there are available items |
543 |
/** |
| 570 |
// available = all bookable items - booked items - booked biblios |
544 |
* MAXIMUM BOOKING WINDOW CALCULATION ALGORITHM |
| 571 |
let total_available = |
545 |
* Core Implementation of "Never Re-add Items to Pool" Principle |
| 572 |
bookable_items.length - |
546 |
* |
| 573 |
unavailable_items.length - |
547 |
* PURPOSE: |
| 574 |
biblio_bookings.length; |
548 |
* Calculate the maximum possible booking window for "any item of itemtype X" bookings |
| 575 |
if (total_available === 0) { |
549 |
* by dynamically reducing the available item pool as items become unavailable. |
| 576 |
return true; |
550 |
* |
| 577 |
} |
551 |
* CORE ALGORITHM: "Never Re-add Items to Pool" |
| 578 |
} |
552 |
* 1. Start with items available on the selected start date ONLY |
|
|
553 |
* 2. Walk through each day from start to target end date |
| 554 |
* 3. Remove items from pool when they become unavailable (booking starts) |
| 555 |
* 4. NEVER re-add items even if they become available again later (booking ends) |
| 556 |
* 5. Return false (disable date) when no items remain in pool |
| 557 |
* |
| 558 |
* WHY THIS WORKS: |
| 559 |
* - Maximizes booking windows by ensuring optimal resource allocation |
| 560 |
* - Prevents booking conflicts by being conservative about item availability |
| 561 |
* - Ensures that if a booking can start on date X, there will always be an |
| 562 |
* item available for the entire duration (no conflicts) |
| 563 |
* |
| 564 |
* DETAILED EXAMPLE: |
| 565 |
* Items: TABLET001, TABLET002, TABLET003 |
| 566 |
* TABLET001: Available 1-9, Booked 10-15, Available 16+ |
| 567 |
* TABLET002: Available 1-12, Booked 13-20, Available 21+ |
| 568 |
* TABLET003: Available 1-17, Booked 18-25, Available 26+ |
| 569 |
* |
| 570 |
* Testing: Can we book from day 5 to day 20? |
| 571 |
* |
| 572 |
* Step 1: Day 5 (start) - Initial pool: {TABLET001, TABLET002, TABLET003} |
| 573 |
* Step 2: Day 6-9 - All items available, pool unchanged |
| 574 |
* Step 3: Day 10 - TABLET001 becomes unavailable → Remove from pool |
| 575 |
* Pool now: {TABLET002, TABLET003} |
| 576 |
* Step 4: Day 11-12 - Remaining items available, pool unchanged |
| 577 |
* Step 5: Day 13 - TABLET002 becomes unavailable → Remove from pool |
| 578 |
* Pool now: {TABLET003} |
| 579 |
* Step 6: Day 14-17 - TABLET003 available, pool unchanged |
| 580 |
* Step 7: Day 18 - TABLET003 becomes unavailable → Remove from pool |
| 581 |
* Pool now: {} (empty) |
| 582 |
* Step 8: Pool is empty → Return false (cannot book to day 20) |
| 583 |
* |
| 584 |
* Result: Can book from day 5 to day 17, but NOT to day 18+ |
| 585 |
* |
| 586 |
* CRITICAL NOTE: Even though TABLET001 becomes available again on day 16, |
| 587 |
* it is NOT re-added to the pool. This is the key principle that ensures |
| 588 |
* booking reliability and optimal resource allocation. |
| 589 |
* |
| 590 |
* PERFORMANCE: O(n × d) where n = items of type, d = days in range |
| 591 |
* |
| 592 |
* @param {Date} startDate - Selected start date from flatpickr |
| 593 |
* @param {Date} endDate - Target end date being checked for availability |
| 594 |
* @param {Array} itemsOfType - Items of the selected itemtype |
| 595 |
* @returns {boolean} - True if date is within maximum window, false if beyond |
| 596 |
*/ |
| 597 |
function isDateInMaximumWindow( |
| 598 |
startDate, |
| 599 |
endDate, |
| 600 |
itemsOfType |
| 601 |
) { |
| 602 |
// Start with only items available on the start date - never add items back |
| 603 |
let availableOnStart = getAvailableItemsOnDate( |
| 604 |
startDate, |
| 605 |
itemsOfType |
| 606 |
); |
| 607 |
let availableItems = new Set( |
| 608 |
availableOnStart.map(item => parseInt(item.item_id, 10)) |
| 609 |
); |
| 579 |
|
610 |
|
| 580 |
// patron has not yet selected a start date (start date checks) |
611 |
let currentDate = dayjs(startDate); |
| 581 |
else if ( |
|
|
| 582 |
date <= end_date && |
| 583 |
date >= start_date |
| 584 |
) { |
| 585 |
// same item, disable date |
| 586 |
if ( |
| 587 |
booking.item_id && |
| 588 |
booking.item_id == booking_item_id |
| 589 |
) { |
| 590 |
return true; |
| 591 |
} |
| 592 |
|
612 |
|
| 593 |
// count all clashes, both item and biblio level |
613 |
// Walk through each day from start to end date |
| 594 |
booked++; |
614 |
while (currentDate.isSameOrBefore(endDate, "day")) { |
| 595 |
if (booked == bookable) { |
615 |
let availableToday = getAvailableItemsOnDate( |
| 596 |
return true; |
616 |
currentDate, |
| 597 |
} |
617 |
itemsOfType |
|
|
618 |
); |
| 619 |
let availableIds = new Set( |
| 620 |
availableToday.map(item => |
| 621 |
parseInt(item.item_id, 10) |
| 622 |
) |
| 623 |
); |
| 598 |
|
624 |
|
| 599 |
// FIXME: The above is not intelligent enough to spot |
625 |
// Remove items from our pool that are no longer available (never add back) |
| 600 |
// cases where an item must be used for a biblio level booking |
626 |
// Only remove items that are unavailable today, don't re-add previously removed items |
| 601 |
// due to all other items being booking within the biblio level |
627 |
let itemsToRemove = []; |
| 602 |
// booking period... we end up with a clash |
628 |
for (let itemId of availableItems) { |
| 603 |
// To reproduce: |
629 |
if (!availableIds.has(itemId)) { |
| 604 |
// * One bib with two bookable items. |
630 |
itemsToRemove.push(itemId); |
| 605 |
// * Add item level booking |
|
|
| 606 |
// * Add biblio level booking that extends one day beyond the item level booking |
| 607 |
// * Try to book the item without an item level booking from the day before the biblio level |
| 608 |
// booking is to be returned. Note this is a clash, the only item available for the biblio |
| 609 |
// level booking is the item you just booked out overlapping the end date. |
| 610 |
} |
| 611 |
} |
631 |
} |
| 612 |
} |
632 |
} |
|
|
633 |
itemsToRemove.forEach(itemId => |
| 634 |
availableItems.delete(itemId) |
| 635 |
); |
| 636 |
|
| 637 |
// If no items left in the pool, this date is beyond the maximum window |
| 638 |
if (availableItems.size === 0) { |
| 639 |
return false; |
| 640 |
} |
| 641 |
|
| 642 |
// Move to next day |
| 643 |
currentDate = currentDate.add(1, "day"); |
| 644 |
} |
| 645 |
|
| 646 |
return true; // Date is within the maximum window |
| 647 |
} |
| 648 |
|
| 649 |
// Get items of itemtype that are available on a specific date |
| 650 |
function getAvailableItemsOnDate(date, itemsOfType) { |
| 651 |
let unavailableItems = new Set(); |
| 652 |
|
| 653 |
// Check all existing bookings for conflicts on this date |
| 654 |
for (let booking of bookings) { |
| 655 |
// Skip if we're editing this booking |
| 656 |
if (booking_id && booking_id == booking.booking_id) { |
| 657 |
continue; |
| 658 |
} |
| 659 |
|
| 660 |
let start_date = dayjs(booking.start_date); |
| 661 |
let end_date = dayjs(booking.end_date); |
| 662 |
let checkDate = dayjs(date); |
| 663 |
|
| 664 |
// Check if this date falls within this booking period |
| 665 |
if ( |
| 666 |
checkDate.isSameOrAfter(start_date, "day") && |
| 667 |
checkDate.isSameOrBefore(end_date, "day") |
| 668 |
) { |
| 669 |
// All bookings have item_id, so mark this specific item as unavailable |
| 670 |
// Ensure integer comparison consistency |
| 671 |
unavailableItems.add(parseInt(booking.item_id, 10)); |
| 672 |
} |
| 673 |
} |
| 674 |
|
| 675 |
// Return items of our type that are not unavailable |
| 676 |
let available = itemsOfType.filter( |
| 677 |
item => |
| 678 |
!unavailableItems.has(parseInt(item.item_id, 10)) |
| 613 |
); |
679 |
); |
|
|
680 |
return available; |
| 681 |
} |
| 682 |
|
| 683 |
// Item-specific availability logic for specific item bookings |
| 684 |
function isDateDisabledForSpecificItem(date, selectedDates) { |
| 685 |
for (let booking of bookings) { |
| 686 |
// Skip if we're editing this booking |
| 687 |
if (booking_id && booking_id == booking.booking_id) { |
| 688 |
continue; |
| 689 |
} |
| 690 |
|
| 691 |
let start_date = dayjs(booking.start_date); |
| 692 |
let end_date = dayjs(booking.end_date); |
| 693 |
let checkDate = dayjs(date); |
| 694 |
|
| 695 |
// Check if this booking conflicts with our selected item and date |
| 696 |
if ( |
| 697 |
checkDate.isSameOrAfter(start_date, "day") && |
| 698 |
checkDate.isSameOrBefore(end_date, "day") |
| 699 |
) { |
| 700 |
// Same item, disable date (ensure integer comparison) |
| 701 |
if ( |
| 702 |
parseInt(booking.item_id, 10) === |
| 703 |
parseInt(booking_item_id, 10) |
| 704 |
) { |
| 705 |
return true; |
| 706 |
} |
| 707 |
} |
| 708 |
} |
| 709 |
return false; |
| 710 |
} |
| 711 |
|
| 712 |
/** |
| 713 |
* OPTIMAL ITEM SELECTION FOR "ANY ITEM" BOOKINGS |
| 714 |
* |
| 715 |
* PURPOSE: |
| 716 |
* When submitting an "any item of itemtype X" booking, select the specific item |
| 717 |
* that provides the best future availability for subsequent bookings. |
| 718 |
* |
| 719 |
* SELECTION CRITERIA: |
| 720 |
* 1. Item must be available for the entire requested booking period |
| 721 |
* 2. Among available items, choose the one with longest future availability |
| 722 |
* 3. This maximizes opportunities for future bookings by preserving |
| 723 |
* items with shorter future availability for those bookings |
| 724 |
* |
| 725 |
* ALGORITHM: |
| 726 |
* 1. Filter items of the specified type |
| 727 |
* 2. Find items available for the entire booking period |
| 728 |
* 3. For each available item, calculate future availability after booking ends |
| 729 |
* 4. Select item with longest future availability |
| 730 |
* 5. If tie, select first item (arbitrary but consistent) |
| 731 |
* |
| 732 |
* EXAMPLE: |
| 733 |
* Booking period: Days 5-10 |
| 734 |
* TABLET001: Available 5-10, then booked 11-15, then free 16+ |
| 735 |
* TABLET002: Available 5-10, then free until 25, then booked 26+ |
| 736 |
* TABLET003: Available 5-10, then booked 11-30 |
| 737 |
* |
| 738 |
* Future availability calculations (after day 10): |
| 739 |
* TABLET001: 0 days (booked immediately after) |
| 740 |
* TABLET002: 15 days (free until day 25) |
| 741 |
* TABLET003: 0 days (booked immediately after) |
| 742 |
* |
| 743 |
* Result: Select TABLET002 (longest future availability) |
| 744 |
* Benefit: Preserves TABLET001 for bookings that might need days 11-15 |
| 745 |
* |
| 746 |
* @param {string} itemTypeId - The itemtype ID to select from |
| 747 |
* @param {Date} startDate - Booking start date |
| 748 |
* @param {Date} endDate - Booking end date |
| 749 |
* @returns {string|null} - Selected item ID, or null if none available |
| 750 |
*/ |
| 751 |
function selectOptimalItem(itemTypeId, startDate, endDate) { |
| 752 |
// Get items of the selected itemtype |
| 753 |
let itemsOfType = bookable_items.filter( |
| 754 |
item => item.effective_item_type_id === itemTypeId |
| 755 |
); |
| 756 |
|
| 757 |
// Find items available for the entire booking period |
| 758 |
let availableItems = itemsOfType.filter(item => { |
| 759 |
return isItemAvailableForPeriod( |
| 760 |
item.item_id, |
| 761 |
startDate, |
| 762 |
endDate |
| 763 |
); |
| 764 |
}); |
| 765 |
|
| 766 |
if (availableItems.length === 0) { |
| 767 |
return null; // No items available |
| 768 |
} |
| 769 |
|
| 770 |
if (availableItems.length === 1) { |
| 771 |
return availableItems[0].item_id; // Only one choice |
| 772 |
} |
| 773 |
|
| 774 |
// Calculate future availability for each item and choose the best one |
| 775 |
let bestItem = null; |
| 776 |
let longestFutureAvailability = -1; |
| 777 |
|
| 778 |
for (let item of availableItems) { |
| 779 |
let futureAvailability = calculateFutureAvailability( |
| 780 |
item.item_id, |
| 781 |
endDate |
| 782 |
); |
| 783 |
|
| 784 |
if (futureAvailability > longestFutureAvailability) { |
| 785 |
longestFutureAvailability = futureAvailability; |
| 786 |
bestItem = item; |
| 787 |
} |
| 788 |
} |
| 789 |
|
| 790 |
return bestItem |
| 791 |
? bestItem.item_id |
| 792 |
: availableItems[0].item_id; |
| 793 |
} |
| 794 |
|
| 795 |
// Check if a specific item is available for the entire booking period |
| 796 |
function isItemAvailableForPeriod(itemId, startDate, endDate) { |
| 797 |
for (let booking of bookings) { |
| 798 |
// Skip if we're editing this booking |
| 799 |
if (booking_id && booking_id == booking.booking_id) { |
| 800 |
continue; |
| 801 |
} |
| 802 |
|
| 803 |
if (booking.item_id !== itemId) { |
| 804 |
continue; // Different item, no conflict |
| 805 |
} |
| 806 |
|
| 807 |
let booking_start = dayjs(booking.start_date); |
| 808 |
let booking_end = dayjs(booking.end_date); |
| 809 |
let checkStartDate = dayjs(startDate); |
| 810 |
let checkEndDate = dayjs(endDate); |
| 811 |
|
| 812 |
// Check for any overlap with our booking period |
| 813 |
if ( |
| 814 |
!( |
| 815 |
checkEndDate.isBefore(booking_start, "day") || |
| 816 |
checkStartDate.isAfter(booking_end, "day") |
| 817 |
) |
| 818 |
) { |
| 819 |
return false; // Overlap detected |
| 820 |
} |
| 821 |
} |
| 822 |
return true; // No conflicts found |
| 823 |
} |
| 824 |
|
| 825 |
// Calculate how many days an item is available after the booking end date |
| 826 |
function calculateFutureAvailability(itemId, bookingEndDate) { |
| 827 |
let daysAvailable = 0; |
| 828 |
let checkDate = dayjs(bookingEndDate).add(1, "day"); // Start day after booking ends |
| 829 |
|
| 830 |
// Check availability for next 365 days (or until we hit a booking) |
| 831 |
for (let i = 0; i < 365; i++) { |
| 832 |
let isAvailable = true; |
| 833 |
|
| 834 |
for (let booking of bookings) { |
| 835 |
// Skip if we're editing this booking |
| 836 |
if ( |
| 837 |
booking_id && |
| 838 |
booking_id == booking.booking_id |
| 839 |
) { |
| 840 |
continue; |
| 841 |
} |
| 842 |
|
| 843 |
if (booking.item_id !== itemId) { |
| 844 |
continue; // Different item |
| 845 |
} |
| 846 |
|
| 847 |
let booking_start = dayjs(booking.start_date); |
| 848 |
let booking_end = dayjs(booking.end_date); |
| 849 |
|
| 850 |
// Check if this date is within an existing booking |
| 851 |
if ( |
| 852 |
checkDate.isSameOrAfter(booking_start, "day") && |
| 853 |
checkDate.isSameOrBefore(booking_end, "day") |
| 854 |
) { |
| 855 |
isAvailable = false; |
| 856 |
break; |
| 857 |
} |
| 858 |
} |
| 859 |
|
| 860 |
if (!isAvailable) { |
| 861 |
break; // Hit a booking, stop counting |
| 862 |
} |
| 863 |
|
| 864 |
daysAvailable++; |
| 865 |
checkDate = checkDate.add(1, "day"); |
| 866 |
} |
| 867 |
|
| 868 |
return daysAvailable; |
| 614 |
} |
869 |
} |
| 615 |
|
870 |
|
| 616 |
// Setup listener for itemtype select2 |
871 |
// Setup listener for itemtype select2 |
|
Lines 649-657
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 649 |
|
904 |
|
| 650 |
// Setup listener for item select2 |
905 |
// Setup listener for item select2 |
| 651 |
$("#booking_item_id").on("select2:select", function (e) { |
906 |
$("#booking_item_id").on("select2:select", function (e) { |
| 652 |
booking_item_id = e.params.data.id |
907 |
booking_item_id = |
| 653 |
? e.params.data.id |
908 |
e.params.data.id !== undefined && |
| 654 |
: null; |
909 |
e.params.data.id !== null |
|
|
910 |
? parseInt(e.params.data.id, 10) |
| 911 |
: 0; |
| 655 |
|
912 |
|
| 656 |
// Disable invalid pickup locations |
913 |
// Disable invalid pickup locations |
| 657 |
$("#pickup_library_id > option").each(function () { |
914 |
$("#pickup_library_id > option").each(function () { |
|
Lines 665-671
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 665 |
.split(",") |
922 |
.split(",") |
| 666 |
.map(Number); |
923 |
.map(Number); |
| 667 |
if ( |
924 |
if ( |
| 668 |
valid_items.includes(parseInt(booking_item_id)) |
925 |
valid_items.includes( |
|
|
926 |
parseInt(booking_item_id, 10) |
| 927 |
) |
| 669 |
) { |
928 |
) { |
| 670 |
option.prop("disabled", false); |
929 |
option.prop("disabled", false); |
| 671 |
} else { |
930 |
} else { |
|
Lines 753-759
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 753 |
for (let i = 0; i < renewalsAllowed; i++) { |
1012 |
for (let i = 0; i < renewalsAllowed; i++) { |
| 754 |
nextDate.setDate( |
1013 |
nextDate.setDate( |
| 755 |
nextDate.getDate() + |
1014 |
nextDate.getDate() + |
| 756 |
parseInt(renewalLength) |
1015 |
parseInt(renewalLength) |
| 757 |
); |
1016 |
); |
| 758 |
boldDates.push(new Date(nextDate)); |
1017 |
boldDates.push(new Date(nextDate)); |
| 759 |
} |
1018 |
} |
|
Lines 864-879
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 864 |
let bookingsByDate = {}; |
1123 |
let bookingsByDate = {}; |
| 865 |
// Iterate through the bookings array |
1124 |
// Iterate through the bookings array |
| 866 |
bookings.forEach(booking => { |
1125 |
bookings.forEach(booking => { |
| 867 |
const start_date = flatpickr.parseDate(booking.start_date); |
1126 |
const start_date = dayjs(booking.start_date); |
| 868 |
const end_date = flatpickr.parseDate(booking.end_date); |
1127 |
const end_date = dayjs(booking.end_date); |
| 869 |
const item_id = booking.item_id; |
1128 |
const item_id = booking.item_id; |
| 870 |
|
1129 |
|
| 871 |
// Iterate through each date within the range of start_date and end_date |
1130 |
// Iterate through each date within the range of start_date and end_date |
| 872 |
let currentDate = new Date(start_date); |
1131 |
let currentDate = dayjs(start_date); |
| 873 |
while (currentDate <= end_date) { |
1132 |
while (currentDate.isSameOrBefore(end_date, "day")) { |
| 874 |
const currentDateStr = currentDate |
1133 |
const currentDateStr = currentDate.format("YYYY-MM-DD"); |
| 875 |
.toISOString() |
|
|
| 876 |
.split("T")[0]; |
| 877 |
|
1134 |
|
| 878 |
// If the date key doesn't exist in the hash, create an empty array for it |
1135 |
// If the date key doesn't exist in the hash, create an empty array for it |
| 879 |
if (!bookingsByDate[currentDateStr]) { |
1136 |
if (!bookingsByDate[currentDateStr]) { |
|
Lines 884-890
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 884 |
bookingsByDate[currentDateStr].push(item_id); |
1141 |
bookingsByDate[currentDateStr].push(item_id); |
| 885 |
|
1142 |
|
| 886 |
// Move to the next day |
1143 |
// Move to the next day |
| 887 |
currentDate.setDate(currentDate.getDate() + 1); |
1144 |
currentDate = currentDate.add(1, "day"); |
| 888 |
} |
1145 |
} |
| 889 |
}); |
1146 |
}); |
| 890 |
|
1147 |
|
|
Lines 933-940
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 933 |
); |
1190 |
); |
| 934 |
const startDate = periodPicker.selectedDates[0] |
1191 |
const startDate = periodPicker.selectedDates[0] |
| 935 |
? dayjs(periodPicker.selectedDates[0]).startOf( |
1192 |
? dayjs(periodPicker.selectedDates[0]).startOf( |
| 936 |
"day" |
1193 |
"day" |
| 937 |
) |
1194 |
) |
| 938 |
: null; |
1195 |
: null; |
| 939 |
|
1196 |
|
| 940 |
const leadStart = startDate |
1197 |
const leadStart = startDate |
|
Lines 960-966
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 960 |
dayElem.classList.toggle( |
1217 |
dayElem.classList.toggle( |
| 961 |
"leadRange", |
1218 |
"leadRange", |
| 962 |
elemDate.isSameOrAfter(leadStart) && |
1219 |
elemDate.isSameOrAfter(leadStart) && |
| 963 |
elemDate.isBefore(leadEnd) |
1220 |
elemDate.isBefore(leadEnd) |
| 964 |
); |
1221 |
); |
| 965 |
dayElem.classList.toggle( |
1222 |
dayElem.classList.toggle( |
| 966 |
"leadRangeEnd", |
1223 |
"leadRangeEnd", |
|
Lines 973-979
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 973 |
dayElem.classList.toggle( |
1230 |
dayElem.classList.toggle( |
| 974 |
"trailRange", |
1231 |
"trailRange", |
| 975 |
elemDate.isAfter(trailStart) && |
1232 |
elemDate.isAfter(trailStart) && |
| 976 |
elemDate.isSameOrBefore(trailEnd) |
1233 |
elemDate.isSameOrBefore(trailEnd) |
| 977 |
); |
1234 |
); |
| 978 |
dayElem.classList.toggle( |
1235 |
dayElem.classList.toggle( |
| 979 |
"trailRangeEnd", |
1236 |
"trailRangeEnd", |
|
Lines 1116-1122
function setFormValues(
Link Here
|
| 1116 |
// If passed an itemnumber, pre-select |
1373 |
// If passed an itemnumber, pre-select |
| 1117 |
if (booking_item_id) { |
1374 |
if (booking_item_id) { |
| 1118 |
// Wait a bit for the item options to be fully created with data attributes |
1375 |
// Wait a bit for the item options to be fully created with data attributes |
| 1119 |
setTimeout(function() { |
1376 |
setTimeout(function () { |
| 1120 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
1377 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
| 1121 |
// Also trigger the select2:select event with proper data |
1378 |
// Also trigger the select2:select event with proper data |
| 1122 |
let selectedOption = $("#booking_item_id option:selected")[0]; |
1379 |
let selectedOption = $("#booking_item_id option:selected")[0]; |
|
Lines 1126-1134
function setFormValues(
Link Here
|
| 1126 |
params: { |
1383 |
params: { |
| 1127 |
data: { |
1384 |
data: { |
| 1128 |
id: booking_item_id, |
1385 |
id: booking_item_id, |
| 1129 |
element: selectedOption |
1386 |
element: selectedOption, |
| 1130 |
} |
1387 |
}, |
| 1131 |
} |
1388 |
}, |
| 1132 |
}); |
1389 |
}); |
| 1133 |
} |
1390 |
} |
| 1134 |
}, 100); |
1391 |
}, 100); |
|
Lines 1162-1167
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1162 |
let biblio_id = $("#booking_biblio_id").val(); |
1419 |
let biblio_id = $("#booking_biblio_id").val(); |
| 1163 |
let item_id = $("#booking_item_id").val(); |
1420 |
let item_id = $("#booking_item_id").val(); |
| 1164 |
|
1421 |
|
|
|
1422 |
// If "any item" is selected, find the optimal item |
| 1423 |
if (item_id == 0) { |
| 1424 |
item_id = selectOptimalItem( |
| 1425 |
booking_itemtype_id, |
| 1426 |
new Date(start_date), |
| 1427 |
new Date(end_date) |
| 1428 |
); |
| 1429 |
|
| 1430 |
if (!item_id) { |
| 1431 |
$("#booking_result").replaceWith( |
| 1432 |
'<div id="booking_result" class="alert alert-danger">' + |
| 1433 |
__("No suitable item found for booking") + |
| 1434 |
"</div>" |
| 1435 |
); |
| 1436 |
return; |
| 1437 |
} |
| 1438 |
} |
| 1439 |
|
| 1165 |
if (!booking_id) { |
1440 |
if (!booking_id) { |
| 1166 |
let posting = $.post( |
1441 |
let posting = $.post( |
| 1167 |
url, |
1442 |
url, |
|
Lines 1170-1176
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1170 |
end_date: end_date, |
1445 |
end_date: end_date, |
| 1171 |
pickup_library_id: pickup_library_id, |
1446 |
pickup_library_id: pickup_library_id, |
| 1172 |
biblio_id: biblio_id, |
1447 |
biblio_id: biblio_id, |
| 1173 |
item_id: item_id != 0 ? item_id : null, |
1448 |
item_id: item_id, |
| 1174 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
1449 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 1175 |
}) |
1450 |
}) |
| 1176 |
); |
1451 |
); |
|
Lines 1212-1219
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1212 |
// Set feedback |
1487 |
// Set feedback |
| 1213 |
$("#transient_result").replaceWith( |
1488 |
$("#transient_result").replaceWith( |
| 1214 |
'<div id="transient_result" class="alert alert-info">' + |
1489 |
'<div id="transient_result" class="alert alert-info">' + |
| 1215 |
__("Booking successfully placed") + |
1490 |
__("Booking successfully placed") + |
| 1216 |
"</div>" |
1491 |
"</div>" |
| 1217 |
); |
1492 |
); |
| 1218 |
|
1493 |
|
| 1219 |
// Close modal |
1494 |
// Close modal |
|
Lines 1223-1233
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1223 |
posting.fail(function (data) { |
1498 |
posting.fail(function (data) { |
| 1224 |
$("#booking_result").replaceWith( |
1499 |
$("#booking_result").replaceWith( |
| 1225 |
'<div id="booking_result" class="alert alert-danger">' + |
1500 |
'<div id="booking_result" class="alert alert-danger">' + |
| 1226 |
__("Failure") + |
1501 |
__("Failure") + |
| 1227 |
"</div>" |
1502 |
"</div>" |
| 1228 |
); |
1503 |
); |
| 1229 |
}); |
1504 |
}); |
| 1230 |
} else { |
1505 |
} else { |
|
|
1506 |
// For edits, also handle optimal item selection |
| 1507 |
if (item_id == 0) { |
| 1508 |
item_id = selectOptimalItem( |
| 1509 |
booking_itemtype_id, |
| 1510 |
new Date(start_date), |
| 1511 |
new Date(end_date) |
| 1512 |
); |
| 1513 |
|
| 1514 |
if (!item_id) { |
| 1515 |
$("#booking_result").replaceWith( |
| 1516 |
'<div id="booking_result" class="alert alert-danger">' + |
| 1517 |
__("No suitable item found for booking") + |
| 1518 |
"</div>" |
| 1519 |
); |
| 1520 |
return; |
| 1521 |
} |
| 1522 |
} |
| 1523 |
|
| 1231 |
url += "/" + booking_id; |
1524 |
url += "/" + booking_id; |
| 1232 |
let putting = $.ajax({ |
1525 |
let putting = $.ajax({ |
| 1233 |
method: "PUT", |
1526 |
method: "PUT", |
|
Lines 1239-1245
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1239 |
end_date: end_date, |
1532 |
end_date: end_date, |
| 1240 |
pickup_library_id: pickup_library_id, |
1533 |
pickup_library_id: pickup_library_id, |
| 1241 |
biblio_id: biblio_id, |
1534 |
biblio_id: biblio_id, |
| 1242 |
item_id: item_id != 0 ? item_id : null, |
1535 |
item_id: item_id, |
| 1243 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
1536 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 1244 |
}), |
1537 |
}), |
| 1245 |
}); |
1538 |
}); |
|
Lines 1281-1288
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1281 |
// Set feedback |
1574 |
// Set feedback |
| 1282 |
$("#transient_result").replaceWith( |
1575 |
$("#transient_result").replaceWith( |
| 1283 |
'<div id="transient_result" class="alert alert-info">' + |
1576 |
'<div id="transient_result" class="alert alert-info">' + |
| 1284 |
__("Booking successfully updated") + |
1577 |
__("Booking successfully updated") + |
| 1285 |
"</div>" |
1578 |
"</div>" |
| 1286 |
); |
1579 |
); |
| 1287 |
|
1580 |
|
| 1288 |
// Close modal |
1581 |
// Close modal |
|
Lines 1292-1299
$("#placeBookingForm").on("submit", function (e) {
Link Here
|
| 1292 |
putting.fail(function (data) { |
1585 |
putting.fail(function (data) { |
| 1293 |
$("#booking_result").replaceWith( |
1586 |
$("#booking_result").replaceWith( |
| 1294 |
'<div id="booking_result" class="alert alert-danger">' + |
1587 |
'<div id="booking_result" class="alert alert-danger">' + |
| 1295 |
__("Failure") + |
1588 |
__("Failure") + |
| 1296 |
"</div>" |
1589 |
"</div>" |
| 1297 |
); |
1590 |
); |
| 1298 |
}); |
1591 |
}); |
| 1299 |
} |
1592 |
} |
|
Lines 1307-1313
$("#placeBookingModal").on("hidden.bs.modal", function (e) {
Link Here
|
| 1307 |
booking_patron = undefined; |
1600 |
booking_patron = undefined; |
| 1308 |
|
1601 |
|
| 1309 |
// Reset item select |
1602 |
// Reset item select |
| 1310 |
$("#booking_item_id").val(0).trigger("change"); |
1603 |
$("#booking_item_id").val(parseInt(0)).trigger("change"); |
| 1311 |
$("#booking_item_id").prop("disabled", true); |
1604 |
$("#booking_item_id").prop("disabled", true); |
| 1312 |
|
1605 |
|
| 1313 |
// Reset itemtype select |
1606 |
// Reset itemtype select |
| 1314 |
- |
|
|