feat: Gestion des classes scolaires

Permet aux administrateurs de créer, modifier et supprimer des classes
pour organiser les élèves par niveau. L'archivage soft-delete préserve
l'historique tout en masquant les classes obsolètes.

Inclut la validation des noms (2-50 caractères), les niveaux scolaires
du CP à la Terminale, et les contrôles d'accès par rôle.
This commit is contained in:
2026-02-05 15:24:29 +01:00
parent b45ef735db
commit 8e09e0abf1
54 changed files with 5099 additions and 5 deletions

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
// Types
interface SchoolClass {
id: string;
name: string;
level: string | null;
capacity: number | null;
description: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
// State
let schoolClass = $state<SchoolClass | null>(null);
let isLoading = $state(true);
let isSaving = $state(false);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Form state (bound to schoolClass)
let formName = $state('');
let formLevel = $state<string | null>(null);
let formCapacity = $state<number | null>(null);
let formDescription = $state('');
// Track original values to detect intentional clearing
let originalLevel = $state<string | null>(null);
let originalCapacity = $state<number | null>(null);
let originalDescription = $state<string | null>(null);
const classId = $derived(page.params.id);
// Load class on mount
$effect(() => {
if (classId) {
loadClass(classId);
}
});
async function loadClass(id: string) {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/classes/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Classe introuvable');
}
throw new Error('Erreur lors du chargement de la classe');
}
schoolClass = await response.json();
// Initialize form with loaded data
if (schoolClass) {
formName = schoolClass.name;
formLevel = schoolClass.level;
formCapacity = schoolClass.capacity;
formDescription = schoolClass.description ?? '';
// Track original values for clear detection
originalLevel = schoolClass.level;
originalCapacity = schoolClass.capacity;
originalDescription = schoolClass.description;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function handleSave() {
if (!formName.trim() || !schoolClass) return;
try {
isSaving = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
// Detect if user intentionally cleared optional fields
const clearLevel = originalLevel !== null && formLevel === null;
const clearCapacity = originalCapacity !== null && formCapacity === null;
const trimmedDescription = formDescription.trim() || null;
const clearDescription = originalDescription !== null && trimmedDescription === null;
const response = await authenticatedFetch(`${apiUrl}/classes/${schoolClass.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json'
},
body: JSON.stringify({
name: formName.trim(),
level: formLevel,
capacity: formCapacity,
description: trimmedDescription,
clearLevel,
clearCapacity,
clearDescription
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Erreur lors de la modification');
}
const updatedClass: SchoolClass = await response.json();
schoolClass = updatedClass;
successMessage = 'Classe modifiée avec succès';
// Update original values after successful save
originalLevel = updatedClass.level;
originalCapacity = updatedClass.capacity;
originalDescription = updatedClass.description;
// Clear success message after 3 seconds
window.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
} finally {
isSaving = false;
}
}
function goBack() {
goto('/admin/classes');
}
</script>
<svelte:head>
<title>{schoolClass?.name ?? 'Modifier la classe'} - Classeo</title>
</svelte:head>
<div class="edit-page">
<nav class="breadcrumb">
<a href="/admin/classes">Classes</a>
<span class="separator">/</span>
<span class="current">{schoolClass?.name ?? 'Chargement...'}</span>
</nav>
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement de la classe...</p>
</div>
{:else if error && !schoolClass}
<div class="error-state">
<span class="error-icon">⚠️</span>
<h2>Erreur</h2>
<p>{error}</p>
<button class="btn-primary" onclick={goBack}>Retour à la liste</button>
</div>
{:else if schoolClass}
<div class="edit-form-container">
<header class="form-header">
<h1>Modifier la classe</h1>
<p class="subtitle">
Créée le {new Date(schoolClass.createdAt).toLocaleDateString('fr-FR')}
</p>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>×</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
<span class="alert-icon"></span>
{successMessage}
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="form-group">
<label for="class-name">Nom de la classe *</label>
<input
type="text"
id="class-name"
bind:value={formName}
placeholder="ex: 6ème A"
required
minlength="2"
maxlength="50"
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="class-level">Niveau scolaire</label>
<select id="class-level" bind:value={formLevel}>
<option value={null}>-- Aucun --</option>
{#each SCHOOL_LEVEL_OPTIONS as level}
<option value={level.value}>{level.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="class-capacity">Capacité maximale</label>
<input
type="number"
id="class-capacity"
bind:value={formCapacity}
placeholder="ex: 30"
min="1"
max="100"
/>
</div>
</div>
<div class="form-group">
<label for="class-description">Description (optionnelle)</label>
<textarea
id="class-description"
bind:value={formDescription}
placeholder="ex: Classe à option musique"
rows="3"
></textarea>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" onclick={goBack} disabled={isSaving}>
Annuler
</button>
<button type="submit" class="btn-primary" disabled={isSaving || !formName.trim()}>
{#if isSaving}
Enregistrement...
{:else}
Enregistrer les modifications
{/if}
</button>
</div>
</form>
</div>
{/if}
</div>
<style>
.edit-page {
padding: 1.5rem;
max-width: 600px;
margin: 0 auto;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.breadcrumb a {
color: #3b82f6;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb .separator {
color: #9ca3af;
}
.breadcrumb .current {
color: #6b7280;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.error-state h2 {
margin: 0 0 0.5rem;
color: #1f2937;
}
.error-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
.edit-form-container {
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 1.5rem;
}
.form-header {
margin-bottom: 1.5rem;
}
.form-header h1 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #059669;
}
.alert-icon {
flex-shrink: 0;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.form-row {
grid-template-columns: 1fr;
}
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group textarea {
resize: vertical;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
</style>