|
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 1047-1054
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1047 |
|
1077 |
|
| 1048 |
// Iterate through each date within the range of start_date and end_date |
1078 |
// Iterate through each date within the range of start_date and end_date |
| 1049 |
// Use dayjs to maintain browser timezone consistency |
1079 |
// Use dayjs to maintain browser timezone consistency |
| 1050 |
let currentDate = dayjs(start_date).startOf("day"); |
1080 |
let currentDate = startOfDay(start_date); |
| 1051 |
const endDate = dayjs(end_date).startOf("day"); |
1081 |
const endDate = startOfDay(end_date); |
| 1052 |
while (currentDate.isSameOrBefore(endDate, "day")) { |
1082 |
while (currentDate.isSameOrBefore(endDate, "day")) { |
| 1053 |
// Format in browser timezone - no UTC conversion |
1083 |
// Format in browser timezone - no UTC conversion |
| 1054 |
const currentDateStr = currentDate.format("YYYY-MM-DD"); |
1084 |
const currentDateStr = currentDate.format("YYYY-MM-DD"); |
|
Lines 1184-1206
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1184 |
const itemConflicts = new Map(); |
1214 |
const itemConflicts = new Map(); |
| 1185 |
if (isAnyItemMode) { |
1215 |
if (isAnyItemMode) { |
| 1186 |
itemsOfSelectedType.forEach(item => { |
1216 |
itemsOfSelectedType.forEach(item => { |
| 1187 |
itemConflicts.set( |
1217 |
itemConflicts.set(toInt(item.item_id), { |
| 1188 |
parseInt(item.item_id, 10), |
1218 |
leadConflict: false, |
| 1189 |
{ |
1219 |
trailConflict: false, |
| 1190 |
leadConflict: false, |
1220 |
leadReason: { |
| 1191 |
trailConflict: false, |
1221 |
withTrail: false, |
| 1192 |
leadReason: { |
1222 |
withLead: false, |
| 1193 |
withTrail: false, |
1223 |
withBooking: false, |
| 1194 |
withLead: false, |
1224 |
}, |
| 1195 |
withBooking: false, |
1225 |
trailReason: { |
| 1196 |
}, |
1226 |
withTrail: false, |
| 1197 |
trailReason: { |
1227 |
withLead: false, |
| 1198 |
withTrail: false, |
1228 |
withBooking: false, |
| 1199 |
withLead: false, |
1229 |
}, |
| 1200 |
withBooking: false, |
1230 |
}); |
| 1201 |
}, |
|
|
| 1202 |
} |
| 1203 |
); |
| 1204 |
}); |
1231 |
}); |
| 1205 |
} |
1232 |
} |
| 1206 |
|
1233 |
|
|
Lines 1213-1230
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1213 |
return; |
1240 |
return; |
| 1214 |
} |
1241 |
} |
| 1215 |
|
1242 |
|
| 1216 |
const bookingItemId = parseInt( |
1243 |
const bookingItemId = toInt(booking.item_id); |
| 1217 |
booking.item_id, |
|
|
| 1218 |
10 |
| 1219 |
); |
| 1220 |
|
1244 |
|
| 1221 |
// For specific item mode: skip bookings for different items |
1245 |
// For specific item mode: skip bookings for different items |
| 1222 |
if (!isAnyItemMode) { |
1246 |
if (!isAnyItemMode) { |
| 1223 |
if ( |
1247 |
if ( |
| 1224 |
booking.item_id && |
1248 |
booking.item_id && |
| 1225 |
booking_item_id && |
1249 |
booking_item_id && |
| 1226 |
bookingItemId !== |
1250 |
bookingItemId !== toInt(booking_item_id) |
| 1227 |
parseInt(booking_item_id, 10) |
|
|
| 1228 |
) { |
1251 |
) { |
| 1229 |
return; |
1252 |
return; |
| 1230 |
} |
1253 |
} |
|
Lines 1235-1246
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1235 |
} |
1258 |
} |
| 1236 |
} |
1259 |
} |
| 1237 |
|
1260 |
|
| 1238 |
const bookingStart = dayjs( |
1261 |
const bookingStart = startOfDay( |
| 1239 |
booking.start_date |
1262 |
booking.start_date |
| 1240 |
).startOf("day"); |
1263 |
); |
| 1241 |
const bookingEnd = dayjs( |
1264 |
const bookingEnd = startOfDay(booking.end_date); |
| 1242 |
booking.end_date |
|
|
| 1243 |
).startOf("day"); |
| 1244 |
|
1265 |
|
| 1245 |
// BIDIRECTIONAL: Mathematical checks for conflicts (works across month boundaries) |
1266 |
// BIDIRECTIONAL: Mathematical checks for conflicts (works across month boundaries) |
| 1246 |
// Calculate this booking's full protected period |
1267 |
// Calculate this booking's full protected period |
|
Lines 2128-2133
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 2128 |
} |
2149 |
} |
| 2129 |
}); |
2150 |
}); |
| 2130 |
|
2151 |
|
|
|
2152 |
/** |
| 2153 |
* Set date range on the period picker |
| 2154 |
* @param {Object} periodPicker - Flatpickr instance |
| 2155 |
* @param {string} start_date - Start date string |
| 2156 |
* @param {string} end_date - End date string |
| 2157 |
*/ |
| 2158 |
function setPickerDates(periodPicker, start_date, end_date) { |
| 2159 |
if (start_date && end_date) { |
| 2160 |
periodPicker.setDate([new Date(start_date), new Date(end_date)], true); |
| 2161 |
} |
| 2162 |
} |
| 2163 |
|
| 2131 |
function setFormValues( |
2164 |
function setFormValues( |
| 2132 |
patron_id, |
2165 |
patron_id, |
| 2133 |
booking_item_id, |
2166 |
booking_item_id, |
|
Lines 2140-2172
function setFormValues(
Link Here
|
| 2140 |
if (item_type_id) { |
2173 |
if (item_type_id) { |
| 2141 |
booking_itemtype_id = item_type_id; |
2174 |
booking_itemtype_id = item_type_id; |
| 2142 |
} |
2175 |
} |
|
|
2176 |
|
| 2143 |
// If passed patron, pre-select |
2177 |
// If passed patron, pre-select |
| 2144 |
if (patron_id) { |
2178 |
if (patron_id) { |
| 2145 |
let patronSelect = $("#booking_patron_id"); |
2179 |
const patronSelect = $("#booking_patron_id"); |
| 2146 |
let patron = $.ajax({ |
2180 |
$.ajax({ |
| 2147 |
url: "/api/v1/patrons/" + patron_id, |
2181 |
url: "/api/v1/patrons/" + patron_id, |
| 2148 |
dataType: "json", |
2182 |
dataType: "json", |
| 2149 |
type: "GET", |
2183 |
type: "GET", |
| 2150 |
}); |
2184 |
}).done(function (patron) { |
| 2151 |
|
|
|
| 2152 |
$.when(patron).done(function (patron) { |
| 2153 |
// clone patron_id to id (select2 expects an id field) |
| 2154 |
patron.id = patron.patron_id; |
2185 |
patron.id = patron.patron_id; |
| 2155 |
patron.text = |
2186 |
patron.text = |
| 2156 |
escape_str(patron.surname) + |
2187 |
escape_str(patron.surname) + |
| 2157 |
", " + |
2188 |
", " + |
| 2158 |
escape_str(patron.firstname); |
2189 |
escape_str(patron.firstname); |
| 2159 |
|
2190 |
|
| 2160 |
// Add and select new option |
2191 |
const newOption = new Option(patron.text, patron.id, true, true); |
| 2161 |
let newOption = new Option(patron.text, patron.id, true, true); |
|
|
| 2162 |
patronSelect.append(newOption).trigger("change"); |
2192 |
patronSelect.append(newOption).trigger("change"); |
| 2163 |
|
|
|
| 2164 |
// manually trigger the `select2:select` event |
| 2165 |
patronSelect.trigger({ |
2193 |
patronSelect.trigger({ |
| 2166 |
type: "select2:select", |
2194 |
type: "select2:select", |
| 2167 |
params: { |
2195 |
params: { data: patron }, |
| 2168 |
data: patron, |
|
|
| 2169 |
}, |
| 2170 |
}); |
2196 |
}); |
| 2171 |
}); |
2197 |
}); |
| 2172 |
} |
2198 |
} |
|
Lines 2176-2465
function setFormValues(
Link Here
|
| 2176 |
// Wait a bit for the item options to be fully created with data attributes |
2202 |
// Wait a bit for the item options to be fully created with data attributes |
| 2177 |
setTimeout(function () { |
2203 |
setTimeout(function () { |
| 2178 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
2204 |
$("#booking_item_id").val(booking_item_id).trigger("change"); |
| 2179 |
// Also trigger the select2:select event with proper data |
2205 |
const selectedOption = $("#booking_item_id option:selected")[0]; |
| 2180 |
let selectedOption = $("#booking_item_id option:selected")[0]; |
|
|
| 2181 |
if (selectedOption) { |
2206 |
if (selectedOption) { |
| 2182 |
$("#booking_item_id").trigger({ |
2207 |
$("#booking_item_id").trigger({ |
| 2183 |
type: "select2:select", |
2208 |
type: "select2:select", |
| 2184 |
params: { |
2209 |
params: { |
| 2185 |
data: { |
2210 |
data: { id: booking_item_id, element: selectedOption }, |
| 2186 |
id: booking_item_id, |
|
|
| 2187 |
element: selectedOption, |
| 2188 |
}, |
| 2189 |
}, |
2211 |
}, |
| 2190 |
}); |
2212 |
}); |
| 2191 |
} |
2213 |
} |
| 2192 |
|
2214 |
// Set dates AFTER item selection to ensure booking_itemtype_id is set |
| 2193 |
// IMPORTANT: Set dates AFTER item selection completes |
2215 |
setPickerDates(periodPicker, start_date, end_date); |
| 2194 |
// This ensures booking_itemtype_id is set before dates are validated |
|
|
| 2195 |
if (start_date) { |
| 2196 |
// Allow invalid pre-load so setDate can set date range |
| 2197 |
// periodPicker.set('allowInvalidPreload', true); |
| 2198 |
// FIXME: Why is this the case.. we're passing two valid Date objects |
| 2199 |
let start = new Date(start_date); |
| 2200 |
let end = new Date(end_date); |
| 2201 |
|
| 2202 |
let dates = [new Date(start_date), new Date(end_date)]; |
| 2203 |
periodPicker.setDate(dates, true); |
| 2204 |
} |
| 2205 |
}, 100); |
2216 |
}, 100); |
|
|
2217 |
} else if (start_date) { |
| 2218 |
setPickerDates(periodPicker, start_date, end_date); |
| 2219 |
} else { |
| 2220 |
periodPicker.redraw(); |
| 2206 |
} |
2221 |
} |
| 2207 |
// If no item selected but dates provided, set them now |
2222 |
} |
| 2208 |
else if (start_date) { |
2223 |
|
| 2209 |
let start = new Date(start_date); |
2224 |
/** |
| 2210 |
let end = new Date(end_date); |
2225 |
* Get available items of a specific itemtype for a booking period |
|
|
2226 |
* @param {string} startDate - Start date string |
| 2227 |
* @param {string} endDate - End date string |
| 2228 |
* @returns {Array} - Array of available items |
| 2229 |
*/ |
| 2230 |
function getAvailableItemsForPeriod(startDate, endDate) { |
| 2231 |
const itemsOfType = bookable_items.filter( |
| 2232 |
item => item.effective_item_type_id === booking_itemtype_id |
| 2233 |
); |
| 2234 |
return itemsOfType.filter(item => |
| 2235 |
isItemAvailableForPeriod( |
| 2236 |
item.item_id, |
| 2237 |
new Date(startDate), |
| 2238 |
new Date(endDate) |
| 2239 |
) |
| 2240 |
); |
| 2241 |
} |
| 2211 |
|
2242 |
|
| 2212 |
let dates = [new Date(start_date), new Date(end_date)]; |
2243 |
/** |
| 2213 |
periodPicker.setDate(dates, true); |
2244 |
* Build the booking payload with item selection logic |
|
|
2245 |
* @param {Object} basePayload - Base payload with common fields |
| 2246 |
* @param {string} itemId - Selected item ID (0 for "any item") |
| 2247 |
* @param {string} startDate - Start date string |
| 2248 |
* @param {string} endDate - End date string |
| 2249 |
* @returns {Object|null} - Complete payload or null if no items available |
| 2250 |
*/ |
| 2251 |
function buildBookingPayload(basePayload, itemId, startDate, endDate) { |
| 2252 |
const payload = { ...basePayload }; |
| 2253 |
|
| 2254 |
if (itemId == 0) { |
| 2255 |
const availableItems = getAvailableItemsForPeriod(startDate, endDate); |
| 2256 |
if (availableItems.length === 0) { |
| 2257 |
return null; |
| 2258 |
} else if (availableItems.length === 1) { |
| 2259 |
payload.item_id = availableItems[0].item_id; |
| 2260 |
} else { |
| 2261 |
payload.itemtype_id = booking_itemtype_id; |
| 2262 |
} |
| 2263 |
} else { |
| 2264 |
payload.item_id = itemId; |
| 2214 |
} |
2265 |
} |
| 2215 |
// Reset periodPicker, biblio_id may have been nulled |
2266 |
|
| 2216 |
else { |
2267 |
return payload; |
| 2217 |
periodPicker.redraw(); |
2268 |
} |
|
|
2269 |
|
| 2270 |
/** |
| 2271 |
* Create timeline item data from booking response |
| 2272 |
* @param {Object} data - Booking response data |
| 2273 |
* @returns {Object} - Timeline item data |
| 2274 |
*/ |
| 2275 |
function createTimelineItem(data) { |
| 2276 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2277 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2278 |
return { |
| 2279 |
id: data.booking_id, |
| 2280 |
booking: data.booking_id, |
| 2281 |
patron: data.patron_id, |
| 2282 |
start: $toDisplayDate(startServerTz), |
| 2283 |
end: $toDisplayDate(endServerTz), |
| 2284 |
content: $patron_to_html(booking_patron, { |
| 2285 |
display_cardnumber: true, |
| 2286 |
url: false, |
| 2287 |
}), |
| 2288 |
editable: { remove: true, updateTime: true }, |
| 2289 |
type: "range", |
| 2290 |
group: data.item_id ? data.item_id : 0, |
| 2291 |
}; |
| 2292 |
} |
| 2293 |
|
| 2294 |
/** |
| 2295 |
* Show error message in booking result area |
| 2296 |
* @param {string} message - Error message to display |
| 2297 |
*/ |
| 2298 |
function showBookingError(message) { |
| 2299 |
$("#booking_result").replaceWith( |
| 2300 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2301 |
message + |
| 2302 |
"</div>" |
| 2303 |
); |
| 2304 |
} |
| 2305 |
|
| 2306 |
/** |
| 2307 |
* Show success feedback and close modal |
| 2308 |
* @param {string} message - Success message to display |
| 2309 |
*/ |
| 2310 |
function showBookingSuccess(message) { |
| 2311 |
$("#transient_result").replaceWith( |
| 2312 |
'<div id="transient_result" class="alert alert-info">' + |
| 2313 |
message + |
| 2314 |
"</div>" |
| 2315 |
); |
| 2316 |
$("#placeBookingModal").modal("hide"); |
| 2317 |
} |
| 2318 |
|
| 2319 |
/** |
| 2320 |
* Refresh bookings table if present |
| 2321 |
*/ |
| 2322 |
function refreshBookingsTable() { |
| 2323 |
if (typeof bookings_table !== "undefined" && bookings_table !== null) { |
| 2324 |
bookings_table.api().ajax.reload(); |
| 2218 |
} |
2325 |
} |
| 2219 |
} |
2326 |
} |
| 2220 |
|
2327 |
|
| 2221 |
$("#placeBookingForm").on("submit", function (e) { |
2328 |
$("#placeBookingForm").on("submit", function (e) { |
| 2222 |
e.preventDefault(); |
2329 |
e.preventDefault(); |
| 2223 |
|
2330 |
|
| 2224 |
let url = "/api/v1/bookings"; |
2331 |
const url = "/api/v1/bookings"; |
| 2225 |
|
2332 |
const start_date = $("#booking_start_date").val(); |
| 2226 |
let start_date = $("#booking_start_date").val(); |
2333 |
const end_date = $("#booking_end_date").val(); |
| 2227 |
let end_date = $("#booking_end_date").val(); |
2334 |
const item_id = $("#booking_item_id").val(); |
| 2228 |
let pickup_library_id = $("#pickup_library_id").val(); |
|
|
| 2229 |
let biblio_id = $("#booking_biblio_id").val(); |
| 2230 |
let item_id = $("#booking_item_id").val(); |
| 2231 |
|
2335 |
|
| 2232 |
// Prepare booking payload |
2336 |
const basePayload = { |
| 2233 |
let booking_payload = { |
|
|
| 2234 |
start_date: start_date, |
2337 |
start_date: start_date, |
| 2235 |
end_date: end_date, |
2338 |
end_date: end_date, |
| 2236 |
pickup_library_id: pickup_library_id, |
2339 |
pickup_library_id: $("#pickup_library_id").val(), |
| 2237 |
biblio_id: biblio_id, |
2340 |
biblio_id: $("#booking_biblio_id").val(), |
| 2238 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
2341 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 2239 |
}; |
2342 |
}; |
| 2240 |
|
2343 |
|
| 2241 |
// If "any item" is selected, determine whether to send item_id or itemtype_id |
2344 |
const payload = buildBookingPayload( |
| 2242 |
if (item_id == 0) { |
2345 |
basePayload, |
| 2243 |
// Get items of the selected itemtype that are available for the period |
2346 |
item_id, |
| 2244 |
let itemsOfType = bookable_items.filter( |
2347 |
start_date, |
| 2245 |
item => item.effective_item_type_id === booking_itemtype_id |
2348 |
end_date |
| 2246 |
); |
2349 |
); |
| 2247 |
|
2350 |
if (!payload) { |
| 2248 |
let availableItems = itemsOfType.filter(item => { |
2351 |
showBookingError(__("No suitable item found for booking")); |
| 2249 |
return isItemAvailableForPeriod( |
2352 |
return; |
| 2250 |
item.item_id, |
|
|
| 2251 |
new Date(start_date), |
| 2252 |
new Date(end_date) |
| 2253 |
); |
| 2254 |
}); |
| 2255 |
|
| 2256 |
if (availableItems.length === 0) { |
| 2257 |
$("#booking_result").replaceWith( |
| 2258 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2259 |
__("No suitable item found for booking") + |
| 2260 |
"</div>" |
| 2261 |
); |
| 2262 |
return; |
| 2263 |
} else if (availableItems.length === 1) { |
| 2264 |
// Only one item available - optimization: send specific item_id |
| 2265 |
booking_payload.item_id = availableItems[0].item_id; |
| 2266 |
} else { |
| 2267 |
// Multiple items available - let server choose optimal item |
| 2268 |
booking_payload.itemtype_id = booking_itemtype_id; |
| 2269 |
} |
| 2270 |
} else { |
| 2271 |
// Specific item selected |
| 2272 |
booking_payload.item_id = item_id; |
| 2273 |
} |
2353 |
} |
| 2274 |
|
2354 |
|
| 2275 |
if (!booking_id) { |
2355 |
if (!booking_id) { |
| 2276 |
let posting = $.post(url, JSON.stringify(booking_payload)); |
2356 |
// Create new booking |
| 2277 |
|
2357 |
$.post(url, JSON.stringify(payload)) |
| 2278 |
posting.done(function (data) { |
2358 |
.done(function (data) { |
| 2279 |
// Update bookings store for subsequent bookings |
2359 |
bookings.push(data); |
| 2280 |
bookings.push(data); |
2360 |
refreshBookingsTable(); |
| 2281 |
|
2361 |
|
| 2282 |
// Update bookings page as required |
2362 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2283 |
if ( |
2363 |
timeline.itemsData.add(createTimelineItem(data)); |
| 2284 |
typeof bookings_table !== "undefined" && |
2364 |
timeline.focus(data.booking_id); |
| 2285 |
bookings_table !== null |
2365 |
} |
| 2286 |
) { |
|
|
| 2287 |
bookings_table.api().ajax.reload(); |
| 2288 |
} |
| 2289 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2290 |
// Convert to library timezone for timeline display |
| 2291 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2292 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2293 |
timeline.itemsData.add({ |
| 2294 |
id: data.booking_id, |
| 2295 |
booking: data.booking_id, |
| 2296 |
patron: data.patron_id, |
| 2297 |
start: $toDisplayDate(startServerTz), |
| 2298 |
end: $toDisplayDate(endServerTz), |
| 2299 |
content: $patron_to_html(booking_patron, { |
| 2300 |
display_cardnumber: true, |
| 2301 |
url: false, |
| 2302 |
}), |
| 2303 |
editable: { remove: true, updateTime: true }, |
| 2304 |
type: "range", |
| 2305 |
group: data.item_id ? data.item_id : 0, |
| 2306 |
}); |
| 2307 |
timeline.focus(data.booking_id); |
| 2308 |
} |
| 2309 |
|
| 2310 |
// Update bookings counts |
| 2311 |
$(".bookings_count").html( |
| 2312 |
parseInt($(".bookings_count").html(), 10) + 1 |
| 2313 |
); |
| 2314 |
|
| 2315 |
// Set feedback |
| 2316 |
$("#transient_result").replaceWith( |
| 2317 |
'<div id="transient_result" class="alert alert-info">' + |
| 2318 |
__("Booking successfully placed") + |
| 2319 |
"</div>" |
| 2320 |
); |
| 2321 |
|
| 2322 |
// Close modal |
| 2323 |
$("#placeBookingModal").modal("hide"); |
| 2324 |
}); |
| 2325 |
|
2366 |
|
| 2326 |
posting.fail(function (data) { |
2367 |
$(".bookings_count").html( |
| 2327 |
$("#booking_result").replaceWith( |
2368 |
toInt($(".bookings_count").html()) + 1 |
| 2328 |
'<div id="booking_result" class="alert alert-danger">' + |
|
|
| 2329 |
__("Failure") + |
| 2330 |
"</div>" |
| 2331 |
); |
| 2332 |
}); |
| 2333 |
} else { |
| 2334 |
// For edits with "any item" (item_id == 0), use same hybrid approach as new bookings |
| 2335 |
let edit_payload = { |
| 2336 |
booking_id: booking_id, |
| 2337 |
start_date: start_date, |
| 2338 |
end_date: end_date, |
| 2339 |
pickup_library_id: pickup_library_id, |
| 2340 |
biblio_id: biblio_id, |
| 2341 |
patron_id: $("#booking_patron_id").find(":selected").val(), |
| 2342 |
}; |
| 2343 |
|
| 2344 |
if (item_id == 0) { |
| 2345 |
// Get items of the selected itemtype that are available for the period |
| 2346 |
let itemsOfType = bookable_items.filter( |
| 2347 |
item => item.effective_item_type_id === booking_itemtype_id |
| 2348 |
); |
| 2349 |
|
| 2350 |
let availableItems = itemsOfType.filter(item => { |
| 2351 |
return isItemAvailableForPeriod( |
| 2352 |
item.item_id, |
| 2353 |
new Date(start_date), |
| 2354 |
new Date(end_date) |
| 2355 |
); |
2369 |
); |
|
|
2370 |
showBookingSuccess(__("Booking successfully placed")); |
| 2371 |
}) |
| 2372 |
.fail(function () { |
| 2373 |
showBookingError(__("Failure")); |
| 2356 |
}); |
2374 |
}); |
|
|
2375 |
} else { |
| 2376 |
// Update existing booking |
| 2377 |
payload.booking_id = booking_id; |
| 2357 |
|
2378 |
|
| 2358 |
if (availableItems.length === 0) { |
2379 |
$.ajax({ |
| 2359 |
$("#booking_result").replaceWith( |
|
|
| 2360 |
'<div id="booking_result" class="alert alert-danger">' + |
| 2361 |
__("No suitable item found for booking") + |
| 2362 |
"</div>" |
| 2363 |
); |
| 2364 |
return; |
| 2365 |
} else if (availableItems.length === 1) { |
| 2366 |
// Only one item available - send specific item_id |
| 2367 |
edit_payload.item_id = availableItems[0].item_id; |
| 2368 |
} else { |
| 2369 |
// Multiple items available - let server choose optimal item |
| 2370 |
edit_payload.itemtype_id = booking_itemtype_id; |
| 2371 |
} |
| 2372 |
} else { |
| 2373 |
// Specific item selected |
| 2374 |
edit_payload.item_id = item_id; |
| 2375 |
} |
| 2376 |
|
| 2377 |
url += "/" + booking_id; |
| 2378 |
let putting = $.ajax({ |
| 2379 |
method: "PUT", |
2380 |
method: "PUT", |
| 2380 |
url: url, |
2381 |
url: url + "/" + booking_id, |
| 2381 |
contentType: "application/json", |
2382 |
contentType: "application/json", |
| 2382 |
data: JSON.stringify(edit_payload), |
2383 |
data: JSON.stringify(payload), |
| 2383 |
}); |
2384 |
}) |
| 2384 |
|
2385 |
.done(function (data) { |
| 2385 |
putting.done(function (data) { |
2386 |
const target = bookings.find( |
| 2386 |
update_success = 1; |
2387 |
obj => obj.booking_id === data.booking_id |
| 2387 |
|
2388 |
); |
| 2388 |
// Update bookings store for subsequent bookings |
2389 |
if (target) { |
| 2389 |
let target = bookings.find( |
2390 |
Object.assign(target, data); |
| 2390 |
obj => obj.booking_id === data.booking_id |
2391 |
} |
| 2391 |
); |
2392 |
refreshBookingsTable(); |
| 2392 |
Object.assign(target, data); |
|
|
| 2393 |
|
| 2394 |
// Update bookings page as required |
| 2395 |
if ( |
| 2396 |
typeof bookings_table !== "undefined" && |
| 2397 |
bookings_table !== null |
| 2398 |
) { |
| 2399 |
bookings_table.api().ajax.reload(); |
| 2400 |
} |
| 2401 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2402 |
// Convert to library timezone for timeline display |
| 2403 |
const startServerTz = dayjs(data.start_date).tz($timezone()); |
| 2404 |
const endServerTz = dayjs(data.end_date).tz($timezone()); |
| 2405 |
timeline.itemsData.update({ |
| 2406 |
id: data.booking_id, |
| 2407 |
booking: data.booking_id, |
| 2408 |
patron: data.patron_id, |
| 2409 |
start: $toDisplayDate(startServerTz), |
| 2410 |
end: $toDisplayDate(endServerTz), |
| 2411 |
content: $patron_to_html(booking_patron, { |
| 2412 |
display_cardnumber: true, |
| 2413 |
url: false, |
| 2414 |
}), |
| 2415 |
editable: { remove: true, updateTime: true }, |
| 2416 |
type: "range", |
| 2417 |
group: data.item_id ? data.item_id : 0, |
| 2418 |
}); |
| 2419 |
timeline.focus(data.booking_id); |
| 2420 |
} |
| 2421 |
|
| 2422 |
// Set feedback |
| 2423 |
$("#transient_result").replaceWith( |
| 2424 |
'<div id="transient_result" class="alert alert-info">' + |
| 2425 |
__("Booking successfully updated") + |
| 2426 |
"</div>" |
| 2427 |
); |
| 2428 |
|
2393 |
|
| 2429 |
// Close modal |
2394 |
if (typeof timeline !== "undefined" && timeline !== null) { |
| 2430 |
$("#placeBookingModal").modal("hide"); |
2395 |
timeline.itemsData.update(createTimelineItem(data)); |
| 2431 |
}); |
2396 |
timeline.focus(data.booking_id); |
|
|
2397 |
} |
| 2432 |
|
2398 |
|
| 2433 |
putting.fail(function (data) { |
2399 |
showBookingSuccess(__("Booking successfully updated")); |
| 2434 |
$("#booking_result").replaceWith( |
2400 |
}) |
| 2435 |
'<div id="booking_result" class="alert alert-danger">' + |
2401 |
.fail(function () { |
| 2436 |
__("Failure") + |
2402 |
showBookingError(__("Failure")); |
| 2437 |
"</div>" |
2403 |
}); |
| 2438 |
); |
|
|
| 2439 |
}); |
| 2440 |
} |
2404 |
} |
| 2441 |
}); |
2405 |
}); |
| 2442 |
|
2406 |
|
| 2443 |
$("#placeBookingModal").on("hidden.bs.modal", function (e) { |
2407 |
$("#placeBookingModal").on("hidden.bs.modal", function (e) { |
| 2444 |
// Reset patron select |
2408 |
// Reset patron select |
| 2445 |
$("#booking_patron_id").val(null).trigger("change"); |
2409 |
$("#booking_patron_id") |
| 2446 |
$("#booking_patron_id").empty(); |
2410 |
.val(null) |
| 2447 |
$("#booking_patron_id").prop("disabled", false); |
2411 |
.trigger("change") |
|
|
2412 |
.empty() |
| 2413 |
.prop("disabled", false); |
| 2448 |
booking_patron = undefined; |
2414 |
booking_patron = undefined; |
| 2449 |
|
2415 |
|
| 2450 |
// Reset item select |
2416 |
// Reset item select |
| 2451 |
$("#booking_item_id").val(parseInt(0)).trigger("change"); |
2417 |
$("#booking_item_id").val(0).trigger("change").prop("disabled", true); |
| 2452 |
$("#booking_item_id").prop("disabled", true); |
|
|
| 2453 |
|
2418 |
|
| 2454 |
// Reset itemtype select |
2419 |
// Reset itemtype select |
| 2455 |
$("#booking_itemtype").val(null).trigger("change"); |
2420 |
$("#booking_itemtype").val(null).trigger("change").prop("disabled", true); |
| 2456 |
$("#booking_itemtype").prop("disabled", true); |
|
|
| 2457 |
booking_itemtype_id = undefined; |
2421 |
booking_itemtype_id = undefined; |
| 2458 |
|
2422 |
|
| 2459 |
// Reset pickup library select |
2423 |
// Reset pickup library select |
| 2460 |
$("#pickup_library_id").val(null).trigger("change"); |
2424 |
$("#pickup_library_id") |
| 2461 |
$("#pickup_library_id").empty(); |
2425 |
.val(null) |
| 2462 |
$("#pickup_library_id").prop("disabled", true); |
2426 |
.trigger("change") |
|
|
2427 |
.empty() |
| 2428 |
.prop("disabled", true); |
| 2463 |
|
2429 |
|
| 2464 |
// Reset booking period picker |
2430 |
// Reset booking period picker |
| 2465 |
$("#period").get(0)._flatpickr.clear(); |
2431 |
$("#period").get(0)._flatpickr.clear(); |
| 2466 |
- |
|
|