feat: Afficher les statistiques de notes par matière côté administration
L'admin doit pouvoir voir en un coup d'œil quelles matières sont actives (notes saisies) pour décider lesquelles peuvent être supprimées sans perte de données. Auparavant, la suppression d'une matière était silencieuse : elle cascade-deletait évaluations et notes sans avertir. La liste des matières affiche désormais les compteurs d'enseignants, classes, évaluations et notes. La suppression déclenche une confirmation explicite quand la matière contient des notes, avec récapitulatif des volumes impactés, pour rendre l'action irréversible consciente. Côté tests, un endpoint de seeding HTTP remplace les appels docker exec dans les E2E (gain ~30-60s → 5-10s par test), et un trait partagé factorise le SQL de seeding entre les deux suites fonctionnelles.
This commit is contained in:
72
frontend/src/lib/features/subjects/api/deleteSubject.ts
Normal file
72
frontend/src/lib/features/subjects/api/deleteSubject.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Logique de suppression d'une matière, extraite du composant Svelte
|
||||
* pour être testable unitairement.
|
||||
*
|
||||
* Flux :
|
||||
* - Si la liste indique `hasGrades === true`, on envoie `?confirm=true` pour forcer
|
||||
* le backend à accepter la suppression (confirmation déjà donnée par l'admin).
|
||||
* - Si `hasGrades` est `null` (stats non chargées) ou `false`, on envoie un DELETE simple :
|
||||
* le backend décidera. En cas de stats obsolètes côté UI, il renverra 409.
|
||||
* - 409 = les stats côté liste étaient périmées ; on renvoie `status: 'conflict'` pour
|
||||
* que l'appelant rafraîchisse la liste et affiche un message explicatif plutôt qu'une
|
||||
* erreur générique.
|
||||
*/
|
||||
|
||||
export interface DeleteSubjectInput {
|
||||
id: string;
|
||||
hasGrades: boolean | null;
|
||||
}
|
||||
|
||||
export type DeleteSubjectResult =
|
||||
| { status: 'success' }
|
||||
| { status: 'conflict'; message: string }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
export type DeleteSubjectFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export async function deleteSubject(
|
||||
subject: DeleteSubjectInput,
|
||||
fetchFn: DeleteSubjectFetch,
|
||||
apiBaseUrl: string
|
||||
): Promise<DeleteSubjectResult> {
|
||||
const url =
|
||||
subject.hasGrades === true
|
||||
? `${apiBaseUrl}/subjects/${subject.id}?confirm=true`
|
||||
: `${apiBaseUrl}/subjects/${subject.id}`;
|
||||
|
||||
const response = await fetchFn(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
return { status: 'success' };
|
||||
}
|
||||
|
||||
const message = await extractErrorMessage(response);
|
||||
|
||||
if (response.status === 409) {
|
||||
return { status: 'conflict', message };
|
||||
}
|
||||
|
||||
return { status: 'error', message };
|
||||
}
|
||||
|
||||
async function extractErrorMessage(response: Response): Promise<string> {
|
||||
const fallback = `Erreur lors de la suppression (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (typeof errorData === 'object' && errorData !== null) {
|
||||
const record = errorData as Record<string, unknown>;
|
||||
if (typeof record['hydra:description'] === 'string') {
|
||||
return record['hydra:description'];
|
||||
}
|
||||
if (typeof record['message'] === 'string') {
|
||||
return record['message'];
|
||||
}
|
||||
if (typeof record['detail'] === 'string') {
|
||||
return record['detail'];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, keep fallback
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { deleteSubject } from '$lib/features/subjects/api/deleteSubject';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
// Types
|
||||
@@ -17,6 +18,9 @@
|
||||
status: string;
|
||||
teacherCount: number | null;
|
||||
classCount: number | null;
|
||||
evaluationCount: number | null;
|
||||
gradeCount: number | null;
|
||||
hasGrades: boolean | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -177,29 +181,26 @@
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!subjectToDelete) return;
|
||||
if (isDeleting) return;
|
||||
|
||||
try {
|
||||
isDeleting = true;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await deleteSubject(
|
||||
{ id: subjectToDelete.id, hasGrades: subjectToDelete.hasGrades },
|
||||
authenticatedFetch,
|
||||
getApiBaseUrl()
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur lors de la suppression (${response.status})`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData['hydra:description']) {
|
||||
errorMessage = errorData['hydra:description'];
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.detail) {
|
||||
errorMessage = errorData.detail;
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, keep default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
if (result.status === 'conflict') {
|
||||
// Stats côté UI périmées : rafraîchir la liste pour que l'admin voie l'impact réel.
|
||||
closeDeleteModal();
|
||||
await loadSubjects();
|
||||
error = `${result.message} La liste a été rafraîchie, réessayez pour voir l'impact exact.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'error') {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
closeDeleteModal();
|
||||
@@ -313,6 +314,10 @@
|
||||
<span class="stat-icon">🏫</span>
|
||||
{subject.classCount ?? 0}
|
||||
</span>
|
||||
<span class="stat-item" title="Évaluations créées">
|
||||
<span class="stat-icon">📝</span>
|
||||
{subject.evaluationCount ?? 0}
|
||||
</span>
|
||||
<span class="stat-item status-{subject.status}">
|
||||
{subject.status === 'active' ? 'Active' : 'Archivée'}
|
||||
</span>
|
||||
@@ -448,7 +453,9 @@
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-modal-title"
|
||||
aria-describedby="delete-modal-description"
|
||||
aria-describedby={subjectToDelete.hasGrades
|
||||
? 'delete-modal-description delete-subject-impact'
|
||||
: 'delete-modal-description'}
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
|
||||
@@ -463,6 +470,23 @@
|
||||
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
|
||||
?
|
||||
</p>
|
||||
{#if subjectToDelete.hasGrades}
|
||||
<div
|
||||
class="delete-impact"
|
||||
id="delete-subject-impact"
|
||||
data-testid="delete-subject-impact"
|
||||
>
|
||||
<p class="delete-impact-title" data-testid="delete-subject-impact-summary">
|
||||
⚠️ <strong>{subjectToDelete.evaluationCount ?? 0}</strong>
|
||||
évaluation{(subjectToDelete.evaluationCount ?? 0) > 1 ? 's' : ''} et
|
||||
<strong>{subjectToDelete.gradeCount ?? 0}</strong>
|
||||
note{(subjectToDelete.gradeCount ?? 0) > 1 ? 's' : ''} seront affectées.
|
||||
</p>
|
||||
<p class="delete-impact-note">
|
||||
Ces données resteront consultables dans l'historique mais la matière ne sera plus sélectionnable.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="delete-warning">Cette action est irréversible.</p>
|
||||
</div>
|
||||
|
||||
@@ -906,4 +930,26 @@
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.delete-impact {
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.875rem 1rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.delete-impact-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.delete-impact-note {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: #78350f;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user