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).
This commit is contained in:
2026-02-13 20:22:39 +01:00
parent 73a473ec93
commit 88e7f319db
61 changed files with 6484 additions and 52 deletions

View File

@@ -0,0 +1,388 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
roles: string[];
statut: string;
}
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
status: string;
startDate: string;
}
interface SchoolClass {
id: string;
name: string;
level: string | null;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
}
let teacherId = $derived($page.params.id);
let teacher = $state<User | null>(null);
let assignments = $state<TeacherAssignment[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
function extractCollection<T>(data: Record<string, unknown>): T[] {
const hydra = data['hydra:member'];
if (Array.isArray(hydra)) return hydra as T[];
const member = data['member'];
if (Array.isArray(member)) return member as T[];
if (Array.isArray(data)) return data as T[];
return [];
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls ? cls.name : classId;
}
function getClassLevel(classId: string): string | null {
const cls = classes.find((c) => c.id === classId);
return cls?.level ?? null;
}
function getSubjectName(subjectId: string): string {
const subject = subjects.find((s) => s.id === subjectId);
return subject ? subject.name : subjectId;
}
function getSubjectColor(subjectId: string): string | null {
const subject = subjects.find((s) => s.id === subjectId);
return subject?.color ?? null;
}
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
onMount(async () => {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const [teacherRes, assignmentsRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users/${teacherId}`),
authenticatedFetch(`${apiUrl}/teachers/${teacherId}/assignments`),
authenticatedFetch(`${apiUrl}/classes`),
authenticatedFetch(`${apiUrl}/subjects`)
]);
if (!teacherRes.ok) throw new Error('Enseignant non trouvé');
teacher = await teacherRes.json();
if (assignmentsRes.ok) {
const data = await assignmentsRes.json();
assignments = extractCollection(data);
}
if (classesRes.ok) {
const data = await classesRes.json();
classes = extractCollection(data);
}
if (subjectsRes.ok) {
const data = await subjectsRes.json();
subjects = extractCollection(data);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
});
</script>
<svelte:head>
<title>{teacher ? `${teacher.firstName} ${teacher.lastName}` : 'Enseignant'} - Classeo</title>
</svelte:head>
<div class="teacher-page">
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement du profil enseignant...</p>
</div>
{:else if error}
<div class="alert alert-error">{error}</div>
<a href="/admin/users" class="btn-back">Retour aux utilisateurs</a>
{:else if teacher}
<header class="page-header">
<div>
<a href="/admin/users" class="btn-back">Retour</a>
<h1>{teacher.firstName} {teacher.lastName}</h1>
<p class="subtitle">{teacher.email}</p>
</div>
</header>
<section class="info-card">
<h2>Informations</h2>
<dl class="info-grid">
<dt>Email</dt>
<dd>{teacher.email}</dd>
<dt>Statut</dt>
<dd><span class="status-badge status-{teacher.statut}">{teacher.statut}</span></dd>
<dt>Roles</dt>
<dd>{teacher.roles.join(', ')}</dd>
</dl>
</section>
<section class="assignments-card">
<div class="section-header">
<h2>Affectations ({activeAssignments.length})</h2>
<a href="/admin/assignments" class="btn-link">Gerer les affectations</a>
</div>
{#if activeAssignments.length === 0}
<p class="empty-state">Aucune affectation pour cet enseignant.</p>
{:else}
<ul class="assignment-list">
{#each activeAssignments as assignment (assignment.id)}
<li class="assignment-item">
<div class="assignment-info">
<span class="class-name">
{getClassName(assignment.classId)}
{#if getClassLevel(assignment.classId)}
<span class="class-level">({getClassLevel(assignment.classId)})</span>
{/if}
</span>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-badge"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
<span class="subject-badge">{getSubjectName(assignment.subjectId)}</span>
{/if}
</div>
<span class="assignment-date">
Depuis le {new Date(assignment.startDate).toLocaleDateString('fr-FR')}
</span>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
</div>
<style>
.teacher-page {
padding: 1.5rem;
max-width: 700px;
margin: 0 auto;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem;
text-align: center;
}
.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); }
}
.alert-error {
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.btn-back {
display: inline-block;
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
margin-bottom: 0.5rem;
}
.btn-back:hover {
text-decoration: underline;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.info-card,
.assignments-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.info-card h2,
.assignments-card h2 {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.info-grid {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.info-grid dt {
font-weight: 500;
color: #6b7280;
font-size: 0.875rem;
}
.info-grid dd {
margin: 0;
font-size: 0.875rem;
color: #1f2937;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: #f3f4f6;
color: #374151;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
.btn-link {
font-size: 0.875rem;
color: #3b82f6;
text-decoration: none;
}
.btn-link:hover {
text-decoration: underline;
}
.empty-state {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.assignment-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.assignment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
gap: 1rem;
}
.assignment-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.class-name {
font-weight: 500;
color: #1f2937;
}
.class-level {
font-weight: 400;
color: #6b7280;
font-size: 0.875rem;
}
.subject-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: #e5e7eb;
color: #374151;
}
.assignment-date {
font-size: 0.75rem;
color: #9ca3af;
white-space: nowrap;
}
</style>