Files
Classeo/frontend/src/routes/admin/classes/[id]/+page.svelte
Mathias STRASSER 88e7f319db feat: Affectation des enseignants aux classes et matières
Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.

Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
2026-02-13 20:22:39 +01:00

683 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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;
}
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
status: string;
startDate: string;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
}
interface User {
id: string;
firstName: string;
lastName: 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);
// Teacher assignments state
let classTeachers = $state<TeacherAssignment[]>([]);
let allSubjects = $state<Subject[]>([]);
let allTeachers = $state<User[]>([]);
let isLoadingTeachers = $state(false);
let teachersError = $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 and teachers on mount
$effect(() => {
if (classId) {
loadClass(classId);
loadClassTeachers(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
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
} finally {
isSaving = false;
}
}
async function loadClassTeachers(id: string) {
try {
isLoadingTeachers = true;
teachersError = null;
const apiUrl = getApiBaseUrl();
const [teachersRes, subjectsRes, usersRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes/${id}/teachers`),
authenticatedFetch(`${apiUrl}/subjects`),
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
const teachersData = await teachersRes.json();
classTeachers = (teachersData['hydra:member'] ?? teachersData['member'] ?? (Array.isArray(teachersData) ? teachersData : []))
.filter((a: TeacherAssignment) => a.status === 'active');
if (subjectsRes.ok) {
const subjectsData = await subjectsRes.json();
allSubjects = subjectsData['hydra:member'] ?? subjectsData['member'] ?? (Array.isArray(subjectsData) ? subjectsData : []);
}
if (usersRes.ok) {
const usersData = await usersRes.json();
allTeachers = usersData['hydra:member'] ?? usersData['member'] ?? (Array.isArray(usersData) ? usersData : []);
}
} catch (e) {
teachersError = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoadingTeachers = false;
}
}
function getTeacherName(teacherId: string): string {
const teacher = allTeachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getSubjectName(subjectId: string): string {
const subject = allSubjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = allSubjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
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>
<!-- Teachers Section (AC5) -->
<section class="teachers-section">
<div class="section-header">
<h2>Enseignants affectés</h2>
<a href="/admin/assignments" class="btn-link">Gérer les affectations</a>
</div>
{#if teachersError}
<div class="alert alert-error">
{teachersError}
</div>
{/if}
{#if isLoadingTeachers}
<p class="section-loading">Chargement des enseignants...</p>
{:else if classTeachers.length === 0}
<p class="section-empty">Aucun enseignant affecté à cette classe.</p>
{:else}
<ul class="teacher-list">
{#each classTeachers as assignment (assignment.id)}
<li class="teacher-item">
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-tag"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
<span class="subject-tag subject-tag-default">
{getSubjectName(assignment.subjectId)}
</span>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
<style>
.edit-page {
padding: 1.5rem;
max-width: 700px;
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;
gap: 1rem;
}
@media (min-width: 640px) {
.form-row {
grid-template-columns: 1fr 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;
}
/* Teachers Section */
.teachers-section {
margin-top: 1.5rem;
background: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.btn-link {
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.section-loading,
.section-empty {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.teacher-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.teacher-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.teacher-name {
font-weight: 500;
color: #1f2937;
}
.subject-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.subject-tag-default {
background: #f3f4f6;
color: #374151;
}
</style>