View | Details | Raw Unified | Return to bug 37707
Collapse All | Expand All

(-)a/koha-tmpl/intranet-tmpl/prog/js/modals/place_booking.js (-39 / +299 lines)
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(
(-)a/t/cypress/integration/Circulation/bookingsModalBasic_spec.ts (-1 / +315 lines)
Lines 1485-1488 describe("Booking Modal Basic Tests", () => { Link Here
1485
            }
1485
            }
1486
        });
1486
        });
1487
    });
1487
    });
1488
1489
    it("should correctly handle lead/trail period conflicts for 'any item' bookings", () => {
1490
        /**
1491
         * Bug 37707: Lead/Trail Period Conflict Detection for "Any Item" Bookings
1492
         * ========================================================================
1493
         *
1494
         * This test validates that lead/trail period conflict detection works correctly
1495
         * when "any item of itemtype X" is selected. The key principle is:
1496
         *
1497
         * - Only block date selection when ALL items of the itemtype have conflicts
1498
         * - Allow selection when at least one item is free from lead/trail conflicts
1499
         *
1500
         * The bug occurred because the mouseover handler was checking conflicts against
1501
         * ALL bookings regardless of itemtype, rather than tracking per-item conflicts.
1502
         *
1503
         * Test Setup:
1504
         * ===========
1505
         * - 3 items of itemtype BK
1506
         * - Lead period: 2 days, Trail period: 2 days
1507
         * - ITEM 0: Booking on days 10-12 (trail period: 13-14)
1508
         * - ITEM 1: Booking on days 10-12 (same as item 0)
1509
         * - ITEM 2: No bookings (always available)
1510
         *
1511
         * Test Scenarios:
1512
         * ==============
1513
         * 1. Hover day 15: ITEM 0 and ITEM 1 have trail period conflict (lead period
1514
         *    June 13-14 overlaps their trail June 13-14), but ITEM 2 is free
1515
         *    → Should NOT be blocked (at least one item available)
1516
         *
1517
         * 2. Create booking on ITEM 2 for days 10-12, then hover day 15 again:
1518
         *    → ALL items now have trail period conflicts
1519
         *    → Should BE blocked
1520
         */
1521
1522
        const today = dayjs();
1523
        let testItems = [];
1524
        let testBiblio = null;
1525
        let testPatron = null;
1526
        let testLibraries = null;
1527
1528
        // Circulation rules with non-zero lead/trail periods
1529
        const circulationRules = {
1530
            bookings_lead_period: 2,
1531
            bookings_trail_period: 2,
1532
            issuelength: 14,
1533
            renewalsallowed: 2,
1534
            renewalperiod: 7,
1535
        };
1536
1537
        // Setup: Create biblio with 3 items of the same itemtype
1538
        cy.task("insertSampleBiblio", { item_count: 3 })
1539
            .then(objects => {
1540
                testBiblio = objects.biblio;
1541
                testItems = objects.items;
1542
                testLibraries = objects.libraries;
1543
1544
                // Make all items the same itemtype (BK)
1545
                const itemUpdates = testItems.map((item, index) => {
1546
                    const enumchron = String.fromCharCode(65 + index);
1547
                    return cy.task("query", {
1548
                        sql: "UPDATE items SET bookable = 1, itype = 'BK', homebranch = 'CPL', enumchron = ?, dateaccessioned = ? WHERE itemnumber = ?",
1549
                        values: [
1550
                            enumchron,
1551
                            `2024-12-0${4 - index}`,
1552
                            item.item_id,
1553
                        ],
1554
                    });
1555
                });
1556
                return Promise.all(itemUpdates);
1557
            })
1558
            .then(() => {
1559
                return cy.task("buildSampleObject", {
1560
                    object: "patron",
1561
                    values: {
1562
                        firstname: "LeadTrail",
1563
                        surname: "Tester",
1564
                        cardnumber: `LT${Date.now()}`,
1565
                        category_id: "PT",
1566
                        library_id: "CPL",
1567
                    },
1568
                });
1569
            })
1570
            .then(mockPatron => {
1571
                testPatron = mockPatron;
1572
                return cy.task("query", {
1573
                    sql: `INSERT INTO borrowers (borrowernumber, firstname, surname, cardnumber, categorycode, branchcode, dateofbirth)
1574
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1575
                    values: [
1576
                        mockPatron.patron_id,
1577
                        mockPatron.firstname,
1578
                        mockPatron.surname,
1579
                        mockPatron.cardnumber,
1580
                        mockPatron.category_id,
1581
                        mockPatron.library_id,
1582
                        "1990-01-01",
1583
                    ],
1584
                });
1585
            })
1586
            .then(() => {
1587
                // Create bookings on ITEM 0 and ITEM 1 for days 10-12
1588
                // ITEM 2 remains free
1589
                const bookingInserts = [
1590
                    // ITEM 0: Booked days 10-12
1591
                    cy.task("query", {
1592
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1593
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1594
                        values: [
1595
                            testBiblio.biblio_id,
1596
                            testPatron.patron_id,
1597
                            testItems[0].item_id,
1598
                            "CPL",
1599
                            today.add(10, "day").format("YYYY-MM-DD"),
1600
                            today.add(12, "day").format("YYYY-MM-DD"),
1601
                            "new",
1602
                        ],
1603
                    }),
1604
                    // ITEM 1: Booked days 10-12 (same period)
1605
                    cy.task("query", {
1606
                        sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1607
                              VALUES (?, ?, ?, ?, ?, ?, ?)`,
1608
                        values: [
1609
                            testBiblio.biblio_id,
1610
                            testPatron.patron_id,
1611
                            testItems[1].item_id,
1612
                            "CPL",
1613
                            today.add(10, "day").format("YYYY-MM-DD"),
1614
                            today.add(12, "day").format("YYYY-MM-DD"),
1615
                            "new",
1616
                        ],
1617
                    }),
1618
                    // ITEM 2: No booking - remains free
1619
                ];
1620
                return Promise.all(bookingInserts);
1621
            })
1622
            .then(() => {
1623
                cy.intercept(
1624
                    "GET",
1625
                    `/api/v1/biblios/${testBiblio.biblio_id}/pickup_locations*`
1626
                ).as("getPickupLocations");
1627
                cy.intercept("GET", "/api/v1/circulation_rules*", {
1628
                    body: [circulationRules],
1629
                }).as("getCirculationRules");
1630
1631
                cy.visit(
1632
                    `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1633
                );
1634
1635
                cy.get('[data-bs-target="#placeBookingModal"]').first().click();
1636
                cy.get("#placeBookingModal").should("be.visible");
1637
1638
                cy.selectFromSelect2(
1639
                    "#booking_patron_id",
1640
                    `${testPatron.surname}, ${testPatron.firstname}`,
1641
                    testPatron.cardnumber
1642
                );
1643
                cy.wait("@getPickupLocations");
1644
1645
                cy.get("#pickup_library_id").should("not.be.disabled");
1646
                cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1647
1648
                // Select itemtype BK
1649
                cy.get("#booking_itemtype").should("not.be.disabled");
1650
                cy.selectFromSelect2("#booking_itemtype", "Books");
1651
                cy.wait("@getCirculationRules");
1652
1653
                // Select "Any item" (index 0)
1654
                cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1655
                cy.get("#booking_item_id").should("have.value", "0");
1656
1657
                cy.get("#period").should("not.be.disabled");
1658
                cy.get("#period").as("flatpickrInput");
1659
1660
                // ================================================================
1661
                // SCENARIO 1: Hover day 15 - ITEM 2 is free, should NOT be blocked
1662
                // ================================================================
1663
                cy.log(
1664
                    "=== Scenario 1: Day 15 should be selectable (ITEM 2 is free) ==="
1665
                );
1666
1667
                /**
1668
                 * Day 15 as start date:
1669
                 * - Lead period: days 13-14
1670
                 * - ITEM 0's trail period: days 13-14 (booking ended day 12, trail = 2 days)
1671
                 * - ITEM 1's trail period: days 13-14 (same)
1672
                 * - ITEM 2: No booking, no trail period conflict
1673
                 *
1674
                 * The new booking's lead period (13-14) overlaps with ITEM 0 and ITEM 1's
1675
                 * trail period, but ITEM 2 has no conflict.
1676
                 *
1677
                 * With the bug, this would be blocked because ANY booking conflicted.
1678
                 * With the fix, this should be allowed because ITEM 2 is available.
1679
                 */
1680
1681
                cy.get("@flatpickrInput").openFlatpickr();
1682
                cy.get("@flatpickrInput")
1683
                    .getFlatpickrDate(today.add(15, "day").toDate())
1684
                    .trigger("mouseover");
1685
1686
                // Day 15 should NOT have leadDisable class (at least one item is free)
1687
                cy.get("@flatpickrInput")
1688
                    .getFlatpickrDate(today.add(15, "day").toDate())
1689
                    .should("not.have.class", "leadDisable");
1690
1691
                cy.log(
1692
                    "✓ Day 15 is selectable - lead period conflict detection correctly allows selection when one item is free"
1693
                );
1694
1695
                // Actually click day 15 to verify it's selectable
1696
                cy.get("@flatpickrInput")
1697
                    .getFlatpickrDate(today.add(15, "day").toDate())
1698
                    .should("not.have.class", "flatpickr-disabled")
1699
                    .click();
1700
1701
                // Verify day 15 was selected as start date
1702
                cy.get("@flatpickrInput")
1703
                    .getFlatpickrDate(today.add(15, "day").toDate())
1704
                    .should("have.class", "selected");
1705
1706
                cy.log(
1707
                    "✓ CONFIRMED: Day 15 successfully selected as start date"
1708
                );
1709
1710
                // Reset for next scenario
1711
                cy.get("@flatpickrInput").clearFlatpickr();
1712
1713
                // ================================================================
1714
                // SCENARIO 2: Add booking on ITEM 2 - ALL items now have conflicts
1715
                // ================================================================
1716
                cy.log(
1717
                    "=== Scenario 2: Day 15 should be BLOCKED when all items have conflicts ==="
1718
                );
1719
1720
                // Add booking on ITEM 2 for same period (days 10-12)
1721
                cy.task("query", {
1722
                    sql: `INSERT INTO bookings (biblio_id, patron_id, item_id, pickup_library_id, start_date, end_date, status)
1723
                          VALUES (?, ?, ?, ?, ?, ?, ?)`,
1724
                    values: [
1725
                        testBiblio.biblio_id,
1726
                        testPatron.patron_id,
1727
                        testItems[2].item_id,
1728
                        "CPL",
1729
                        today.add(10, "day").format("YYYY-MM-DD"),
1730
                        today.add(12, "day").format("YYYY-MM-DD"),
1731
                        "new",
1732
                    ],
1733
                }).then(() => {
1734
                    // Reload page to get updated booking data
1735
                    cy.visit(
1736
                        `/cgi-bin/koha/catalogue/detail.pl?biblionumber=${testBiblio.biblio_id}`
1737
                    );
1738
1739
                    cy.get('[data-bs-target="#placeBookingModal"]')
1740
                        .first()
1741
                        .click();
1742
                    cy.get("#placeBookingModal").should("be.visible");
1743
1744
                    cy.selectFromSelect2(
1745
                        "#booking_patron_id",
1746
                        `${testPatron.surname}, ${testPatron.firstname}`,
1747
                        testPatron.cardnumber
1748
                    );
1749
                    cy.wait("@getPickupLocations");
1750
1751
                    cy.get("#pickup_library_id").should("not.be.disabled");
1752
                    cy.selectFromSelect2ByIndex("#pickup_library_id", 0);
1753
1754
                    // Select itemtype BK
1755
                    cy.get("#booking_itemtype").should("not.be.disabled");
1756
                    cy.selectFromSelect2("#booking_itemtype", "Books");
1757
                    cy.wait("@getCirculationRules");
1758
1759
                    // Select "Any item" (index 0)
1760
                    cy.selectFromSelect2ByIndex("#booking_item_id", 0);
1761
                    cy.get("#booking_item_id").should("have.value", "0");
1762
1763
                    cy.get("#period").should("not.be.disabled");
1764
                    cy.get("#period").as("flatpickrInput2");
1765
1766
                    cy.get("@flatpickrInput2").openFlatpickr();
1767
                    cy.get("@flatpickrInput2")
1768
                        .getFlatpickrDate(today.add(15, "day").toDate())
1769
                        .trigger("mouseover");
1770
1771
                    // Day 15 should NOW have leadDisable class (all items have conflicts)
1772
                    cy.get("@flatpickrInput2")
1773
                        .getFlatpickrDate(today.add(15, "day").toDate())
1774
                        .should("have.class", "leadDisable");
1775
1776
                    cy.log(
1777
                        "✓ Day 15 is BLOCKED - all items have lead period conflicts"
1778
                    );
1779
                });
1780
            });
1781
1782
        // Cleanup
1783
        cy.then(() => {
1784
            if (testBiblio) {
1785
                cy.task("query", {
1786
                    sql: "DELETE FROM bookings WHERE biblio_id = ?",
1787
                    values: [testBiblio.biblio_id],
1788
                });
1789
                cy.task("deleteSampleObjects", {
1790
                    biblio: testBiblio,
1791
                    items: testItems,
1792
                    libraries: testLibraries,
1793
                });
1794
            }
1795
            if (testPatron) {
1796
                cy.task("query", {
1797
                    sql: "DELETE FROM borrowers WHERE borrowernumber = ?",
1798
                    values: [testPatron.patron_id],
1799
                });
1800
            }
1801
        });
1802
    });
1488
});
1803
});
1489
- 

Return to bug 37707