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 |
- |
|
|