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).
683 lines
15 KiB
Svelte
683 lines
15 KiB
Svelte
<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>
|