From a0b75f43dd06c011bbff9d1439a0cf4e302ea6f4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 14 Aug 2026 13:05:19 +0200 Subject: [PATCH 01/22] feat(copies): manage physical copies from the book summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking a book lost/damaged is done per physical copy (a book's availability is derived from its copies, #351). That was only reachable when the book already had copie rows — created on loan or import, never for a manually-created book that was never loaned — so such books had no way to set the status, and there was no way to add a copy from the UI. Add copy management to the book summary (/admin/books/{id}): - The "Copie Fisiche" section is always shown; when the book has no copies it shows an empty state plus an "Aggiungi copia" button. - A new "Aggiungi copia" modal (same style as the existing edit-copy modal) creates a physical copy: optional inventory number (auto-allocated as the next collision-free "{base}-C{N}" when left blank), initial status and a note. - Backend: CopyController::createCopy + POST /admin/books/{id}/copies/create, reusing CopyRepository and recalculating availability. The availability model already excludes lost/damaged/maintenance copies from copie_totali, so marking a copy lost lowers the total on its own. E2E: tests/copy-management-scheda.spec.js (10 real browser tests, all green): section + button, empty state, add (auto + explicit inventory), duplicate rejected, a born-damaged copy not counted, edit to lost/damaged lowering the total and round-tripping, delete guard + delete of an out-of-circulation copy, and "every copy out of circulation → non_disponibile". New UI strings added to all five locales. --- app/Controllers/CopyController.php | 67 ++++++++ app/Routes/web.php | 6 + app/Views/libri/scheda_libro.php | 103 ++++++++++++- locale/da_DK.json | 13 +- locale/de_DE.json | 13 +- locale/en_US.json | 13 +- locale/fr_FR.json | 13 +- locale/it_IT.json | 13 +- tests/copy-management-scheda.spec.js | 218 +++++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 tests/copy-management-scheda.spec.js diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index 90bccb658..1b394c846 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -290,6 +290,73 @@ public function updateCopy(Request $request, Response $response, mysqli $db, int return $response->withHeader('Location', url("/admin/books/{$libroId}"))->withStatus(302); } + /** + * Crea una nuova copia fisica per un libro, direttamente dalla scheda. + * + * Loan states ('prestato'/'prenotato') are never created here — those belong + * to the Prestiti system. A lost/damaged/maintenance copy is allowed: the + * availability recalculation then excludes it from copie_totali, so marking a + * copy lost reduces the book's total on its own. + */ + public function createCopy(Request $request, Response $response, mysqli $db, int $bookId): Response + { + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware + + $stmt = $db->prepare("SELECT id, numero_inventario FROM libri WHERE id = ? AND deleted_at IS NULL"); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $book = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$book) { + $_SESSION['error_message'] = __('Libro non trovato.'); + return $response->withHeader('Location', $this->safeReferer('/admin/books'))->withStatus(302); + } + + // Only physical statuses a copy can be created in — loan states are + // managed by the Prestiti system, never set here. + $stato = (string) ($data['stato'] ?? 'disponibile'); + $statiValidi = ['disponibile', 'manutenzione', 'in_restauro', 'perso', 'danneggiato', 'in_trasferimento']; + if (!in_array($stato, $statiValidi, true)) { + $_SESSION['error_message'] = __('Stato non valido.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + $note = trim((string) ($data['note'] ?? '')); + + $repo = new \App\Models\CopyRepository($db); + + // Inventory code: honour an explicit value (must be unique), otherwise + // auto-allocate the next collision-free "{base}-C{N}" like book creation. + $numero = trim((string) ($data['numero_inventario'] ?? '')); + if ($numero !== '') { + $numero = preg_replace('/[\x00-\x1F]/', '', $numero); + if (mb_strlen($numero) > 100) { + $numero = mb_substr($numero, 0, 100); + } + if ($repo->inventoryCodeExists($numero)) { + $_SESSION['error_message'] = __('Esiste già una copia con questo numero di inventario.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + } else { + $base = !empty($book['numero_inventario']) ? (string) $book['numero_inventario'] : "LIB-{$bookId}"; + $codes = $repo->allocateInventoryCodes($base, 1); + $numero = $codes[0] ?? ($base . '-C1'); + } + + try { + $repo->create($bookId, $numero, $stato, $note !== '' ? $note : null); + } catch (\Throwable $e) { + SecureLogger::error('[CopyController] createCopy failed', ['book' => $bookId, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile aggiungere la copia.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + + (new \App\Support\DataIntegrity($db))->recalculateBookAvailability($bookId); + + $_SESSION['success_message'] = __('Copia aggiunta con successo.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + /** * Elimina una singola copia */ diff --git a/app/Routes/web.php b/app/Routes/web.php index 6c04f3a55..92c67cfe0 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1494,6 +1494,12 @@ return $controller->deleteCopy($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/books/{id:\d+}/copies/create', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\CopyController(); + $db = $app->getContainer()->get('db'); + return $controller->createCopy($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Series (Series) management $app->get('/admin/series', function ($request, $response) use ($app) { $controller = new \App\Controllers\CollaneController(); diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index 421014f15..549cc08d6 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -908,8 +908,8 @@ class="inline-flex items-center gap-1 px-3 py-2 text-xs font-medium rounded-lg b - - 0): ?> + +
@@ -930,11 +930,19 @@ class="inline-flex items-center gap-1 px-3 py-2 text-xs font-medium rounded-lg b - - - +
+ + + + + + +
@@ -951,6 +959,15 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm + + + + + + + + + @@ -1120,7 +1137,6 @@ class="text-red-600 hover:text-red-900 transition-colors"
- @@ -1866,6 +1882,77 @@ function confirmRenewal(e){
+ + + + +