From fd4fb5ce4a909e75853bcfa8b3ceb55c5cf9cbb5 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Wed, 15 Apr 2026 12:58:03 +0200 Subject: [PATCH] Add delete/batch-delete and sortable columns to admin list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Database: add deleteThesis() and bulkDeleteTheses() methods with file cleanup - Database: add SORT_MAP + buildOrderBy() for safe column sorting - Database: getThesesList() now respects sort/dir filter params - New action: actions/delete.php (single + batch delete with CSRF) - Admin index: delete button per row with confirmation dialog - Admin index: batch 'Supprimer' button in bulk actions bar - Admin index: sortable column headers (ID, Titre, Année, Orientation, AP, Statut) - Admin index: sort state preserved in pagination links - CSS: admin-btn-delete (red muted), admin-sort-link styles --- TODO.md | 4 ++ public/admin/actions/delete.php | 55 +++++++++++++++++++++ public/admin/index.php | 64 +++++++++++++++++++++--- public/assets/css/admin.css | 23 +++++++++ src/Database.php | 88 ++++++++++++++++++++++++++++++++- 5 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 public/admin/actions/delete.php diff --git a/TODO.md b/TODO.md index 5ab051d..6f8a002 100644 --- a/TODO.md +++ b/TODO.md @@ -23,3 +23,7 @@ - [x] tfe.php: contact shown from author_email+show_contact; baiu_link relabeled as "Lien" - [x] actions/settings.php: handler for formulaire settings form - [x] CSS: admin-toggle pill switches + admin-settings-toggles layout + admin-form-group +- [x] Fix undefined $from– variable in admin/index.php (brace-interpolate around en-dash) +- [x] Add delete single entry to admin table (delete action + handler) +- [x] Add batch delete to bulk actions bar +- [x] Add sortable columns to admin table (click column headers to sort) diff --git a/public/admin/actions/delete.php b/public/admin/actions/delete.php new file mode 100644 index 0000000..358e1ae --- /dev/null +++ b/public/admin/actions/delete.php @@ -0,0 +1,55 @@ + $id > 0); + + if (empty($ids)) { + App::flash('error', 'Aucun TFE sélectionné.'); + header('Location: ../index.php'); + exit; + } + + $db->bulkDeleteTheses($ids); + $count = count($ids); + App::flash('success', "$count TFE(s) supprimé(s) avec succès."); + + } else { + $thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT); + + if (!$thesisId || $thesisId <= 0) { + App::flash('error', 'ID invalide.'); + header('Location: ../index.php'); + exit; + } + + $db->deleteThesis($thesisId); + App::flash('success', 'TFE supprimé avec succès.'); + } + +} catch (Exception $e) { + error_log('delete.php error: ' . $e->getMessage()); + App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage()); +} + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +header('Location: ../index.php'); +exit; diff --git a/public/admin/index.php b/public/admin/index.php index adc8062..23143c4 100644 --- a/public/admin/index.php +++ b/public/admin/index.php @@ -222,11 +222,16 @@ try { $orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null; $apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null; + $sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at'; + $sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc'; + $filters = []; if ($searchQuery) $filters['search'] = $searchQuery; if ($yearFilter) $filters['year'] = $yearFilter; if ($orientationFilter) $filters['orientation'] = $orientationFilter; if ($apFilter) $filters['ap'] = $apFilter; + $filters['sort'] = $sortCol; + $filters['dir'] = $sortDir; $perPage = 25; $page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1; @@ -262,9 +267,18 @@ function updateBulk() { function bulkAction(action) { const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked'); if (!checked.length) { alert('Sélectionnez au moins un TFE.'); return; } - const word = action === 'publish' ? 'publier' : 'dépublier'; - if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return; + let word, endpoint; + if (action === 'publish') { word = 'publier'; endpoint = 'actions/publish.php'; } + else if (action === 'unpublish') { word = 'dépublier'; endpoint = 'actions/publish.php'; } + else if (action === 'delete') { word = 'supprimer'; endpoint = 'actions/delete.php'; } + else return; + if (action === 'delete') { + if (!confirm(`Supprimer définitivement ${checked.length} TFE(s) ? Cette action est irréversible.`)) return; + } else { + if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return; + } document.getElementById('bulk-action-input').value = action; + document.getElementById('bulk-form').action = endpoint; const container = document.getElementById('bulk-checkboxes'); container.innerHTML = ''; checked.forEach(cb => { @@ -274,6 +288,11 @@ function bulkAction(action) { }); document.getElementById('bulk-form').submit(); } +function deleteThesis(id, title) { + if (!confirm(`Supprimer « ${title} » ?\nCette action est irréversible.`)) return; + const form = document.getElementById('delete-form-' + id); + if (form) form.submit(); +} document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.addEventListener('change', updateBulk)); }); @@ -346,6 +365,7 @@ document.addEventListener('DOMContentLoaded', () => {
+
@@ -371,17 +391,37 @@ document.addEventListener('DOMContentLoaded', () => { } ?>

+ $searchQuery, + 'year' => $yearFilter ?: '', + 'orientation' => $orientationFilter ?: '', + 'ap' => $apFilter ?: '', + ]); + + $sortLink = function(string $col) use ($sortCol, $sortDir, $sortParams): string { + $params = $sortParams; + $params['sort'] = $col; + $params['dir'] = ($sortCol === $col && $sortDir === 'desc') ? 'asc' : 'desc'; + return '/admin/?' . http_build_query($params); + }; + + $sortArrow = function(string $col) use ($sortCol, $sortDir): string { + if ($sortCol !== $col) return ''; + return $sortDir === 'asc' ? ' ↑' : ' ↓'; + }; + ?> - - + + - - - - + + + + @@ -422,6 +462,12 @@ document.addEventListener('DOMContentLoaded', () => { + + + + + @@ -436,6 +482,8 @@ document.addEventListener('DOMContentLoaded', () => { 'year' => $yearFilter ?: '', 'orientation' => $orientationFilter ?: '', 'ap' => $apFilter ?: '', + 'sort' => $sortCol, + 'dir' => $sortDir, ]); include APP_ROOT . '/templates/partials/pagination.php'; ?> diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css index 5857fa3..df19404 100644 --- a/public/assets/css/admin.css +++ b/public/assets/css/admin.css @@ -498,6 +498,20 @@ white-space: nowrap; } +/* Sortable column headers */ +.admin-sort-link { + color: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 2px; + transition: color 0.15s; +} + +.admin-sort-link:hover { + color: var(--accent-primary); +} + .admin-body table td { padding: var(--space-2xs) var(--space-xs); border-bottom: 1px solid var(--border-primary); @@ -621,6 +635,15 @@ background: var(--bg-tertiary); } +.admin-btn-delete { + background: var(--error-muted-bg); + color: var(--error); + border-color: var(--error-muted-border, var(--border-primary)); +} +.admin-btn-delete:hover { + filter: brightness(0.9); +} + .publish-form { display: inline; margin: 0; diff --git a/src/Database.php b/src/Database.php index 8db1712..f9da3e3 100644 --- a/src/Database.php +++ b/src/Database.php @@ -661,6 +661,37 @@ class Database { * @param array $filters * @return array */ + /** + * Allowed sort columns for the admin list. + * Maps query-string `sort` values to safe SQL ORDER BY expressions. + */ + private const SORT_MAP = [ + 'id' => 't.id', + 'identifier' => 't.identifier', + 'title' => 't.title', + 'year' => 't.year', + 'orientation' => 'o.name', + 'ap_program' => 'ap.name', + 'is_published' => 't.is_published', + 'submitted_at' => 't.submitted_at', + ]; + + /** + * Build the ORDER BY clause from sort/direction parameters. + * Returns a safe SQL fragment (never interpolates raw user input). + */ + private function buildOrderBy(array $filters): string { + $sort = $filters['sort'] ?? 'submitted_at'; + $dir = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC'; + + $col = self::SORT_MAP[$sort] ?? self::SORT_MAP['submitted_at']; + + // Secondary sort for stable ordering + $secondary = ($sort === 'year') ? ', t.title ASC' : ', t.id DESC'; + + return "ORDER BY {$col} {$dir}{$secondary}"; + } + /** * Count theses matching the given admin filters (no LIMIT). * Used alongside getThesesList() to calculate total pages. @@ -746,7 +777,8 @@ class Database { $params[] = intval($filters['ap']); } - $sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC"; + $orderBy = $this->buildOrderBy($filters); + $sql .= " GROUP BY t.id {$orderBy}"; if ($limit > 0) { $sql .= " LIMIT :limit OFFSET :offset"; @@ -1560,6 +1592,60 @@ class Database { return $thesisId; } + /** + * Delete a single thesis and all its related data (cascade via FK). + * Also removes the banner file from disk if present. + */ + public function deleteThesis(int $thesisId): void { + // Clean up banner file + $bannerPath = $this->getThesisBannerPath($thesisId); + if ($bannerPath !== null) { + $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null; + if ($fullPath && file_exists($fullPath)) { + @unlink($fullPath); + } + } + + // Clean up thesis files from disk + $files = $this->getThesisFiles($thesisId); + foreach ($files as $file) { + if (!empty($file['file_path']) && file_exists($file['file_path'])) { + @unlink($file['file_path']); + } + } + + // DB cascade handles junction tables + $this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]); + } + + /** + * Delete multiple theses at once. + * @param int[] $thesisIds + */ + public function bulkDeleteTheses(array $thesisIds): void { + if (empty($thesisIds)) return; + + // Clean up files for each thesis + foreach ($thesisIds as $id) { + $bannerPath = $this->getThesisBannerPath($id); + if ($bannerPath !== null) { + $fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null; + if ($fullPath && file_exists($fullPath)) { + @unlink($fullPath); + } + } + $files = $this->getThesisFiles($id); + foreach ($files as $file) { + if (!empty($file['file_path']) && file_exists($file['file_path'])) { + @unlink($file['file_path']); + } + } + } + + $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); + $this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds); + } + /** * Return the stored identifier string (e.g. "2024-003") for an existing thesis. */
IDTitreIDTitre Auteur(s)AnnéeOrientationAPStatutAnnéeOrientationAPStatut Actions