|
Lines 1135-1140
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1135 |
withBooking: false, |
1135 |
withBooking: false, |
| 1136 |
}; |
1136 |
}; |
| 1137 |
|
1137 |
|
|
|
1138 |
// For "any item" mode, we need to check if at least one item |
| 1139 |
// of the selected itemtype is free from lead/trail conflicts. |
| 1140 |
// For specific item mode, we use the original single-item logic. |
| 1141 |
const isAnyItemMode = |
| 1142 |
!booking_item_id && booking_itemtype_id; |
| 1143 |
|
| 1144 |
// Get items of the selected itemtype for "any item" mode |
| 1145 |
let itemsOfSelectedType = []; |
| 1146 |
if (isAnyItemMode) { |
| 1147 |
itemsOfSelectedType = bookable_items.filter( |
| 1148 |
item => |
| 1149 |
item.effective_item_type_id === |
| 1150 |
booking_itemtype_id |
| 1151 |
); |
| 1152 |
} |
| 1153 |
|
| 1154 |
// Track per-item conflicts for "any item" mode |
| 1155 |
// Maps item_id -> { leadConflict: bool, trailConflict: bool, leadReason: {...}, trailReason: {...} } |
| 1156 |
const itemConflicts = new Map(); |
| 1157 |
if (isAnyItemMode) { |
| 1158 |
itemsOfSelectedType.forEach(item => { |
| 1159 |
itemConflicts.set( |
| 1160 |
parseInt(item.item_id, 10), |
| 1161 |
{ |
| 1162 |
leadConflict: false, |
| 1163 |
trailConflict: false, |
| 1164 |
leadReason: { |
| 1165 |
withTrail: false, |
| 1166 |
withLead: false, |
| 1167 |
withBooking: false, |
| 1168 |
}, |
| 1169 |
trailReason: { |
| 1170 |
withTrail: false, |
| 1171 |
withLead: false, |
| 1172 |
withBooking: false, |
| 1173 |
}, |
| 1174 |
} |
| 1175 |
); |
| 1176 |
}); |
| 1177 |
} |
| 1178 |
|
| 1138 |
bookings.forEach(booking => { |
1179 |
bookings.forEach(booking => { |
| 1139 |
// Skip if we're editing this booking |
1180 |
// Skip if we're editing this booking |
| 1140 |
if ( |
1181 |
if ( |
|
Lines 1144-1156
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1144 |
return; |
1185 |
return; |
| 1145 |
} |
1186 |
} |
| 1146 |
|
1187 |
|
| 1147 |
// Skip if not same item (for item-specific bookings) |
1188 |
const bookingItemId = parseInt( |
| 1148 |
if ( |
1189 |
booking.item_id, |
| 1149 |
booking.item_id && |
1190 |
10 |
| 1150 |
booking_item_id && |
1191 |
); |
| 1151 |
booking.item_id != booking_item_id |
1192 |
|
| 1152 |
) { |
1193 |
// For specific item mode: skip bookings for different items |
| 1153 |
return; |
1194 |
if (!isAnyItemMode) { |
|
|
1195 |
if ( |
| 1196 |
booking.item_id && |
| 1197 |
booking_item_id && |
| 1198 |
bookingItemId !== |
| 1199 |
parseInt(booking_item_id, 10) |
| 1200 |
) { |
| 1201 |
return; |
| 1202 |
} |
| 1203 |
} else { |
| 1204 |
// For "any item" mode: skip bookings for items not of the selected itemtype |
| 1205 |
if (!itemConflicts.has(bookingItemId)) { |
| 1206 |
return; |
| 1207 |
} |
| 1154 |
} |
1208 |
} |
| 1155 |
|
1209 |
|
| 1156 |
const bookingStart = dayjs( |
1210 |
const bookingStart = dayjs( |
|
Lines 1181-1186
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1181 |
|
1235 |
|
| 1182 |
// Check if new booking's LEAD period overlaps with existing booking |
1236 |
// Check if new booking's LEAD period overlaps with existing booking |
| 1183 |
if (!periodPicker.selectedDates[0]) { |
1237 |
if (!periodPicker.selectedDates[0]) { |
|
|
1238 |
let hasLeadConflict = false; |
| 1239 |
let reason = { |
| 1240 |
withTrail: false, |
| 1241 |
withLead: false, |
| 1242 |
withBooking: false, |
| 1243 |
}; |
| 1244 |
|
| 1184 |
// Check overlap with existing booking's trail period |
1245 |
// Check overlap with existing booking's trail period |
| 1185 |
if ( |
1246 |
if ( |
| 1186 |
leadStart.isSameOrBefore( |
1247 |
leadStart.isSameOrBefore( |
|
Lines 1190-1197
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1190 |
existingTrailStart |
1251 |
existingTrailStart |
| 1191 |
) |
1252 |
) |
| 1192 |
) { |
1253 |
) { |
| 1193 |
leadDisable = true; |
1254 |
hasLeadConflict = true; |
| 1194 |
leadConflictReason.withTrail = true; |
1255 |
reason.withTrail = true; |
| 1195 |
} |
1256 |
} |
| 1196 |
// Check overlap with existing booking's lead period |
1257 |
// Check overlap with existing booking's lead period |
| 1197 |
else if ( |
1258 |
else if ( |
|
Lines 1200-1220
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1200 |
) && |
1261 |
) && |
| 1201 |
leadEnd.isSameOrAfter(existingLeadStart) |
1262 |
leadEnd.isSameOrAfter(existingLeadStart) |
| 1202 |
) { |
1263 |
) { |
| 1203 |
leadDisable = true; |
1264 |
hasLeadConflict = true; |
| 1204 |
leadConflictReason.withLead = true; |
1265 |
reason.withLead = true; |
| 1205 |
} |
1266 |
} |
| 1206 |
// Check overlap with existing booking itself |
1267 |
// Check overlap with existing booking itself |
| 1207 |
else if ( |
1268 |
else if ( |
| 1208 |
leadStart.isSameOrBefore(bookingEnd) && |
1269 |
leadStart.isSameOrBefore(bookingEnd) && |
| 1209 |
leadEnd.isSameOrAfter(bookingStart) |
1270 |
leadEnd.isSameOrAfter(bookingStart) |
| 1210 |
) { |
1271 |
) { |
| 1211 |
leadDisable = true; |
1272 |
hasLeadConflict = true; |
| 1212 |
leadConflictReason.withBooking = true; |
1273 |
reason.withBooking = true; |
|
|
1274 |
} |
| 1275 |
|
| 1276 |
if (hasLeadConflict) { |
| 1277 |
if (isAnyItemMode) { |
| 1278 |
// Track conflict for this specific item |
| 1279 |
const itemState = |
| 1280 |
itemConflicts.get( |
| 1281 |
bookingItemId |
| 1282 |
); |
| 1283 |
if (itemState) { |
| 1284 |
itemState.leadConflict = true; |
| 1285 |
Object.assign( |
| 1286 |
itemState.leadReason, |
| 1287 |
reason |
| 1288 |
); |
| 1289 |
} |
| 1290 |
} else { |
| 1291 |
// Specific item mode: set global flags |
| 1292 |
leadDisable = true; |
| 1293 |
Object.assign( |
| 1294 |
leadConflictReason, |
| 1295 |
reason |
| 1296 |
); |
| 1297 |
} |
| 1213 |
} |
1298 |
} |
| 1214 |
} |
1299 |
} |
| 1215 |
|
1300 |
|
| 1216 |
// Check if new booking's TRAIL period overlaps with existing booking |
1301 |
// Check if new booking's TRAIL period overlaps with existing booking |
| 1217 |
if (periodPicker.selectedDates[0]) { |
1302 |
if (periodPicker.selectedDates[0]) { |
|
|
1303 |
let hasTrailConflict = false; |
| 1304 |
let reason = { |
| 1305 |
withTrail: false, |
| 1306 |
withLead: false, |
| 1307 |
withBooking: false, |
| 1308 |
}; |
| 1309 |
|
| 1218 |
// Check overlap with existing booking's lead period |
1310 |
// Check overlap with existing booking's lead period |
| 1219 |
if ( |
1311 |
if ( |
| 1220 |
trailStart.isSameOrBefore( |
1312 |
trailStart.isSameOrBefore( |
|
Lines 1224-1231
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1224 |
existingLeadStart |
1316 |
existingLeadStart |
| 1225 |
) |
1317 |
) |
| 1226 |
) { |
1318 |
) { |
| 1227 |
trailDisable = true; |
1319 |
hasTrailConflict = true; |
| 1228 |
trailConflictReason.withLead = true; |
1320 |
reason.withLead = true; |
| 1229 |
} |
1321 |
} |
| 1230 |
// Check overlap with existing booking's trail period |
1322 |
// Check overlap with existing booking's trail period |
| 1231 |
else if ( |
1323 |
else if ( |
|
Lines 1236-1255
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1236 |
existingTrailStart |
1328 |
existingTrailStart |
| 1237 |
) |
1329 |
) |
| 1238 |
) { |
1330 |
) { |
| 1239 |
trailDisable = true; |
1331 |
hasTrailConflict = true; |
| 1240 |
trailConflictReason.withTrail = true; |
1332 |
reason.withTrail = true; |
| 1241 |
} |
1333 |
} |
| 1242 |
// Check overlap with existing booking itself |
1334 |
// Check overlap with existing booking itself |
| 1243 |
else if ( |
1335 |
else if ( |
| 1244 |
trailStart.isSameOrBefore(bookingEnd) && |
1336 |
trailStart.isSameOrBefore(bookingEnd) && |
| 1245 |
trailEnd.isSameOrAfter(bookingStart) |
1337 |
trailEnd.isSameOrAfter(bookingStart) |
| 1246 |
) { |
1338 |
) { |
| 1247 |
trailDisable = true; |
1339 |
hasTrailConflict = true; |
| 1248 |
trailConflictReason.withBooking = true; |
1340 |
reason.withBooking = true; |
|
|
1341 |
} |
| 1342 |
|
| 1343 |
if (hasTrailConflict) { |
| 1344 |
if (isAnyItemMode) { |
| 1345 |
// Track conflict for this specific item |
| 1346 |
const itemState = |
| 1347 |
itemConflicts.get( |
| 1348 |
bookingItemId |
| 1349 |
); |
| 1350 |
if (itemState) { |
| 1351 |
itemState.trailConflict = true; |
| 1352 |
Object.assign( |
| 1353 |
itemState.trailReason, |
| 1354 |
reason |
| 1355 |
); |
| 1356 |
} |
| 1357 |
} else { |
| 1358 |
// Specific item mode: set global flags |
| 1359 |
trailDisable = true; |
| 1360 |
Object.assign( |
| 1361 |
trailConflictReason, |
| 1362 |
reason |
| 1363 |
); |
| 1364 |
} |
| 1249 |
} |
1365 |
} |
| 1250 |
} |
1366 |
} |
| 1251 |
|
1367 |
|
| 1252 |
// Find closest bookings for visual feedback (when dates are in view) |
1368 |
// Find closest bookings for visual feedback (when dates are in view) |
|
|
1369 |
// For "any item" mode, only track closest bookings for items of the selected type |
| 1253 |
if (bookingEnd.isBefore(hoverDate)) { |
1370 |
if (bookingEnd.isBefore(hoverDate)) { |
| 1254 |
const distance = hoverDate.diff( |
1371 |
const distance = hoverDate.diff( |
| 1255 |
bookingEnd, |
1372 |
bookingEnd, |
|
Lines 1279-1284
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1279 |
} |
1396 |
} |
| 1280 |
}); |
1397 |
}); |
| 1281 |
|
1398 |
|
|
|
1399 |
// For "any item" mode: only disable if ALL items have conflicts |
| 1400 |
if (isAnyItemMode && itemConflicts.size > 0) { |
| 1401 |
// Check if all items have lead conflicts |
| 1402 |
let allHaveLeadConflict = true; |
| 1403 |
let allHaveTrailConflict = true; |
| 1404 |
|
| 1405 |
for (const [ |
| 1406 |
itemId, |
| 1407 |
state, |
| 1408 |
] of itemConflicts.entries()) { |
| 1409 |
if (!state.leadConflict) { |
| 1410 |
allHaveLeadConflict = false; |
| 1411 |
} |
| 1412 |
if (!state.trailConflict) { |
| 1413 |
allHaveTrailConflict = false; |
| 1414 |
} |
| 1415 |
} |
| 1416 |
|
| 1417 |
if (allHaveLeadConflict) { |
| 1418 |
leadDisable = true; |
| 1419 |
// Use the reason from the first item with a conflict for messaging |
| 1420 |
for (const [ |
| 1421 |
itemId, |
| 1422 |
state, |
| 1423 |
] of itemConflicts.entries()) { |
| 1424 |
if (state.leadConflict) { |
| 1425 |
Object.assign( |
| 1426 |
leadConflictReason, |
| 1427 |
state.leadReason |
| 1428 |
); |
| 1429 |
break; |
| 1430 |
} |
| 1431 |
} |
| 1432 |
} |
| 1433 |
|
| 1434 |
if (allHaveTrailConflict) { |
| 1435 |
trailDisable = true; |
| 1436 |
// Use the reason from the first item with a conflict for messaging |
| 1437 |
for (const [ |
| 1438 |
itemId, |
| 1439 |
state, |
| 1440 |
] of itemConflicts.entries()) { |
| 1441 |
if (state.trailConflict) { |
| 1442 |
Object.assign( |
| 1443 |
trailConflictReason, |
| 1444 |
state.trailReason |
| 1445 |
); |
| 1446 |
break; |
| 1447 |
} |
| 1448 |
} |
| 1449 |
} |
| 1450 |
} |
| 1451 |
|
| 1452 |
// For "any item" mode, find closest "all items booked" dates mathematically |
| 1453 |
// These are dates where ALL items of the itemtype have bookings |
| 1454 |
// Using mathematical search allows detection across month boundaries |
| 1455 |
let closestFullyBookedBefore = null; |
| 1456 |
let closestFullyBookedAfter = null; |
| 1457 |
|
| 1458 |
if ( |
| 1459 |
isAnyItemMode && |
| 1460 |
itemsOfSelectedType.length > 0 |
| 1461 |
) { |
| 1462 |
const searchLimit = 180; // Days to search in each direction |
| 1463 |
|
| 1464 |
// Search backwards for closest fully-booked date |
| 1465 |
for (let i = 1; i <= searchLimit; i++) { |
| 1466 |
const checkDate = hoverDate.subtract( |
| 1467 |
i, |
| 1468 |
"day" |
| 1469 |
); |
| 1470 |
const availableItems = |
| 1471 |
getAvailableItemsOnDate( |
| 1472 |
checkDate.toDate(), |
| 1473 |
itemsOfSelectedType |
| 1474 |
); |
| 1475 |
if (availableItems.length === 0) { |
| 1476 |
closestFullyBookedBefore = checkDate; |
| 1477 |
break; |
| 1478 |
} |
| 1479 |
} |
| 1480 |
|
| 1481 |
// Search forwards for closest fully-booked date |
| 1482 |
for (let i = 1; i <= searchLimit; i++) { |
| 1483 |
const checkDate = hoverDate.add(i, "day"); |
| 1484 |
const availableItems = |
| 1485 |
getAvailableItemsOnDate( |
| 1486 |
checkDate.toDate(), |
| 1487 |
itemsOfSelectedType |
| 1488 |
); |
| 1489 |
if (availableItems.length === 0) { |
| 1490 |
closestFullyBookedAfter = checkDate; |
| 1491 |
break; |
| 1492 |
} |
| 1493 |
} |
| 1494 |
} |
| 1495 |
|
| 1282 |
// Work through all days in view and add classes appropraitely based on hovered date |
1496 |
// Work through all days in view and add classes appropraitely based on hovered date |
| 1283 |
periodPicker.calendarContainer |
1497 |
periodPicker.calendarContainer |
| 1284 |
.querySelectorAll(".flatpickr-day") |
1498 |
.querySelectorAll(".flatpickr-day") |
|
Lines 1346-1363
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1346 |
); |
1560 |
); |
| 1347 |
} |
1561 |
} |
| 1348 |
|
1562 |
|
| 1349 |
// Show closest preceding existing booking's trail period |
1563 |
// Show closest preceding booking's trail period |
| 1350 |
if (closestBeforeBooking && trailDays > 0) { |
1564 |
// For "any item" mode, use closest fully-booked date; for specific item, use closest booking |
|
|
1565 |
const useClosestFullyBookedForTrail = |
| 1566 |
isAnyItemMode && |
| 1567 |
closestFullyBookedBefore; |
| 1568 |
const useClosestBookingForTrail = |
| 1569 |
!isAnyItemMode && closestBeforeBooking; |
| 1570 |
|
| 1571 |
if ( |
| 1572 |
trailDays > 0 && |
| 1573 |
(useClosestFullyBookedForTrail || |
| 1574 |
useClosestBookingForTrail) |
| 1575 |
) { |
| 1351 |
const existingTrailStart = |
1576 |
const existingTrailStart = |
| 1352 |
closestBeforeBooking.end.add( |
1577 |
useClosestFullyBookedForTrail |
| 1353 |
1, |
1578 |
? closestFullyBookedBefore.add( |
| 1354 |
"day" |
1579 |
1, |
| 1355 |
); |
1580 |
"day" |
|
|
1581 |
) |
| 1582 |
: closestBeforeBooking.end.add( |
| 1583 |
1, |
| 1584 |
"day" |
| 1585 |
); |
| 1356 |
const existingTrailEnd = |
1586 |
const existingTrailEnd = |
| 1357 |
closestBeforeBooking.end.add( |
1587 |
useClosestFullyBookedForTrail |
| 1358 |
trailDays, |
1588 |
? closestFullyBookedBefore.add( |
| 1359 |
"day" |
1589 |
trailDays, |
| 1360 |
); |
1590 |
"day" |
|
|
1591 |
) |
| 1592 |
: closestBeforeBooking.end.add( |
| 1593 |
trailDays, |
| 1594 |
"day" |
| 1595 |
); |
| 1361 |
|
1596 |
|
| 1362 |
if ( |
1597 |
if ( |
| 1363 |
elemDate.isSameOrAfter( |
1598 |
elemDate.isSameOrAfter( |
|
Lines 1392-1409
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1392 |
} |
1627 |
} |
| 1393 |
} |
1628 |
} |
| 1394 |
|
1629 |
|
| 1395 |
// Show closest following existing booking's lead period |
1630 |
// Show closest following booking's lead period |
| 1396 |
if (closestAfterBooking && leadDays > 0) { |
1631 |
// For "any item" mode, use closest fully-booked date; for specific item, use closest booking |
|
|
1632 |
const useClosestFullyBookedForLead = |
| 1633 |
isAnyItemMode && |
| 1634 |
closestFullyBookedAfter; |
| 1635 |
const useClosestBookingForLead = |
| 1636 |
!isAnyItemMode && closestAfterBooking; |
| 1637 |
|
| 1638 |
if ( |
| 1639 |
leadDays > 0 && |
| 1640 |
(useClosestFullyBookedForLead || |
| 1641 |
useClosestBookingForLead) |
| 1642 |
) { |
| 1397 |
const existingLeadStart = |
1643 |
const existingLeadStart = |
| 1398 |
closestAfterBooking.start.subtract( |
1644 |
useClosestFullyBookedForLead |
| 1399 |
leadDays, |
1645 |
? closestFullyBookedAfter.subtract( |
| 1400 |
"day" |
1646 |
leadDays, |
| 1401 |
); |
1647 |
"day" |
|
|
1648 |
) |
| 1649 |
: closestAfterBooking.start.subtract( |
| 1650 |
leadDays, |
| 1651 |
"day" |
| 1652 |
); |
| 1402 |
const existingLeadEnd = |
1653 |
const existingLeadEnd = |
| 1403 |
closestAfterBooking.start.subtract( |
1654 |
useClosestFullyBookedForLead |
| 1404 |
1, |
1655 |
? closestFullyBookedAfter.subtract( |
| 1405 |
"day" |
1656 |
1, |
| 1406 |
); |
1657 |
"day" |
|
|
1658 |
) |
| 1659 |
: closestAfterBooking.start.subtract( |
| 1660 |
1, |
| 1661 |
"day" |
| 1662 |
); |
| 1407 |
|
1663 |
|
| 1408 |
if ( |
1664 |
if ( |
| 1409 |
elemDate.isSameOrAfter( |
1665 |
elemDate.isSameOrAfter( |
|
Lines 1475-1480
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1475 |
} |
1731 |
} |
| 1476 |
|
1732 |
|
| 1477 |
// Check for conflicts with existing booking's trail period |
1733 |
// Check for conflicts with existing booking's trail period |
|
|
1734 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1478 |
if ( |
1735 |
if ( |
| 1479 |
!periodPicker.selectedDates[0] && |
1736 |
!periodPicker.selectedDates[0] && |
| 1480 |
dayElem.classList.contains( |
1737 |
dayElem.classList.contains( |
|
Lines 1491-1496
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1491 |
} |
1748 |
} |
| 1492 |
|
1749 |
|
| 1493 |
// Check for conflicts with existing booking's lead period |
1750 |
// Check for conflicts with existing booking's lead period |
|
|
1751 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1494 |
if ( |
1752 |
if ( |
| 1495 |
periodPicker.selectedDates[0] && |
1753 |
periodPicker.selectedDates[0] && |
| 1496 |
dayElem.classList.contains( |
1754 |
dayElem.classList.contains( |
|
Lines 1518-1523
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1518 |
}); |
1776 |
}); |
| 1519 |
|
1777 |
|
| 1520 |
// Additional check for hovering directly on existing booking's lead/trail periods |
1778 |
// Additional check for hovering directly on existing booking's lead/trail periods |
|
|
1779 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1521 |
// If hovering on an existing booking's lead period when selecting start date, block selection |
1780 |
// If hovering on an existing booking's lead period when selecting start date, block selection |
| 1522 |
if ( |
1781 |
if ( |
| 1523 |
!periodPicker.selectedDates[0] && |
1782 |
!periodPicker.selectedDates[0] && |
|
Lines 1527-1532
$("#placeBookingModal").on("show.bs.modal", function (e) {
Link Here
|
| 1527 |
} |
1786 |
} |
| 1528 |
|
1787 |
|
| 1529 |
// If hovering on an existing booking's trail period when selecting end date, block selection |
1788 |
// If hovering on an existing booking's trail period when selecting end date, block selection |
|
|
1789 |
// In "any item" mode, these classes now represent "all items booked" periods |
| 1530 |
if ( |
1790 |
if ( |
| 1531 |
periodPicker.selectedDates[0] && |
1791 |
periodPicker.selectedDates[0] && |
| 1532 |
target.classList.contains( |
1792 |
target.classList.contains( |