From de2c727c0c88f0e9fb982268c5e409599ef5af75 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 22 Jul 2026 18:48:36 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(#281):=20loan-extension=20handling=20?= =?UTF-8?q?=E2=80=94=20status=20recompute,=20localized=20date=20picker,=20?= =?UTF-8?q?bulk=20extend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses HansUwe52's loan-management report (#281) in three parts. 1. Overdue status not cleared after extending the due date. prestiti.stato is a stored enum, transitioned in_corso -> in_ritardo one-way by the maintenance/integrity jobs and never reverted, while the Edit-Loan save (LoanRepository::update) deliberately never touches lifecycle columns. So extending an overdue loan's due date left stato='in_ritardo' and it kept showing "Overdue". PrestitiController::update() now recomputes stato against the new due date (in_ritardo if past, else in_corso), scoped to the physically-out states so prenotato/da_ritirare are untouched, using the app timezone (DateHelper::today, same clock as MaintenanceService). Returning to in_corso also resets the reminder flags so the new window notifies afresh. 2. Date picker always Italian for non-English UI languages. flatpickr-init.js detectAppLocale() only recognized it/en and fell through to Italian, so German/French/Danish installs got an Italian calendar. vendor.js now bundles the de/fr/da flatpickr l10n and registers them; detectAppLocale() maps every shipped UI language and defaults to neutral English; date display is day-first for the European languages. The create-loan inline picker got the same fix. vendor.bundle.js rebuilt. 3. Bulk loan extension. New PrestitiController::bulkExtend() + POST /admin/loans/bulk-extend: extend the due date of several selected active out-loans by N days at once, recomputing stato per (1). Deliberately mirrors the manual Edit-Loan semantics (not renew(), which refuses overdue loans) since the point is extending overdue loans. The loans list gains a per-row checkbox (only for extendable loans), select-all, and a bulk bar; selection persists across DataTables page draws. New i18n keys added to all four locales. Verified: loan-extension-281.unit.php (12/12 — single + bulk recompute, scoping, reminder reset), a real browser bulk-extend against the running app (loan extended +7 days, status recomputed, success flash), PHPStan clean. --- app/Controllers/PrestitiController.php | 83 +++++++++++++ app/Routes/web.php | 9 ++ app/Views/prestiti/crea_prestito.php | 7 +- app/Views/prestiti/index.php | 163 ++++++++++++++++++++++++- frontend/js/flatpickr-init.js | 39 +++--- frontend/js/vendor.js | 10 +- locale/de_DE.json | 14 ++- locale/en_US.json | 14 ++- locale/fr_FR.json | 14 ++- locale/it_IT.json | 14 ++- public/assets/661.bundle.js | 2 +- public/assets/flatpickr-init.js | 39 +++--- public/assets/main.css | 12 ++ public/assets/vendor.bundle.js | 2 +- tests/loan-extension-281.unit.php | 156 +++++++++++++++++++++++ 15 files changed, 524 insertions(+), 54 deletions(-) create mode 100644 tests/loan-extension-281.unit.php diff --git a/app/Controllers/PrestitiController.php b/app/Controllers/PrestitiController.php index 7676c3aa4..b83bfb5ef 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,67 @@ 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), '?')); + // MySQL evaluates single-table SET assignments left to right, so the + // CASE branches see the NEW (already-extended) data_scadenza. Scope + // to physically-out states so prenotato / da_ritirare are untouched. + $sql = "UPDATE prestiti + SET data_scadenza = DATE_ADD(data_scadenza, INTERVAL ? DAY), + 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 IN ($placeholders) + AND attivo = 1 + AND stato IN ('in_corso', 'in_ritardo')"; + $stmt = $db->prepare($sql); + $types = 'isss' . str_repeat('i', count($ids)); + $params = array_merge([$days, $today, $today, $today], $ids); + $stmt->bind_param($types, ...$params); + $stmt->execute(); + $extended = $stmt->affected_rows; + $stmt->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..dd5b7a010 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 + @@ -333,14 +336,18 @@ class="inline-flex items-center gap-2 whitespace-nowrap px-4 py-2 bg-gray-100 te - + +
+ +
+

+ +
@@ -396,6 +403,32 @@ class="inline-flex items-center px-3 py-1.5 bg-red-600 hover:bg-red-500 text-whi + + +