|
Lines 1113-1118
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1113 |
withBooking: false, |
1113 |
withBooking: false, |
| 1114 |
}; |
1114 |
}; |
| 1115 |
|
1115 |
|
|
|
1116 |
// For "any item" mode, we need to check if at least one item |
| 1117 |
// of the selected itemtype is free from lead/trail conflicts. |
| 1118 |
// For specific item mode, we use the original single-item logic. |
| 1119 |
const isAnyItemMode = |
| 1120 |
!booking_item_id && booking_itemtype_id; |
| 1121 |
|
| 1122 |
// Get items of the selected itemtype for "any item" mode |
| 1123 |
let itemsOfSelectedType = []; |
| 1124 |
if (isAnyItemMode) { |
| 1125 |
itemsOfSelectedType = bookable_items.filter( |
| 1126 |
item => |
| 1127 |
item.effective_item_type_id === |
| 1128 |
booking_itemtype_id |
| 1129 |
); |
| 1130 |
} |
| 1131 |
|
| 1132 |
// Track per-item conflicts for "any item" mode |
| 1133 |
// Maps item_id -> { leadConflict: bool, trailConflict: bool, leadReason: {...}, trailReason: {...} } |
| 1134 |
const itemConflicts = new Map(); |
| 1135 |
if (isAnyItemMode) { |
| 1136 |
itemsOfSelectedType.forEach(item => { |
| 1137 |
itemConflicts.set( |
| 1138 |
parseInt(item.item_id, 10), |
| 1139 |
{ |
| 1140 |
leadConflict: false, |
| 1141 |
trailConflict: false, |
| 1142 |
leadReason: { |
| 1143 |
withTrail: false, |
| 1144 |
withLead: false, |
| 1145 |
withBooking: false, |
| 1146 |
}, |
| 1147 |
trailReason: { |
| 1148 |
withTrail: false, |
| 1149 |
withLead: false, |
| 1150 |
withBooking: false, |
| 1151 |
}, |
| 1152 |
} |
| 1153 |
); |
| 1154 |
}); |
| 1155 |
} |
| 1156 |
|
| 1116 |
bookings.forEach(booking => { |
1157 |
bookings.forEach(booking => { |
| 1117 |
// Skip if we're editing this booking |
1158 |
// Skip if we're editing this booking |
| 1118 |
if ( |
1159 |
if ( |
|
Lines 1122-1134
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1122 |
return; |
1163 |
return; |
| 1123 |
} |
1164 |
} |
| 1124 |
|
1165 |
|
| 1125 |
// Skip if not same item (for item-specific bookings) |
1166 |
const bookingItemId = parseInt( |
| 1126 |
if ( |
1167 |
booking.item_id, |
| 1127 |
booking.item_id && |
1168 |
10 |
| 1128 |
booking_item_id && |
1169 |
); |
| 1129 |
booking.item_id != booking_item_id |
1170 |
|
| 1130 |
) { |
1171 |
// For specific item mode: skip bookings for different items |
| 1131 |
return; |
1172 |
if (!isAnyItemMode) { |
|
|
1173 |
if ( |
| 1174 |
booking.item_id && |
| 1175 |
booking_item_id && |
| 1176 |
bookingItemId !== |
| 1177 |
parseInt(booking_item_id, 10) |
| 1178 |
) { |
| 1179 |
return; |
| 1180 |
} |
| 1181 |
} else { |
| 1182 |
// For "any item" mode: skip bookings for items not of the selected itemtype |
| 1183 |
if (!itemConflicts.has(bookingItemId)) { |
| 1184 |
return; |
| 1185 |
} |
| 1132 |
} |
1186 |
} |
| 1133 |
|
1187 |
|
| 1134 |
const bookingStart = dayjs( |
1188 |
const bookingStart = dayjs( |
|
Lines 1159-1164
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1159 |
|
1213 |
|
| 1160 |
// Check if new booking's LEAD period overlaps with existing booking |
1214 |
// Check if new booking's LEAD period overlaps with existing booking |
| 1161 |
if (!periodPicker.selectedDates[0]) { |
1215 |
if (!periodPicker.selectedDates[0]) { |
|
|
1216 |
let hasLeadConflict = false; |
| 1217 |
let reason = { |
| 1218 |
withTrail: false, |
| 1219 |
withLead: false, |
| 1220 |
withBooking: false, |
| 1221 |
}; |
| 1222 |
|
| 1162 |
// Check overlap with existing booking's trail period |
1223 |
// Check overlap with existing booking's trail period |
| 1163 |
if ( |
1224 |
if ( |
| 1164 |
leadStart.isSameOrBefore( |
1225 |
leadStart.isSameOrBefore( |
|
Lines 1168-1175
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1168 |
existingTrailStart |
1229 |
existingTrailStart |
| 1169 |
) |
1230 |
) |
| 1170 |
) { |
1231 |
) { |
| 1171 |
leadDisable = true; |
1232 |
hasLeadConflict = true; |
| 1172 |
leadConflictReason.withTrail = true; |
1233 |
reason.withTrail = true; |
| 1173 |
} |
1234 |
} |
| 1174 |
// Check overlap with existing booking's lead period |
1235 |
// Check overlap with existing booking's lead period |
| 1175 |
else if ( |
1236 |
else if ( |
|
Lines 1178-1198
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1178 |
) && |
1239 |
) && |
| 1179 |
leadEnd.isSameOrAfter(existingLeadStart) |
1240 |
leadEnd.isSameOrAfter(existingLeadStart) |
| 1180 |
) { |
1241 |
) { |
| 1181 |
leadDisable = true; |
1242 |
hasLeadConflict = true; |
| 1182 |
leadConflictReason.withLead = true; |
1243 |
reason.withLead = true; |
| 1183 |
} |
1244 |
} |
| 1184 |
// Check overlap with existing booking itself |
1245 |
// Check overlap with existing booking itself |
| 1185 |
else if ( |
1246 |
else if ( |
| 1186 |
leadStart.isSameOrBefore(bookingEnd) && |
1247 |
leadStart.isSameOrBefore(bookingEnd) && |
| 1187 |
leadEnd.isSameOrAfter(bookingStart) |
1248 |
leadEnd.isSameOrAfter(bookingStart) |
| 1188 |
) { |
1249 |
) { |
| 1189 |
leadDisable = true; |
1250 |
hasLeadConflict = true; |
| 1190 |
leadConflictReason.withBooking = true; |
1251 |
reason.withBooking = true; |
|
|
1252 |
} |
| 1253 |
|
| 1254 |
if (hasLeadConflict) { |
| 1255 |
if (isAnyItemMode) { |
| 1256 |
// Track conflict for this specific item |
| 1257 |
const itemState = |
| 1258 |
itemConflicts.get( |
| 1259 |
bookingItemId |
| 1260 |
); |
| 1261 |
if (itemState) { |
| 1262 |
itemState.leadConflict = true; |
| 1263 |
Object.assign( |
| 1264 |
itemState.leadReason, |
| 1265 |
reason |
| 1266 |
); |
| 1267 |
} |
| 1268 |
} else { |
| 1269 |
// Specific item mode: set global flags |
| 1270 |
leadDisable = true; |
| 1271 |
Object.assign( |
| 1272 |
leadConflictReason, |
| 1273 |
reason |
| 1274 |
); |
| 1275 |
} |
| 1191 |
} |
1276 |
} |
| 1192 |
} |
1277 |
} |
| 1193 |
|
1278 |
|
| 1194 |
// Check if new booking's TRAIL period overlaps with existing booking |
1279 |
// Check if new booking's TRAIL period overlaps with existing booking |
| 1195 |
if (periodPicker.selectedDates[0]) { |
1280 |
if (periodPicker.selectedDates[0]) { |
|
|
1281 |
let hasTrailConflict = false; |
| 1282 |
let reason = { |
| 1283 |
withTrail: false, |
| 1284 |
withLead: false, |
| 1285 |
withBooking: false, |
| 1286 |
}; |
| 1287 |
|
| 1196 |
// Check overlap with existing booking's lead period |
1288 |
// Check overlap with existing booking's lead period |
| 1197 |
if ( |
1289 |
if ( |
| 1198 |
trailStart.isSameOrBefore( |
1290 |
trailStart.isSameOrBefore( |
|
Lines 1202-1209
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1202 |
existingLeadStart |
1294 |
existingLeadStart |
| 1203 |
) |
1295 |
) |
| 1204 |
) { |
1296 |
) { |
| 1205 |
trailDisable = true; |
1297 |
hasTrailConflict = true; |
| 1206 |
trailConflictReason.withLead = true; |
1298 |
reason.withLead = true; |
| 1207 |
} |
1299 |
} |
| 1208 |
// Check overlap with existing booking's trail period |
1300 |
// Check overlap with existing booking's trail period |
| 1209 |
else if ( |
1301 |
else if ( |
|
Lines 1214-1233
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1214 |
existingTrailStart |
1306 |
existingTrailStart |
| 1215 |
) |
1307 |
) |
| 1216 |
) { |
1308 |
) { |
| 1217 |
trailDisable = true; |
1309 |
hasTrailConflict = true; |
| 1218 |
trailConflictReason.withTrail = true; |
1310 |
reason.withTrail = true; |
| 1219 |
} |
1311 |
} |
| 1220 |
// Check overlap with existing booking itself |
1312 |
// Check overlap with existing booking itself |
| 1221 |
else if ( |
1313 |
else if ( |
| 1222 |
trailStart.isSameOrBefore(bookingEnd) && |
1314 |
trailStart.isSameOrBefore(bookingEnd) && |
| 1223 |
trailEnd.isSameOrAfter(bookingStart) |
1315 |
trailEnd.isSameOrAfter(bookingStart) |
| 1224 |
) { |
1316 |
) { |
| 1225 |
trailDisable = true; |
1317 |
hasTrailConflict = true; |
| 1226 |
trailConflictReason.withBooking = true; |
1318 |
reason.withBooking = true; |
|
|
1319 |
} |
| 1320 |
|
| 1321 |
if (hasTrailConflict) { |
| 1322 |
if (isAnyItemMode) { |
| 1323 |
// Track conflict for this specific item |
| 1324 |
const itemState = |
| 1325 |
itemConflicts.get( |
| 1326 |
bookingItemId |
| 1327 |
); |
| 1328 |
if (itemState) { |
| 1329 |
itemState.trailConflict = true; |
| 1330 |
Object.assign( |
| 1331 |
itemState.trailReason, |
| 1332 |
reason |
| 1333 |
); |
| 1334 |
} |
| 1335 |
} else { |
| 1336 |
// Specific item mode: set global flags |
| 1337 |
trailDisable = true; |
| 1338 |
Object.assign( |
| 1339 |
trailConflictReason, |
| 1340 |
reason |
| 1341 |
); |
| 1342 |
} |
| 1227 |
} |
1343 |
} |
| 1228 |
} |
1344 |
} |
| 1229 |
|
1345 |
|
| 1230 |
// Find closest bookings for visual feedback (when dates are in view) |
1346 |
// Find closest bookings for visual feedback (when dates are in view) |
|
|
1347 |
// For "any item" mode, only track closest bookings for items of the selected type |
| 1231 |
if (bookingEnd.isBefore(hoverDate)) { |
1348 |
if (bookingEnd.isBefore(hoverDate)) { |
| 1232 |
const distance = hoverDate.diff( |
1349 |
const distance = hoverDate.diff( |
| 1233 |
bookingEnd, |
1350 |
bookingEnd, |
|
Lines 1257-1262
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1257 |
} |
1374 |
} |
| 1258 |
}); |
1375 |
}); |
| 1259 |
|
1376 |
|
|
|
1377 |
// For "any item" mode: only disable if ALL items have conflicts |
| 1378 |
if (isAnyItemMode && itemConflicts.size > 0) { |
| 1379 |
// Check if all items have lead conflicts |
| 1380 |
let allHaveLeadConflict = true; |
| 1381 |
let allHaveTrailConflict = true; |
| 1382 |
|
| 1383 |
for (const [ |
| 1384 |
itemId, |
| 1385 |
state, |
| 1386 |
] of itemConflicts.entries()) { |
| 1387 |
if (!state.leadConflict) { |
| 1388 |
allHaveLeadConflict = false; |
| 1389 |
} |
| 1390 |
if (!state.trailConflict) { |
| 1391 |
allHaveTrailConflict = false; |
| 1392 |
} |
| 1393 |
} |
| 1394 |
|
| 1395 |
if (allHaveLeadConflict) { |
| 1396 |
leadDisable = true; |
| 1397 |
// Use the reason from the first item with a conflict for messaging |
| 1398 |
for (const [ |
| 1399 |
itemId, |
| 1400 |
state, |
| 1401 |
] of itemConflicts.entries()) { |
| 1402 |
if (state.leadConflict) { |
| 1403 |
Object.assign( |
| 1404 |
leadConflictReason, |
| 1405 |
state.leadReason |
| 1406 |
); |
| 1407 |
break; |
| 1408 |
} |
| 1409 |
} |
| 1410 |
} |
| 1411 |
|
| 1412 |
if (allHaveTrailConflict) { |
| 1413 |
trailDisable = true; |
| 1414 |
// Use the reason from the first item with a conflict for messaging |
| 1415 |
for (const [ |
| 1416 |
itemId, |
| 1417 |
state, |
| 1418 |
] of itemConflicts.entries()) { |
| 1419 |
if (state.trailConflict) { |
| 1420 |
Object.assign( |
| 1421 |
trailConflictReason, |
| 1422 |
state.trailReason |
| 1423 |
); |
| 1424 |
break; |
| 1425 |
} |
| 1426 |
} |
| 1427 |
} |
| 1428 |
} |
| 1429 |
|
| 1430 |
// For "any item" mode, find closest "all items booked" dates mathematically |
| 1431 |
// These are dates where ALL items of the itemtype have bookings |
| 1432 |
// Using mathematical search allows detection across month boundaries |
| 1433 |
let closestFullyBookedBefore = null; |
| 1434 |
let closestFullyBookedAfter = null; |
| 1435 |
|
| 1436 |
if ( |
| 1437 |
isAnyItemMode && |
| 1438 |
itemsOfSelectedType.length > 0 |
| 1439 |
) { |
| 1440 |
const searchLimit = 180; // Days to search in each direction |
| 1441 |
|
| 1442 |
// Search backwards for closest fully-booked date |
| 1443 |
for (let i = 1; i <= searchLimit; i++) { |
| 1444 |
const checkDate = hoverDate.subtract( |
| 1445 |
i, |
| 1446 |
"day" |
| 1447 |
); |
| 1448 |
const availableItems = |
| 1449 |
getAvailableItemsOnDate( |
| 1450 |
checkDate.toDate(), |
| 1451 |
itemsOfSelectedType |
| 1452 |
); |
| 1453 |
if (availableItems.length === 0) { |
| 1454 |
closestFullyBookedBefore = checkDate; |
| 1455 |
break; |
| 1456 |
} |
| 1457 |
} |
| 1458 |
|
| 1459 |
// Search forwards for closest fully-booked date |
| 1460 |
for (let i = 1; i <= searchLimit; i++) { |
| 1461 |
const checkDate = hoverDate.add(i, "day"); |
| 1462 |
const availableItems = |
| 1463 |
getAvailableItemsOnDate( |
| 1464 |
checkDate.toDate(), |
| 1465 |
itemsOfSelectedType |
| 1466 |
); |
| 1467 |
if (availableItems.length === 0) { |
| 1468 |
closestFullyBookedAfter = checkDate; |
| 1469 |
break; |
| 1470 |
} |
| 1471 |
} |
| 1472 |
} |
| 1473 |
|
| 1260 |
// Work through all days in view and add classes appropraitely based on hovered date |
1474 |
// Work through all days in view and add classes appropraitely based on hovered date |
| 1261 |
periodPicker.calendarContainer |
1475 |
periodPicker.calendarContainer |
| 1262 |
.querySelectorAll(".flatpickr-day") |
1476 |
.querySelectorAll(".flatpickr-day") |
|
Lines 1324-1341
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1324 |
); |
1538 |
); |
| 1325 |
} |
1539 |
} |
| 1326 |
|
1540 |
|
| 1327 |
// Show closest preceding existing booking's trail period |
1541 |
// Show closest preceding booking's trail period |
| 1328 |
if (closestBeforeBooking && trailDays > 0) { |
1542 |
// For "any item" mode, use closest fully-booked date; for specific item, use closest booking |
|
|
1543 |
const useClosestFullyBookedForTrail = |
| 1544 |
isAnyItemMode && |
| 1545 |
closestFullyBookedBefore; |
| 1546 |
const useClosestBookingForTrail = |
| 1547 |
!isAnyItemMode && closestBeforeBooking; |
| 1548 |
|
| 1549 |
if ( |
| 1550 |
trailDays > 0 && |
| 1551 |
(useClosestFullyBookedForTrail || |
| 1552 |
useClosestBookingForTrail) |
| 1553 |
) { |
| 1329 |
const existingTrailStart = |
1554 |
const existingTrailStart = |
| 1330 |
closestBeforeBooking.end.add( |
1555 |
useClosestFullyBookedForTrail |
| 1331 |
1, |
1556 |
? closestFullyBookedBefore.add( |
| 1332 |
"day" |
1557 |
1, |
| 1333 |
); |
1558 |
"day" |
|
|
1559 |
) |
| 1560 |
: closestBeforeBooking.end.add( |
| 1561 |
1, |
| 1562 |
"day" |
| 1563 |
); |
| 1334 |
const existingTrailEnd = |
1564 |
const existingTrailEnd = |
| 1335 |
closestBeforeBooking.end.add( |
1565 |
useClosestFullyBookedForTrail |
| 1336 |
trailDays, |
1566 |
? closestFullyBookedBefore.add( |
| 1337 |
"day" |
1567 |
trailDays, |
| 1338 |
); |
1568 |
"day" |
|
|
1569 |
) |
| 1570 |
: closestBeforeBooking.end.add( |
| 1571 |
trailDays, |
| 1572 |
"day" |
| 1573 |
); |
| 1339 |
|
1574 |
|
| 1340 |
if ( |
1575 |
if ( |
| 1341 |
elemDate.isSameOrAfter( |
1576 |
elemDate.isSameOrAfter( |
|
Lines 1370-1387
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1370 |
} |
1605 |
} |
| 1371 |
} |
1606 |
} |
| 1372 |
|
1607 |
|
| 1373 |
// Show closest following existing booking's lead period |
1608 |
// Show closest following booking's lead period |
| 1374 |
if (closestAfterBooking && leadDays > 0) { |
1609 |
// For "any item" mode, use closest fully-booked date; for specific item, use closest booking |
|
|
1610 |
const useClosestFullyBookedForLead = |
| 1611 |
isAnyItemMode && |
| 1612 |
closestFullyBookedAfter; |
| 1613 |
const useClosestBookingForLead = |
| 1614 |
!isAnyItemMode && closestAfterBooking; |
| 1615 |
|
| 1616 |
if ( |
| 1617 |
leadDays > 0 && |
| 1618 |
(useClosestFullyBookedForLead || |
| 1619 |
useClosestBookingForLead) |
| 1620 |
) { |
| 1375 |
const existingLeadStart = |
1621 |
const existingLeadStart = |
| 1376 |
closestAfterBooking.start.subtract( |
1622 |
useClosestFullyBookedForLead |
| 1377 |
leadDays, |
1623 |
? closestFullyBookedAfter.subtract( |
| 1378 |
"day" |
1624 |
leadDays, |
| 1379 |
); |
1625 |
"day" |
|
|
1626 |
) |
| 1627 |
: closestAfterBooking.start.subtract( |
| 1628 |
leadDays, |
| 1629 |
"day" |
| 1630 |
); |
| 1380 |
const existingLeadEnd = |
1631 |
const existingLeadEnd = |
| 1381 |
closestAfterBooking.start.subtract( |
1632 |
useClosestFullyBookedForLead |
| 1382 |
1, |
1633 |
? closestFullyBookedAfter.subtract( |
| 1383 |
"day" |
1634 |
1, |
| 1384 |
); |
1635 |
"day" |
|
|
1636 |
) |
| 1637 |
: closestAfterBooking.start.subtract( |
| 1638 |
1, |
| 1639 |
"day" |
| 1640 |
); |
| 1385 |
|
1641 |
|
| 1386 |
if ( |
1642 |
if ( |
| 1387 |
elemDate.isSameOrAfter( |
1643 |
elemDate.isSameOrAfter( |
|
Lines 1453-1458
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1453 |
} |
1709 |
} |
| 1454 |
|
1710 |
|
| 1455 |
// Check for conflicts with existing booking's trail period |
1711 |
// Check for conflicts with existing booking's trail period |
|
|
1712 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1456 |
if ( |
1713 |
if ( |
| 1457 |
!periodPicker.selectedDates[0] && |
1714 |
!periodPicker.selectedDates[0] && |
| 1458 |
dayElem.classList.contains( |
1715 |
dayElem.classList.contains( |
|
Lines 1469-1474
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1469 |
} |
1726 |
} |
| 1470 |
|
1727 |
|
| 1471 |
// Check for conflicts with existing booking's lead period |
1728 |
// Check for conflicts with existing booking's lead period |
|
|
1729 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1472 |
if ( |
1730 |
if ( |
| 1473 |
periodPicker.selectedDates[0] && |
1731 |
periodPicker.selectedDates[0] && |
| 1474 |
dayElem.classList.contains( |
1732 |
dayElem.classList.contains( |
|
Lines 1496-1501
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1496 |
}); |
1754 |
}); |
| 1497 |
|
1755 |
|
| 1498 |
// Additional check for hovering directly on existing booking's lead/trail periods |
1756 |
// Additional check for hovering directly on existing booking's lead/trail periods |
|
|
1757 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1499 |
// If hovering on an existing booking's lead period when selecting start date, block selection |
1758 |
// If hovering on an existing booking's lead period when selecting start date, block selection |
| 1500 |
if ( |
1759 |
if ( |
| 1501 |
!periodPicker.selectedDates[0] && |
1760 |
!periodPicker.selectedDates[0] && |
|
Lines 1505-1510
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1505 |
} |
1764 |
} |
| 1506 |
|
1765 |
|
| 1507 |
// If hovering on an existing booking's trail period when selecting end date, block selection |
1766 |
// If hovering on an existing booking's trail period when selecting end date, block selection |
|
|
1767 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1508 |
if ( |
1768 |
if ( |
| 1509 |
periodPicker.selectedDates[0] && |
1769 |
periodPicker.selectedDates[0] && |
| 1510 |
target.classList.contains( |
1770 |
target.classList.contains( |