mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
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:
4
TODO.md
4
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)
|
||||
|
||||
55
public/admin/actions/delete.php
Normal file
55
public/admin/actions/delete.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../../../config/bootstrap.php';
|
||||
require_once __DIR__ . '/../../../src/AdminAuth.php';
|
||||
|
||||
AdminAuth::requireLogin();
|
||||
|
||||
require_once __DIR__ . '/../../../src/Database.php';
|
||||
|
||||
// CSRF validation
|
||||
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||
App::flash('error', 'Erreur de sécurité : token invalide.');
|
||||
header('Location: ../index.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$isBulk = !empty($_POST['bulk']);
|
||||
|
||||
try {
|
||||
$db = new Database();
|
||||
|
||||
if ($isBulk) {
|
||||
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $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;
|
||||
@@ -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';
|
||||
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', () => {
|
||||
<div class="admin-bulk-btns">
|
||||
<button type="button" class="admin-btn-sm admin-btn-publish" onclick="bulkAction('publish')">Publier</button>
|
||||
<button type="button" class="admin-btn-sm admin-btn-unpublish" onclick="bulkAction('unpublish')">Dépublier</button>
|
||||
<button type="button" class="admin-btn-sm admin-btn-delete" onclick="bulkAction('delete')">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -371,17 +391,37 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
?>
|
||||
</p>
|
||||
<?php
|
||||
$sortParams = array_filter([
|
||||
'search' => $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' ? ' ↑' : ' ↓';
|
||||
};
|
||||
?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><input type="checkbox" onchange="toggleAll(this)"></th>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Titre</th>
|
||||
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link">ID<?= $sortArrow('identifier') ?></a></th>
|
||||
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link">Titre<?= $sortArrow('title') ?></a></th>
|
||||
<th scope="col">Auteur(s)</th>
|
||||
<th scope="col">Année</th>
|
||||
<th scope="col">Orientation</th>
|
||||
<th scope="col">AP</th>
|
||||
<th scope="col">Statut</th>
|
||||
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link">Année<?= $sortArrow('year') ?></a></th>
|
||||
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link">Orientation<?= $sortArrow('orientation') ?></a></th>
|
||||
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link">AP<?= $sortArrow('ap_program') ?></a></th>
|
||||
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link">Statut<?= $sortArrow('is_published') ?></a></th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -422,6 +462,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<button type="submit" class="admin-btn-sm admin-btn-publish">Publier</button>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<form method="post" action="actions/delete.php" id="delete-form-<?= $thesis['id'] ?>" class="publish-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
|
||||
<button type="button" class="admin-btn-sm admin-btn-delete"
|
||||
onclick="deleteThesis(<?= $thesis['id'] ?>, <?= htmlspecialchars(json_encode($thesis['title']), ENT_QUOTES) ?>)">Supprimer</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -436,6 +482,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
'year' => $yearFilter ?: '',
|
||||
'orientation' => $orientationFilter ?: '',
|
||||
'ap' => $apFilter ?: '',
|
||||
'sort' => $sortCol,
|
||||
'dir' => $sortDir,
|
||||
]);
|
||||
include APP_ROOT . '/templates/partials/pagination.php';
|
||||
?>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user