|
Lines 7-57
let bookable_items,
Link Here
|
| 7 |
booking_patron, |
7 |
booking_patron, |
| 8 |
booking_itemtype_id; |
8 |
booking_itemtype_id; |
| 9 |
|
9 |
|
| 10 |
function containsAny(integers1, integers2) { |
10 |
// ============================================================================ |
| 11 |
// Create a hash set to store integers from the second array |
11 |
// UTILITY FUNCTIONS |
| 12 |
let integerSet = {}; |
12 |
// ============================================================================ |
| 13 |
for (let i = 0; i < integers2.length; i++) { |
13 |
|
| 14 |
integerSet[integers2[i]] = true; |
14 |
/** |
| 15 |
} |
15 |
* Check if two arrays share any common elements |
|
|
16 |
* @param {Array} arr1 - First array of values |
| 17 |
* @param {Array} arr2 - Second array of values |
| 18 |
* @returns {boolean} - True if any element exists in both arrays |
| 19 |
*/ |
| 20 |
function containsAny(arr1, arr2) { |
| 21 |
const set = new Set(arr2); |
| 22 |
return arr1.some(item => set.has(item)); |
| 23 |
} |
| 16 |
|
24 |
|
| 17 |
// Check if any integer from the first array exists in the hash set |
25 |
/** |
| 18 |
for (let i = 0; i < integers1.length; i++) { |
26 |
* Parse a value to integer, with fallback to 0 |
| 19 |
if (integerSet[integers1[i]]) { |
27 |
* @param {*} value - Value to parse |
| 20 |
return true; // Found a match, return true |
28 |
* @returns {number} - Parsed integer or 0 |
| 21 |
} |
29 |
*/ |
| 22 |
} |
30 |
function toInt(value) { |
|
|
31 |
const parsed = parseInt(value, 10); |
| 32 |
return isNaN(parsed) ? 0 : parsed; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Normalize a date to start of day using dayjs |
| 37 |
* @param {Date|string|dayjs} date - Date to normalize |
| 38 |
* @returns {dayjs} - dayjs object at start of day |
| 39 |
*/ |
| 40 |
function startOfDay(date) { |
| 41 |
return dayjs(date).startOf("day"); |
| 42 |
} |
| 23 |
|
43 |
|
| 24 |
return false; // No match found |
44 |
/** |
|
|
45 |
* Check if two date ranges overlap |
| 46 |
* @param {Date|dayjs} start1 - Start of first range |
| 47 |
* @param {Date|dayjs} end1 - End of first range |
| 48 |
* @param {Date|dayjs} start2 - Start of second range |
| 49 |
* @param {Date|dayjs} end2 - End of second range |
| 50 |
* @returns {boolean} - True if ranges overlap |
| 51 |
*/ |
| 52 |
function datesOverlap(start1, end1, start2, end2) { |
| 53 |
const s1 = startOfDay(start1); |
| 54 |
const e1 = startOfDay(end1); |
| 55 |
const s2 = startOfDay(start2); |
| 56 |
const e2 = startOfDay(end2); |
| 57 |
// Ranges overlap if neither is completely before or after the other |
| 58 |
return !(e1.isBefore(s2, "day") || s1.isAfter(e2, "day")); |
| 25 |
} |
59 |
} |
| 26 |
|
60 |
|
| 27 |
// Check if a specific item is available for the entire booking period |
61 |
/** |
|
|
62 |
* Check if a date falls within a date range (inclusive) |
| 63 |
* @param {Date|dayjs} date - Date to check |
| 64 |
* @param {Date|dayjs} start - Start of range |
| 65 |
* @param {Date|dayjs} end - End of range |
| 66 |
* @returns {boolean} - True if date is within range |
| 67 |
*/ |
| 68 |
function isDateInRange(date, start, end) { |
| 69 |
const d = startOfDay(date); |
| 70 |
const s = startOfDay(start); |
| 71 |
const e = startOfDay(end); |
| 72 |
return d.isSameOrAfter(s, "day") && d.isSameOrBefore(e, "day"); |
| 73 |
} |
| 74 |
|
| 75 |
/** |
| 76 |
* Check if a specific item is available for the entire booking period |
| 77 |
* @param {number|string} itemId - Item ID to check |
| 78 |
* @param {Date} startDate - Start of booking period |
| 79 |
* @param {Date} endDate - End of booking period |
| 80 |
* @returns {boolean} - True if item is available for the entire period |
| 81 |
*/ |
| 28 |
function isItemAvailableForPeriod(itemId, startDate, endDate) { |
82 |
function isItemAvailableForPeriod(itemId, startDate, endDate) { |
| 29 |
for (let booking of bookings) { |
83 |
const checkItemId = toInt(itemId); |
|
|
84 |
for (const booking of bookings) { |
| 30 |
// Skip if we're editing this booking |
85 |
// Skip if we're editing this booking |
| 31 |
if (booking_id && booking_id == booking.booking_id) { |
86 |
if (booking_id && booking_id == booking.booking_id) { |
| 32 |
continue; |
87 |
continue; |
| 33 |
} |
88 |
} |
| 34 |
|
89 |
// Skip different items |
| 35 |
if (booking.item_id !== itemId) { |
90 |
if (toInt(booking.item_id) !== checkItemId) { |
| 36 |
continue; // Different item, no conflict |
91 |
continue; |
| 37 |
} |
92 |
} |
| 38 |
|
93 |
// Check for overlap |
| 39 |
let booking_start = dayjs(booking.start_date); |
|
|
| 40 |
let booking_end = dayjs(booking.end_date); |
| 41 |
let checkStartDate = dayjs(startDate); |
| 42 |
let checkEndDate = dayjs(endDate); |
| 43 |
|
| 44 |
// Check for any overlap with our booking period |
| 45 |
if ( |
94 |
if ( |
| 46 |
!( |
95 |
datesOverlap( |
| 47 |
checkEndDate.isBefore(booking_start, "day") || |
96 |
startDate, |
| 48 |
checkStartDate.isAfter(booking_end, "day") |
97 |
endDate, |
|
|
98 |
booking.start_date, |
| 99 |
booking.end_date |
| 49 |
) |
100 |
) |
| 50 |
) { |
101 |
) { |
| 51 |
return false; // Overlap detected |
102 |
return false; |
| 52 |
} |
103 |
} |
| 53 |
} |
104 |
} |
| 54 |
return true; // No conflicts found |
105 |
return true; |
| 55 |
} |
106 |
} |
| 56 |
|
107 |
|
| 57 |
$("#placeBookingModal").on("show.bs.modal", function (e) { |
108 |
$("#placeBookingModal").on("show.bs.modal", function (e) { |
|
Lines 635-641
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 635 |
itemsOfType |
686 |
itemsOfType |
| 636 |
); |
687 |
); |
| 637 |
let availableItems = new Set( |
688 |
let availableItems = new Set( |
| 638 |
availableOnStart.map(item => parseInt(item.item_id, 10)) |
689 |
availableOnStart.map(item => toInt(item.item_id)) |
| 639 |
); |
690 |
); |
| 640 |
|
691 |
|
| 641 |
let currentDate = dayjs(startDate); |
692 |
let currentDate = dayjs(startDate); |
|
Lines 647-655
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 647 |
itemsOfType |
698 |
itemsOfType |
| 648 |
); |
699 |
); |
| 649 |
let availableIds = new Set( |
700 |
let availableIds = new Set( |
| 650 |
availableToday.map(item => |
701 |
availableToday.map(item => toInt(item.item_id)) |
| 651 |
parseInt(item.item_id, 10) |
|
|
| 652 |
) |
| 653 |
); |
702 |
); |
| 654 |
|
703 |
|
| 655 |
// Remove items from our pool that are no longer available (never add back) |
704 |
// Remove items from our pool that are no longer available (never add back) |
|
Lines 678-739
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 678 |
|
727 |
|
| 679 |
// Get items of itemtype that are available on a specific date |
728 |
// Get items of itemtype that are available on a specific date |
| 680 |
function getAvailableItemsOnDate(date, itemsOfType) { |
729 |
function getAvailableItemsOnDate(date, itemsOfType) { |
| 681 |
let unavailableItems = new Set(); |
730 |
const unavailableItems = new Set(); |
| 682 |
|
731 |
|
| 683 |
// Check all existing bookings for conflicts on this date |
732 |
for (const booking of bookings) { |
| 684 |
for (let booking of bookings) { |
|
|
| 685 |
// Skip if we're editing this booking |
733 |
// Skip if we're editing this booking |
| 686 |
if (booking_id && booking_id == booking.booking_id) { |
734 |
if (booking_id && booking_id == booking.booking_id) { |
| 687 |
continue; |
735 |
continue; |
| 688 |
} |
736 |
} |
| 689 |
|
|
|
| 690 |
let start_date = dayjs(booking.start_date); |
| 691 |
let end_date = dayjs(booking.end_date); |
| 692 |
let checkDate = dayjs(date); |
| 693 |
|
| 694 |
// Check if this date falls within this booking period |
737 |
// Check if this date falls within this booking period |
| 695 |
if ( |
738 |
if ( |
| 696 |
checkDate.isSameOrAfter(start_date, "day") && |
739 |
isDateInRange( |
| 697 |
checkDate.isSameOrBefore(end_date, "day") |
740 |
date, |
|
|
741 |
booking.start_date, |
| 742 |
booking.end_date |
| 743 |
) |
| 698 |
) { |
744 |
) { |
| 699 |
// All bookings have item_id, so mark this specific item as unavailable |
745 |
unavailableItems.add(toInt(booking.item_id)); |
| 700 |
// Ensure integer comparison consistency |
|
|
| 701 |
unavailableItems.add(parseInt(booking.item_id, 10)); |
| 702 |
} |
746 |
} |
| 703 |
} |
747 |
} |
| 704 |
|
748 |
|
| 705 |
// Return items of our type that are not unavailable |
749 |
return itemsOfType.filter( |
| 706 |
let available = itemsOfType.filter( |
750 |
item => !unavailableItems.has(toInt(item.item_id)) |
| 707 |
item => |
|
|
| 708 |
!unavailableItems.has(parseInt(item.item_id, 10)) |
| 709 |
); |
751 |
); |
| 710 |
return available; |
|
|
| 711 |
} |
752 |
} |
| 712 |
|
753 |
|
| 713 |
// Item-specific availability logic for specific item bookings |
754 |
// Item-specific availability logic for specific item bookings |
| 714 |
function isDateDisabledForSpecificItem(date, selectedDates) { |
755 |
function isDateDisabledForSpecificItem(date, selectedDates) { |
| 715 |
for (let booking of bookings) { |
756 |
const selectedItemId = toInt(booking_item_id); |
|
|
757 |
for (const booking of bookings) { |
| 716 |
// Skip if we're editing this booking |
758 |
// Skip if we're editing this booking |
| 717 |
if (booking_id && booking_id == booking.booking_id) { |
759 |
if (booking_id && booking_id == booking.booking_id) { |
| 718 |
continue; |
760 |
continue; |
| 719 |
} |
761 |
} |
| 720 |
|
762 |
// Check if date is within booking period and same item |
| 721 |
let start_date = dayjs(booking.start_date); |
|
|
| 722 |
let end_date = dayjs(booking.end_date); |
| 723 |
let checkDate = dayjs(date); |
| 724 |
|
| 725 |
// Check if this booking conflicts with our selected item and date |
| 726 |
if ( |
763 |
if ( |
| 727 |
checkDate.isSameOrAfter(start_date, "day") && |
764 |
isDateInRange( |
| 728 |
checkDate.isSameOrBefore(end_date, "day") |
765 |
date, |
|
|
766 |
booking.start_date, |
| 767 |
booking.end_date |
| 768 |
) && |
| 769 |
toInt(booking.item_id) === selectedItemId |
| 729 |
) { |
770 |
) { |
| 730 |
// Same item, disable date (ensure integer comparison) |
771 |
return true; |
| 731 |
if ( |
|
|
| 732 |
parseInt(booking.item_id, 10) === |
| 733 |
parseInt(booking_item_id, 10) |
| 734 |
) { |
| 735 |
return true; |
| 736 |
} |
| 737 |
} |
772 |
} |
| 738 |
} |
773 |
} |
| 739 |
return false; |
774 |
return false; |
|
Lines 778-806
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 778 |
booking_item_id = |
813 |
booking_item_id = |
| 779 |
e.params.data.id !== undefined && |
814 |
e.params.data.id !== undefined && |
| 780 |
e.params.data.id !== null |
815 |
e.params.data.id !== null |
| 781 |
? parseInt(e.params.data.id, 10) |
816 |
? toInt(e.params.data.id) |
| 782 |
: 0; |
817 |
: 0; |
| 783 |
|
818 |
|
| 784 |
// Disable invalid pickup locations |
819 |
// Disable invalid pickup locations |
| 785 |
$("#pickup_library_id > option").each(function () { |
820 |
$("#pickup_library_id > option").each(function () { |
| 786 |
let option = $(this); |
821 |
const option = $(this); |
| 787 |
if (booking_item_id == 0) { |
822 |
if (booking_item_id == 0) { |
| 788 |
option.prop("disabled", false); |
823 |
option.prop("disabled", false); |
| 789 |
} else { |
824 |
} else { |
| 790 |
let valid_items = String( |
825 |
const valid_items = String( |
| 791 |
option.data("pickup_items") |
826 |
option.data("pickup_items") |
| 792 |
) |
827 |
) |
| 793 |
.split(",") |
828 |
.split(",") |
| 794 |
.map(Number); |
829 |
.map(Number); |
| 795 |
if ( |
830 |
option.prop( |
| 796 |
valid_items.includes( |
831 |
"disabled", |
| 797 |
parseInt(booking_item_id, 10) |
832 |
!valid_items.includes(toInt(booking_item_id)) |
| 798 |
) |
833 |
); |
| 799 |
) { |
|
|
| 800 |
option.prop("disabled", false); |
| 801 |
} else { |
| 802 |
option.prop("disabled", true); |
| 803 |
} |
| 804 |
} |
834 |
} |
| 805 |
}); |
835 |
}); |
| 806 |
$("#pickup_library_id").trigger("change.select2"); |
836 |
$("#pickup_library_id").trigger("change.select2"); |
|
Lines 1025-1032
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1025 |
|
1055 |
|
| 1026 |
// Iterate through each date within the range of start_date and end_date |
1056 |
// Iterate through each date within the range of start_date and end_date |
| 1027 |
// Use dayjs to maintain browser timezone consistency |
1057 |
// Use dayjs to maintain browser timezone consistency |
| 1028 |
let currentDate = dayjs(start_date).startOf("day"); |
1058 |
let currentDate = startOfDay(start_date); |
| 1029 |
const endDate = dayjs(end_date).startOf("day"); |
1059 |
const endDate = startOfDay(end_date); |
| 1030 |
while (currentDate.isSameOrBefore(endDate, "day")) { |
1060 |
while (currentDate.isSameOrBefore(endDate, "day")) { |
| 1031 |
// Format in browser timezone - no UTC conversion |
1061 |
// Format in browser timezone - no UTC conversion |
| 1032 |
const currentDateStr = currentDate.format("YYYY-MM-DD"); |
1062 |
const currentDateStr = currentDate.format("YYYY-MM-DD"); |
|
Lines 1162-1184
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1162 |
const itemConflicts = new Map(); |
1192 |
const itemConflicts = new Map(); |
| 1163 |
if (isAnyItemMode) { |
1193 |
if (isAnyItemMode) { |
| 1164 |
itemsOfSelectedType.forEach(item => { |
1194 |
itemsOfSelectedType.forEach(item => { |
| 1165 |
itemConflicts.set( |
1195 |
itemConflicts.set(toInt(item.item_id), { |
| 1166 |
parseInt(item.item_id, 10), |
1196 |
leadConflict: false, |
| 1167 |
{ |
1197 |
trailConflict: false, |
| 1168 |
leadConflict: false, |
1198 |
leadReason: { |
| 1169 |
trailConflict: false, |
1199 |
withTrail: false, |
| 1170 |
leadReason: { |
1200 |
withLead: false, |
| 1171 |
withTrail: false, |
1201 |
withBooking: false, |
| 1172 |
withLead: false, |
1202 |
}, |
| 1173 |
withBooking: false, |
1203 |
trailReason: { |
| 1174 |
}, |
1204 |
withTrail: false, |
| 1175 |
trailReason: { |
1205 |
withLead: false, |
| 1176 |
withTrail: false, |
1206 |
withBooking: false, |
| 1177 |
withLead: false, |
1207 |
}, |
| 1178 |
withBooking: false, |
1208 |
}); |
| 1179 |
}, |
|
|
| 1180 |
} |
| 1181 |
); |
| 1182 |
}); |
1209 |
}); |
| 1183 |
} |
1210 |
} |
| 1184 |
|
1211 |
|
|
Lines 1191-1208
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1191 |
return; |
1218 |
return; |
| 1192 |
} |
1219 |
} |
| 1193 |
|
1220 |
|
| 1194 |
const bookingItemId = parseInt( |
1221 |
const bookingItemId = toInt(booking.item_id); |
| 1195 |
booking.item_id, |
|
|
| 1196 |
10 |
| 1197 |
); |
| 1198 |
|
1222 |
|
| 1199 |
// For specific item mode: skip bookings for different items |
1223 |
// For specific item mode: skip bookings for different items |
| 1200 |
if (!isAnyItemMode) { |
1224 |
if (!isAnyItemMode) { |
| 1201 |
if ( |
1225 |
if ( |
| 1202 |
booking.item_id && |
1226 |
booking.item_id && |
| 1203 |
booking_item_id && |
1227 |
booking_item_id && |
| 1204 |
bookingItemId !== |
1228 |
bookingItemId !== toInt(booking_item_id) |
| 1205 |
parseInt(booking_item_id, 10) |
|
|
| 1206 |
) { |
1229 |
) { |
| 1207 |
return; |
1230 |
return; |
| 1208 |
} |
1231 |
} |
|
Lines 1213-1224
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1213 |
} |
1236 |
} |
| 1214 |
} |
1237 |
} |
| 1215 |
|
1238 |
|
| 1216 |
const bookingStart = dayjs( |
1239 |
const bookingStart = startOfDay( |
| 1217 |
booking.start_date |
1240 |
booking.start_date |
| 1218 |
).startOf("day"); |
1241 |
); |
| 1219 |
const bookingEnd = dayjs( |
1242 |
const bookingEnd = startOfDay(booking.end_date); |
| 1220 |
booking.end_date |
|
|
| 1221 |
).startOf("day"); |
| 1222 |
|
1243 |
|
| 1223 |
// BIDIRECTIONAL: Mathematical checks for conflicts (works across month boundaries) |
1244 |
// BIDIRECTIONAL: Mathematical checks for conflicts (works across month boundaries) |
| 1224 |
// Calculate this booking's full protected period |
1245 |
// Calculate this booking's full protected period |
|
Lines 2106-2111
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 2106 |
} |
2127 |
} |
| 2107 |
}); |
2128 |
}); |
| 2108 |
|
2129 |
|
|
|
2130 |
/** |
| 2131 |
* Set date range on the period picker |
| 2132 |
* @param {Object} periodPicker - Flatpickr instance |
| 2133 |
* @param {string} start_date - Start date string |
| 2134 |
* @param {string} end_date - End date string |
| 2135 |
*/ |
| 2136 |
function setPickerDates(periodPicker, start_date, end_date) { |
| 2137 |
if (start_date && end_date) { |
| 2138 |
periodPicker.setDate([new Date(start_date), new Date(end_date)], true); |
| 2139 |
} |
| 2140 |
} |
| 2141 |
|
| 2109 |
function setFormValues( |
2142 |
function setFormValues( |
| 2110 |
patron_id, |
2143 |
patron_id, |
| 2111 |
booking_item_id, |
2144 |
booking_item_id, |
|
Lines 2118-2150
function setFormValues(
Link Here
|
| 2118 |
if (item_type_id) { |
2151 |
if (item_type_id) { |
| 2119 |
booking_itemtype_id = item_type_id; |
2152 |
booking_itemtype_id = item_type_id; |
| 2120 |
} |
2153 |
} |
|
|
2154 |
|
| 2121 |
// If passed patron, pre-select |
2155 |
// If passed patron, pre-select |
| 2122 |
if (patron_id) { |
2156 |
if (patron_id) { |
| 2123 |
let patronSelect = $("#booking_patron_id"); |
2157 |
const patronSelect = $("#booking_patron_id"); |
| 2124 |
let patron = $.ajax({ |
2158 |
$.ajax({ |
| 2125 |
url: "/api/v1/patrons/" + patron_id, |
2159 |
url: "/api/v1/patrons/" + patron_id, |
| 2126 |
dataType: "json", |
2160 |
dataType: "json", |
| 2127 |
type: "GET", |
2161 |
type: "GET", |
| 2128 |
}); |
2162 |
}).done(function (patron) { |
| 2129 |
|
|
|
| 2130 |
$.when(patron).done(function (patron) { |
| 2131 |
// clone patron_id to id (select2 expects an id field) |
| 2132 |
patron.id = patron.patron_id; |
2163 |
patron.id = patron.patron_id; |
| 2133 |
patron.text = |
2164 |
patron.text = |
| 2134 |
escape_str(patron.surname) + |
2165 |
escape_str(patron.surname) + |
| 2135 |
", " + |
2166 |
", " + |
| 2136 |
escape_str(patron.firstname); |
2167 |
escape_str(patron.firstname); |
| 2137 |
|
2168 |
|
| 2138 |
// Add and select new option |
2169 |
const newOption = new Option(patron.text, patron.id, true, true); |
| 2139 |
let newOption = new Option(patron.text, patron.id, true, true); |
|
|
| 2140 |
patronSelect.append(newOption).trigger("change"); |
2170 |
patronSelect.append(newOption).trigger("change"); |
| 2141 |
|
|
|
| 2142 |
// manually trigger the `select2:select` event |
| 2143 |
patronSelect.trigger({ |
2171 |
patronSelect.trigger({ |
| 2144 |
type: "select2:select", |
2172 |
type: "select2:select", |
| 2145 |
params: { |
2173 |
params: { data: patron }, |
| 2146 |
data: patron, |
|
|
| 2147 |
}, |
| 2148 |
}); |
2174 |
}); |
| 2149 |
}); |
2175 |
}); |
| 2150 |
} |
2176 |
} |
|
Lines 2154-2443
function setFormValues(
Link Here
|
| 2154 |
// Wait a bit for the item options to be fully created with data attributes |
2180 |
// Wait a bit for the item options to be fully created with data attributes |
| 2155 |
setTimeout(function () { |
2181 |
setTimeout(function () { |
| 2156 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
2182 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
| 2157 |
// Also trigger the select2:select event with proper data |
2183 |
const selectedOption = $("#booking_item_id option:selected")[0]; |
| 2158 |
let selectedOption = $("#booking_item_id option:selected")[0]; |
|
|
| 2159 |
if (selectedOption) { |
2184 |
if (selectedOption) { |
| 2160 |
$("#booking_item_id").trigger({ |
2185 |
$("#booking_item_id").trigger({ |
| 2161 |
type: "select2:select", |
2186 |
type: "select2:select", |
| 2162 |
params: { |
2187 |
params: { |
| 2163 |
data: { |
2188 |
data: { id: booking_item_id, element: selectedOption }, |
| 2164 |
id: booking_item_id, |
|
|
| 2165 |
element: selectedOption, |
| 2166 |
}, |
| 2167 |
}, |
2189 |
}, |
| 2168 |
}); |
2190 |
}); |
| 2169 |
} |
2191 |
} |
| 2170 |
|
2192 |
// Set dates AFTER item selection to ensure booking_itemtype_id is set |
| 2171 |
// IMPORTANT: Set dates AFTER item selection completes |
2193 |
setPickerDates(periodPicker, start_date, end_date); |
| 2172 |
// This ensures booking_itemtype_id is set before dates are validated |
|
|
| 2173 |
if (start_date) { |
| 2174 |
// Allow invalid pre-load so setDate can set date range |
| 2175 |
// periodPicker.set('allowInvalidPreload', true); |
| 2176 |
// FIXME: Why is this the case.. we're passing two valid Date objects |
| 2177 |
let start = new Date(start_date); |
| 2178 |
let end = new Date(end_date); |
| 2179 |
|
| 2180 |
let dates = [new Date(start_date), new Date(end_date)]; |
| 2181 |
periodPicker.setDate(dates, true); |
| 2182 |
} |
| 2183 |
}, 100); |
2194 |
}, 100); |
|
|
2195 |
} else if (start_date) { |
| 2196 |
setPickerDates(periodPicker, start_date, end_date); |
| 2197 |
} else { |
| 2198 |
periodPicker.redraw(); |
| 2184 |
} |
2199 |
} |
| 2185 |
// If no item selected but dates provided, set them now |
2200 |
} |
| 2186 |
else if (start_date) { |
2201 |
|
| 2187 |
let start = new Date(start_date); |
2202 |
/** |
| 2188 |
let end = new Date(end_date); |
2203 |
* Get available items of a specific itemtype for a booking period |
|
|
2204 |
* @param {string} startDate - Start date string |
| 2205 |
* @param {string} endDate - End date string |
| 2206 |
* @returns {Array} - Array of available items |
| 2207 |
*/ |
| 2208 |
function getAvailableItemsForPeriod(startDate, endDate) { |
| 2209 |
const itemsOfType = bookable_items.filter( |
| 2210 |
item => item.effective_item_type_id === booking_itemtype_id |
| 2211 |
); |
| 2212 |
return itemsOfType.filter(item => |
| 2213 |
isItemAvailableForPeriod( |
| 2214 |
item.item_id, |
| 2215 |
new Date(startDate), |
| 2216 |
new Date(endDate) |
| 2217 |
) |
| 2218 |
); |
| 2219 |
} |
| 2189 |
|
2220 |
|
| 2190 |
let dates = [new Date(start_date), new Date(end_date)]; |
2221 |
/** |
| 2191 |
periodPicker.setDate(dates, true); |
2222 |
* Build the booking payload with item selection logic |
|
|
2223 |
* @param {Object} basePayload - Base payload with common fields |
| 2224 |
* @param {string} itemId - Selected item ID (0 for "any item") |
| 2225 |
* @param {string} startDate - Start date string |
| 2226 |
* @param {string} endDate - End date string |
| 2227 |
* @returns {Object|null} - Complete payload or null if no items available |
| 2228 |
*/ |
| 2229 |
function buildBookingPayload(basePayload, itemId, startDate, endDate) { |
| 2230 |
const payload = { ...basePayload }; |
| 2231 |
|
| 2232 |
if (itemId == 0) { |
| 2233 |
const availableItems = getAvailableItemsForPeriod(startDate, endDate); |
| 2234 |
if (availableItems.length === 0) { |
| 2235 |
return null; |
| 2236 |
} else if (availableItems.length === 1) { |
| 2237 |
payload.item_id = availableItems[0].item_id; |
| 2238 |
} else { |
| 2239 |
payload.itemtype_id = booking_itemtype_id; |
| 2240 |
} |
| 2241 |
} else { |
| 2242 |
payload.item_id = itemId; |
| 2192 |
} |
2243 |
} |
| 2193 |
// Reset periodPicker, biblio_id may have been nulled |
2244 |
|
| 2194 |
else { |
2245 |
return payload; |
| 2195 |
periodPicker.redraw(); |
2246 |
} |
|
|
2247 |
|
| 2248 |
/** |
| 2249 |
* Create timeline item data from booking response |
| 2250 |
* @param {Object} data - Booking response data |
| 2251 |
* @returns {Object} - Timeline item data |
| 2252 |
*/ |
| 2253 |
function createTimelineItem(data) { |
| 2254 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2255 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2256 |
return { |
| 2257 |
id: data.booking_id, |
| 2258 |
booking: data.booking_id, |
| 2259 |
patron: data.patron_id, |
| 2260 |
start: $toDisplayDate(startServerTz), |
| 2261 |
end: $toDisplayDate(endServerTz), |
| 2262 |
content: $patron_to_html(booking_patron, { |
| 2263 |
display_cardnumber: true, |
| 2264 |
url: false, |
| 2265 |
}), |
| 2266 |
editable: { remove: true, updateTime: true }, |
| 2267 |
type: "range", |
| 2268 |
group: data.item_id ? data.item_id : 0, |
| 2269 |
}; |
| 2270 |
} |
| 2271 |
|
| 2272 |
/** |
| 2273 |
* Show error message in booking result area |
| 2274 |
* @param {string} message - Error message to display |
| 2275 |
*/ |
| 2276 |
function showBookingError(message) { |
| 2277 |
$("#booking_result").replaceWith( |
| 2278 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2279 |
message + |
| 2280 |
"</div>" |
| 2281 |
); |
| 2282 |
} |
| 2283 |
|
| 2284 |
/** |
| 2285 |
* Show success feedback and close modal |
| 2286 |
* @param {string} message - Success message to display |
| 2287 |
*/ |
| 2288 |
function showBookingSuccess(message) { |
| 2289 |
$("#transient_result").replaceWith( |
| 2290 |
'<div id="transient_result" class="alert alert-info">' + |
| 2291 |
message + |
| 2292 |
"</div>" |
| 2293 |
); |
| 2294 |
$("#placeBookingModal").modal("hide"); |
| 2295 |
} |
| 2296 |
|
| 2297 |
/** |
| 2298 |
* Refresh bookings table if present |
| 2299 |
*/ |
| 2300 |
function refreshBookingsTable() { |
| 2301 |
if (typeof bookings_table !== "undefined" && bookings_table !== null) { |
| 2302 |
bookings_table.api().ajax.reload(); |
| 2196 |
} |
2303 |
} |
| 2197 |
} |
2304 |
} |
| 2198 |
|
2305 |
|
| 2199 |
$("#placeBookingForm").on("submit", function (e) { |
2306 |
$("#placeBookingForm").on("submit", function (e) { |
| 2200 |
e.preventDefault(); |
2307 |
e.preventDefault(); |
| 2201 |
|
2308 |
|
| 2202 |
let url = "/api/v1/bookings"; |
2309 |
const url = "/api/v1/bookings"; |
| 2203 |
|
2310 |
const start_date = $("#booking_start_date").val(); |
| 2204 |
let start_date = $("#booking_start_date").val(); |
2311 |
const end_date = $("#booking_end_date").val(); |
| 2205 |
let end_date = $("#booking_end_date").val(); |
2312 |
const item_id = $("#booking_item_id").val(); |
| 2206 |
let pickup_library_id = $("#pickup_library_id").val(); |
|
|
| 2207 |
let biblio_id = $("#booking_biblio_id").val(); |
| 2208 |
let item_id = $("#booking_item_id").val(); |
| 2209 |
|
2313 |
|
| 2210 |
// Prepare booking payload |
2314 |
const basePayload = { |
| 2211 |
let booking_payload = { |
|
|
| 2212 |
start_date: start_date, |
2315 |
start_date: start_date, |
| 2213 |
end_date: end_date, |
2316 |
end_date: end_date, |
| 2214 |
pickup_library_id: pickup_library_id, |
2317 |
pickup_library_id: $("#pickup_library_id").val(), |
| 2215 |
biblio_id: biblio_id, |
2318 |
biblio_id: $("#booking_biblio_id").val(), |
| 2216 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
2319 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 2217 |
}; |
2320 |
}; |
| 2218 |
|
2321 |
|
| 2219 |
// If "any item" is selected, determine whether to send item_id or itemtype_id |
2322 |
const payload = buildBookingPayload( |
| 2220 |
if (item_id == 0) { |
2323 |
basePayload, |
| 2221 |
// Get items of the selected itemtype that are available for the period |
2324 |
item_id, |
| 2222 |
let itemsOfType = bookable_items.filter( |
2325 |
start_date, |
| 2223 |
item => item.effective_item_type_id === booking_itemtype_id |
2326 |
end_date |
| 2224 |
); |
2327 |
); |
| 2225 |
|
2328 |
if (!payload) { |
| 2226 |
let availableItems = itemsOfType.filter(item => { |
2329 |
showBookingError(__("No suitable item found for booking")); |
| 2227 |
return isItemAvailableForPeriod( |
2330 |
return; |
| 2228 |
item.item_id, |
|
|
| 2229 |
new Date(start_date), |
| 2230 |
new Date(end_date) |
| 2231 |
); |
| 2232 |
}); |
| 2233 |
|
| 2234 |
if (availableItems.length === 0) { |
| 2235 |
$("#booking_result").replaceWith( |
| 2236 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2237 |
__("No suitable item found for booking") + |
| 2238 |
"</div>" |
| 2239 |
); |
| 2240 |
return; |
| 2241 |
} else if (availableItems.length === 1) { |
| 2242 |
// Only one item available - optimization: send specific item_id |
| 2243 |
booking_payload.item_id = availableItems[0].item_id; |
| 2244 |
} else { |
| 2245 |
// Multiple items available - let server choose optimal item |
| 2246 |
booking_payload.itemtype_id = booking_itemtype_id; |
| 2247 |
} |
| 2248 |
} else { |
| 2249 |
// Specific item selected |
| 2250 |
booking_payload.item_id = item_id; |
| 2251 |
} |
2331 |
} |
| 2252 |
|
2332 |
|
| 2253 |
if (!booking_id) { |
2333 |
if (!booking_id) { |
| 2254 |
let posting = $.post(url, JSON.stringify(booking_payload)); |
2334 |
// Create new booking |
| 2255 |
|
2335 |
$.post(url, JSON.stringify(payload)) |
| 2256 |
posting.done(function (data) { |
2336 |
.done(function (data) { |
| 2257 |
// Update bookings store for subsequent bookings |
2337 |
bookings.push(data); |
| 2258 |
bookings.push(data); |
2338 |
refreshBookingsTable(); |
| 2259 |
|
2339 |
|
| 2260 |
// Update bookings page as required |
2340 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2261 |
if ( |
2341 |
timeline.itemsData.add(createTimelineItem(data)); |
| 2262 |
typeof bookings_table !== "undefined" && |
2342 |
timeline.focus(data.booking_id); |
| 2263 |
bookings_table !== null |
2343 |
} |
| 2264 |
) { |
|
|
| 2265 |
bookings_table.api().ajax.reload(); |
| 2266 |
} |
| 2267 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2268 |
// Convert to library timezone for timeline display |
| 2269 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2270 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2271 |
timeline.itemsData.add({ |
| 2272 |
id: data.booking_id, |
| 2273 |
booking: data.booking_id, |
| 2274 |
patron: data.patron_id, |
| 2275 |
start: $toDisplayDate(startServerTz), |
| 2276 |
end: $toDisplayDate(endServerTz), |
| 2277 |
content: $patron_to_html(booking_patron, { |
| 2278 |
display_cardnumber: true, |
| 2279 |
url: false, |
| 2280 |
}), |
| 2281 |
editable: { remove: true, updateTime: true }, |
| 2282 |
type: "range", |
| 2283 |
group: data.item_id ? data.item_id : 0, |
| 2284 |
}); |
| 2285 |
timeline.focus(data.booking_id); |
| 2286 |
} |
| 2287 |
|
| 2288 |
// Update bookings counts |
| 2289 |
$(".bookings_count").html( |
| 2290 |
parseInt($(".bookings_count").html(), 10) + 1 |
| 2291 |
); |
| 2292 |
|
| 2293 |
// Set feedback |
| 2294 |
$("#transient_result").replaceWith( |
| 2295 |
'<div id="transient_result" class="alert alert-info">' + |
| 2296 |
__("Booking successfully placed") + |
| 2297 |
"</div>" |
| 2298 |
); |
| 2299 |
|
| 2300 |
// Close modal |
| 2301 |
$("#placeBookingModal").modal("hide"); |
| 2302 |
}); |
| 2303 |
|
2344 |
|
| 2304 |
posting.fail(function (data) { |
2345 |
$(".bookings_count").html( |
| 2305 |
$("#booking_result").replaceWith( |
2346 |
toInt($(".bookings_count").html()) + 1 |
| 2306 |
'<div id="booking_result" class="alert alert-danger">' + |
|
|
| 2307 |
__("Failure") + |
| 2308 |
"</div>" |
| 2309 |
); |
| 2310 |
}); |
| 2311 |
} else { |
| 2312 |
// For edits with "any item" (item_id == 0), use same hybrid approach as new bookings |
| 2313 |
let edit_payload = { |
| 2314 |
booking_id: booking_id, |
| 2315 |
start_date: start_date, |
| 2316 |
end_date: end_date, |
| 2317 |
pickup_library_id: pickup_library_id, |
| 2318 |
biblio_id: biblio_id, |
| 2319 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 2320 |
}; |
| 2321 |
|
| 2322 |
if (item_id == 0) { |
| 2323 |
// Get items of the selected itemtype that are available for the period |
| 2324 |
let itemsOfType = bookable_items.filter( |
| 2325 |
item => item.effective_item_type_id === booking_itemtype_id |
| 2326 |
); |
| 2327 |
|
| 2328 |
let availableItems = itemsOfType.filter(item => { |
| 2329 |
return isItemAvailableForPeriod( |
| 2330 |
item.item_id, |
| 2331 |
new Date(start_date), |
| 2332 |
new Date(end_date) |
| 2333 |
); |
2347 |
); |
|
|
2348 |
showBookingSuccess(__("Booking successfully placed")); |
| 2349 |
}) |
| 2350 |
.fail(function () { |
| 2351 |
showBookingError(__("Failure")); |
| 2334 |
}); |
2352 |
}); |
|
|
2353 |
} else { |
| 2354 |
// Update existing booking |
| 2355 |
payload.booking_id = booking_id; |
| 2335 |
|
2356 |
|
| 2336 |
if (availableItems.length === 0) { |
2357 |
$.ajax({ |
| 2337 |
$("#booking_result").replaceWith( |
|
|
| 2338 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2339 |
__("No suitable item found for booking") + |
| 2340 |
"</div>" |
| 2341 |
); |
| 2342 |
return; |
| 2343 |
} else if (availableItems.length === 1) { |
| 2344 |
// Only one item available - send specific item_id |
| 2345 |
edit_payload.item_id = availableItems[0].item_id; |
| 2346 |
} else { |
| 2347 |
// Multiple items available - let server choose optimal item |
| 2348 |
edit_payload.itemtype_id = booking_itemtype_id; |
| 2349 |
} |
| 2350 |
} else { |
| 2351 |
// Specific item selected |
| 2352 |
edit_payload.item_id = item_id; |
| 2353 |
} |
| 2354 |
|
| 2355 |
url += "/" + booking_id; |
| 2356 |
let putting = $.ajax({ |
| 2357 |
method: "PUT", |
2358 |
method: "PUT", |
| 2358 |
url: url, |
2359 |
url: url + "/" + booking_id, |
| 2359 |
contentType: "application/json", |
2360 |
contentType: "application/json", |
| 2360 |
data: JSON.stringify(edit_payload), |
2361 |
data: JSON.stringify(payload), |
| 2361 |
}); |
2362 |
}) |
| 2362 |
|
2363 |
.done(function (data) { |
| 2363 |
putting.done(function (data) { |
2364 |
const target = bookings.find( |
| 2364 |
update_success = 1; |
2365 |
obj => obj.booking_id === data.booking_id |
| 2365 |
|
2366 |
); |
| 2366 |
// Update bookings store for subsequent bookings |
2367 |
if (target) { |
| 2367 |
let target = bookings.find( |
2368 |
Object.assign(target, data); |
| 2368 |
obj => obj.booking_id === data.booking_id |
2369 |
} |
| 2369 |
); |
2370 |
refreshBookingsTable(); |
| 2370 |
Object.assign(target, data); |
|
|
| 2371 |
|
| 2372 |
// Update bookings page as required |
| 2373 |
if ( |
| 2374 |
typeof bookings_table !== "undefined" && |
| 2375 |
bookings_table !== null |
| 2376 |
) { |
| 2377 |
bookings_table.api().ajax.reload(); |
| 2378 |
} |
| 2379 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2380 |
// Convert to library timezone for timeline display |
| 2381 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2382 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2383 |
timeline.itemsData.update({ |
| 2384 |
id: data.booking_id, |
| 2385 |
booking: data.booking_id, |
| 2386 |
patron: data.patron_id, |
| 2387 |
start: $toDisplayDate(startServerTz), |
| 2388 |
end: $toDisplayDate(endServerTz), |
| 2389 |
content: $patron_to_html(booking_patron, { |
| 2390 |
display_cardnumber: true, |
| 2391 |
url: false, |
| 2392 |
}), |
| 2393 |
editable: { remove: true, updateTime: true }, |
| 2394 |
type: "range", |
| 2395 |
group: data.item_id ? data.item_id : 0, |
| 2396 |
}); |
| 2397 |
timeline.focus(data.booking_id); |
| 2398 |
} |
| 2399 |
|
| 2400 |
// Set feedback |
| 2401 |
$("#transient_result").replaceWith( |
| 2402 |
'<div id="transient_result" class="alert alert-info">' + |
| 2403 |
__("Booking successfully updated") + |
| 2404 |
"</div>" |
| 2405 |
); |
| 2406 |
|
2371 |
|
| 2407 |
// Close modal |
2372 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2408 |
$("#placeBookingModal").modal("hide"); |
2373 |
timeline.itemsData.update(createTimelineItem(data)); |
| 2409 |
}); |
2374 |
timeline.focus(data.booking_id); |
|
|
2375 |
} |
| 2410 |
|
2376 |
|
| 2411 |
putting.fail(function (data) { |
2377 |
showBookingSuccess(__("Booking successfully updated")); |
| 2412 |
$("#booking_result").replaceWith( |
2378 |
}) |
| 2413 |
'<div id="booking_result" class="alert alert-danger">' + |
2379 |
.fail(function () { |
| 2414 |
__("Failure") + |
2380 |
showBookingError(__("Failure")); |
| 2415 |
"</div>" |
2381 |
}); |
| 2416 |
); |
|
|
| 2417 |
}); |
| 2418 |
} |
2382 |
} |
| 2419 |
}); |
2383 |
}); |
| 2420 |
|
2384 |
|
| 2421 |
$("#placeBookingModal").on("hidden.bs.modal", function (e) { |
2385 |
$("#placeBookingModal").on("hidden.bs.modal", function (e) { |
| 2422 |
// Reset patron select |
2386 |
// Reset patron select |
| 2423 |
$("#booking_patron_id").val(null).trigger("change"); |
2387 |
$("#booking_patron_id") |
| 2424 |
$("#booking_patron_id").empty(); |
2388 |
.val(null) |
| 2425 |
$("#booking_patron_id").prop("disabled", false); |
2389 |
.trigger("change") |
|
|
2390 |
.empty() |
| 2391 |
.prop("disabled", false); |
| 2426 |
booking_patron = undefined; |
2392 |
booking_patron = undefined; |
| 2427 |
|
2393 |
|
| 2428 |
// Reset item select |
2394 |
// Reset item select |
| 2429 |
$("#booking_item_id").val(parseInt(0)).trigger("change"); |
2395 |
$("#booking_item_id").val(0).trigger("change").prop("disabled", true); |
| 2430 |
$("#booking_item_id").prop("disabled", true); |
|
|
| 2431 |
|
2396 |
|
| 2432 |
// Reset itemtype select |
2397 |
// Reset itemtype select |
| 2433 |
$("#booking_itemtype").val(null).trigger("change"); |
2398 |
$("#booking_itemtype").val(null).trigger("change").prop("disabled", true); |
| 2434 |
$("#booking_itemtype").prop("disabled", true); |
|
|
| 2435 |
booking_itemtype_id = undefined; |
2399 |
booking_itemtype_id = undefined; |
| 2436 |
|
2400 |
|
| 2437 |
// Reset pickup library select |
2401 |
// Reset pickup library select |
| 2438 |
$("#pickup_library_id").val(null).trigger("change"); |
2402 |
$("#pickup_library_id") |
| 2439 |
$("#pickup_library_id").empty(); |
2403 |
.val(null) |
| 2440 |
$("#pickup_library_id").prop("disabled", true); |
2404 |
.trigger("change") |
|
|
2405 |
.empty() |
| 2406 |
.prop("disabled", true); |
| 2441 |
|
2407 |
|
| 2442 |
// Reset booking period picker |
2408 |
// Reset booking period picker |
| 2443 |
$("#period").get(0)._flatpickr.clear(); |
2409 |
$("#period").get(0)._flatpickr.clear(); |
| 2444 |
- |
|
|