diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 7676c3aa4..2146c6834 100644 --- a/app/Controllers/PrestitiController.php +++ b/app/Controllers/PrestitiController.php @@ -767,6 +767,28 @@ public function update(Request $request, Response $response, mysqli $db, int $id return $response->withHeader('Location', url('/admin/loans') . '?error=loan_update_failed')->withStatus(302); } + // #281: LoanRepository::update() deliberately never touches lifecycle + // columns, and the in_corso -> in_ritardo transition set by the + // maintenance/integrity jobs is one-way — nothing reverts it. So + // extending an overdue loan's due date left stato='in_ritardo' stale + // and the loan kept showing "Overdue". Recompute stato here against + // the new due date (app timezone, same clock as MaintenanceService), + // scoped to the physically-out states so 'prenotato'/'da_ritirare' + // are never affected. When the loan returns to in_corso (extended + // into the future) also clear the reminder flags so the new window + // triggers fresh warning/overdue emails. + $today = \App\Support\DateHelper::today(); + $recalcStato = $db->prepare( + "UPDATE prestiti + SET stato = CASE WHEN data_scadenza < ? THEN 'in_ritardo' ELSE 'in_corso' END, + warning_sent = CASE WHEN data_scadenza < ? THEN warning_sent ELSE 0 END, + overdue_notification_sent = CASE WHEN data_scadenza < ? THEN overdue_notification_sent ELSE 0 END + WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')" + ); + $recalcStato->bind_param('sssi', $today, $today, $today, $id); + $recalcStato->execute(); + $recalcStato->close(); + // Ricalcola la disponibilità (M6c): spostare le date di un 'prenotato' // attraverso oggi cambia l'occupazione corrente e lascerebbe // copie_disponibili/libri.stato stantii fino al prossimo evento. @@ -1248,6 +1270,171 @@ public function downloadPdf(Request $request, Response $response, mysqli $db, in } } + /** + * #281: bulk-extend the due date of several active out-loans at once. + * Deliberately mirrors the manual Edit-Loan semantics — NOT renew(), which + * refuses overdue loans and enforces the renewal limit. Uwe's request is + * precisely to extend loans that are already overdue, so each selected loan + * that is active and physically out (in_corso / in_ritardo) has its + * data_scadenza pushed forward by N days and its stato recomputed against + * the new date; an overdue loan extended into the future stops showing + * "Overdue" and its reminder flags reset so the new window notifies afresh. + */ + public function bulkExtend(Request $request, Response $response, mysqli $db): Response + { + if ($guard = $this->guardStaffAccess($response)) { + return $guard; + } + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware. + + $ids = array_values(array_unique(array_filter( + array_map('intval', (array) ($data['ids'] ?? [])), + static fn (int $i): bool => $i > 0 + ))); + $days = (int) ($data['days'] ?? 0); + + $backUrl = url('/admin/loans'); + if ($ids === [] || $days < 1 || $days > 365) { + return $response->withHeader('Location', $backUrl . '?error=bulk_extend_invalid')->withStatus(302); + } + + $today = \App\Support\DateHelper::today(); + $db->begin_transaction(); + try { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + + // Discover the books first without taking loan locks, then lock every + // book in deterministic order. This preserves the application-wide + // canonical lock order (libri -> prestiti) and serializes capacity + // decisions with loan/reservation/copy mutations for the same title. + $bookScan = $db->prepare("SELECT id, libro_id FROM prestiti WHERE id IN ($placeholders)"); + $bookScan->bind_param(str_repeat('i', count($ids)), ...$ids); + $bookScan->execute(); + $bookResult = $bookScan->get_result(); + $expectedBookByLoan = []; + $bookIds = []; + while ($row = $bookResult->fetch_assoc()) { + $loanId = (int) $row['id']; + $bookId = (int) $row['libro_id']; + $expectedBookByLoan[$loanId] = $bookId; + $bookIds[$bookId] = $bookId; + } + $bookScan->close(); + sort($bookIds, SORT_NUMERIC); + + $lockBook = $db->prepare('SELECT id FROM libri WHERE id = ? FOR UPDATE'); + foreach ($bookIds as $bookId) { + $lockBook->bind_param('i', $bookId); + $lockBook->execute(); + $lockBook->get_result(); + } + $lockBook->close(); + + // Re-read and lock the eligible rows after their books are locked. + // Rows closed or moved out of an extendable state meanwhile are + // intentionally ignored, matching the previous bulk semantics. + $lockLoans = $db->prepare( + "SELECT id, libro_id, copia_id, data_prestito, data_scadenza + FROM prestiti + WHERE id IN ($placeholders) + AND attivo = 1 + AND stato IN ('in_corso', 'in_ritardo') + ORDER BY id + FOR UPDATE" + ); + $lockLoans->bind_param(str_repeat('i', count($ids)), ...$ids); + $lockLoans->execute(); + $loanResult = $lockLoans->get_result(); + $loans = []; + while ($row = $loanResult->fetch_assoc()) { + $loans[] = $row; + } + $lockLoans->close(); + + $capacity = new \App\Services\CapacityService($db); + $copyOverlap = $db->prepare( + "SELECT 1 FROM prestiti + WHERE copia_id = ? AND id <> ? + AND data_prestito <= ? + AND (stato = 'in_ritardo' OR data_scadenza >= ?) + AND ((attivo = 1 AND stato IN ('prenotato','da_ritirare','in_corso','in_ritardo')) + OR (attivo = 0 AND stato = 'pendente' AND copia_id IS NOT NULL)) + LIMIT 1" + ); + $update = $db->prepare( + "UPDATE prestiti + SET data_scadenza = ?, + stato = CASE WHEN ? < ? THEN 'in_ritardo' ELSE 'in_corso' END, + warning_sent = CASE WHEN ? < ? THEN warning_sent ELSE 0 END, + overdue_notification_sent = CASE WHEN ? < ? THEN overdue_notification_sent ELSE 0 END + WHERE id = ? AND attivo = 1 AND stato IN ('in_corso', 'in_ritardo')" + ); + + $extended = 0; + foreach ($loans as $loan) { + $loanId = (int) $loan['id']; + $bookId = (int) $loan['libro_id']; + if (($expectedBookByLoan[$loanId] ?? null) !== $bookId) { + throw new \RuntimeException('Loan book changed during bulk extension'); + } + + $dueDate = \DateTimeImmutable::createFromFormat('!Y-m-d', (string) $loan['data_scadenza']); + if ($dueDate === false) { + throw new \RuntimeException('Invalid due date during bulk extension'); + } + $newDueDate = $dueDate->modify('+' . $days . ' days')->format('Y-m-d'); + $loanStart = (string) $loan['data_prestito']; + + // Apply each accepted extension immediately inside this transaction: + // the next capacity check therefore sees all earlier proposed + // extensions too. A single conflict rolls the entire batch back. + if (!$capacity->hasFreeCapacity($bookId, $loanStart, $newDueDate, excludePrestitoId: $loanId)) { + $copyOverlap->close(); + $update->close(); + $db->rollback(); + return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302); + } + + $copyId = $loan['copia_id'] !== null ? (int) $loan['copia_id'] : null; + if ($copyId !== null) { + $copyOverlap->bind_param('iiss', $copyId, $loanId, $newDueDate, $loanStart); + $copyOverlap->execute(); + $hasCopyOverlap = (bool) $copyOverlap->get_result()->fetch_row(); + if ($hasCopyOverlap) { + $copyOverlap->close(); + $update->close(); + $db->rollback(); + return $response->withHeader('Location', $backUrl . '?error=bulk_extend_conflict')->withStatus(302); + } + } + + $update->bind_param( + 'sssssssi', + $newDueDate, + $newDueDate, + $today, + $newDueDate, + $today, + $newDueDate, + $today, + $loanId + ); + $update->execute(); + $extended += max(0, $update->affected_rows); + } + $copyOverlap->close(); + $update->close(); + $db->commit(); + } catch (\Throwable $e) { + $db->rollback(); + SecureLogger::error('Bulk loan extend failed: ' . $e->getMessage()); + return $response->withHeader('Location', $backUrl . '?error=bulk_extend_failed')->withStatus(302); + } + + return $response->withHeader('Location', $backUrl . '?bulk_extended=' . (int) $extended . '&days=' . $days)->withStatus(302); + } + public function renew(Request $request, Response $response, mysqli $db, int $id): Response { if ($guard = $this->guardStaffAccess($response)) { diff --git a/app/Routes/web.php b/app/Routes/web.php index e3ed5d4ed..c33e9e699 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1860,6 +1860,15 @@ $db = $app->getContainer()->get('db'); return $controller->renew($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // #281: extend the due date of several selected active loans at once. + $app->post('/admin/loans/bulk-extend', function ($request, $response) use ($app) { + if (\App\Support\ConfigStore::isCatalogueMode()) { + return $response->withHeader('Location', '/admin/dashboard')->withStatus(302); + } + $controller = new PrestitiController(); + $db = $app->getContainer()->get('db'); + return $controller->bulkExtend($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); // Return a loan by scanning/typing the physical copy code (numero_inventario) $app->post('/admin/loans/return-by-code', function ($request, $response) use ($app) { if (\App\Support\ConfigStore::isCatalogueMode()) { diff --git a/app/Views/prestiti/crea_prestito.php b/app/Views/prestiti/crea_prestito.php index 01ef94de9..964aef56b 100644 --- a/app/Views/prestiti/crea_prestito.php +++ b/app/Views/prestiti/crea_prestito.php @@ -396,9 +396,10 @@ function updateConsegnaImmediataVisibility(dateStr) { // Initialize visibility on page load updateConsegnaImmediataVisibility(dataPrestitoEl.value); - // Get locale for flatpickr - const appLocale = document.documentElement.lang?.startsWith('it') ? 'it' : 'en'; - const isItalian = appLocale === 'it'; + // Get locale for flatpickr (all shipped UI languages, not just it/en — #281) + const _lang = (document.documentElement.lang || '').slice(0, 2).toLowerCase(); + const appLocale = ['it', 'en', 'de', 'fr', 'da'].indexOf(_lang) !== -1 ? _lang : 'en'; + const isItalian = appLocale !== 'en'; // day-first display for every European language const localeObj = window.flatpickrLocales ? window.flatpickrLocales[appLocale] : null; // Initialize flatpickr for data_prestito diff --git a/app/Views/prestiti/index.php b/app/Views/prestiti/index.php index e7c5b4537..72b25bd30 100644 --- a/app/Views/prestiti/index.php +++ b/app/Views/prestiti/index.php @@ -321,6 +321,9 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te
| + + | = __('Libro') ?> | = __('Utente') ?> | = __('Date') ?> | @@ -333,14 +336,18 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te|||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + |
= __("Nessun prestito trovato.") ?> |
|||||||||||||
| + + |
= __("ID Prestito:") ?>
@@ -396,6 +403,32 @@ class="inline-flex items-center px-3 py-1.5 bg-red-600 hover:bg-red-500 text-whi
+
+
+
+
+
+
+
+
+
+
+ = __('giorni') ?>
+
+
+
+ | |||||||||||||