Add delete/batch-delete and sortable columns to admin list

- 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
This commit is contained in:
Pontoporeia
2026-04-15 12:58:03 +02:00
parent 1b104df51e
commit fd4fb5ce4a
5 changed files with 225 additions and 9 deletions

View File

@@ -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.
*/