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

@@ -1,17 +1,36 @@
<script lang="ts">
import { untrack } from 'svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
import { fetchRoles, getRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
let accessChecked = $state(false);
let hasAccess = $state(false);
// Load user roles on mount for multi-role context switching (FR5)
$effect(() => {
untrack(() => fetchRoles());
const ADMIN_ROLES = [
'ROLE_SUPER_ADMIN',
'ROLE_ADMIN',
'ROLE_VIE_SCOLAIRE',
'ROLE_SECRETARIAT'
];
// Load user roles and verify admin access
onMount(async () => {
await fetchRoles();
const userRoles = getRoles();
const isAdmin = userRoles.some((r) => ADMIN_ROLES.includes(r.value));
if (!isAdmin) {
goto('/dashboard');
return;
}
hasAccess = true;
accessChecked = true;
});
async function handleLogout() {
@@ -37,44 +56,59 @@
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
</script>
<div class="admin-layout">
<header class="admin-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<RoleSwitcher />
<a href="/dashboard" class="nav-link">Tableau de bord</a>
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
Déconnexion...
{:else}
Déconnexion
{/if}
{#if !accessChecked}
<div class="loading-guard">
<div class="spinner"></div>
</div>
{:else if hasAccess}
<div class="admin-layout">
<header class="admin-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
<span class="logo-text">Classeo</span>
</button>
</nav>
</div>
</header>
<nav class="header-nav">
<RoleSwitcher />
<a href="/dashboard" class="nav-link">Tableau de bord</a>
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
<a href="/admin/assignments" class="nav-link" class:active={isAssignmentsActive}>Affectations</a>
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
Déconnexion...
{:else}
Déconnexion
{/if}
</button>
</nav>
</div>
</header>
<main class="admin-main">
<div class="main-content">
{@render children()}
</div>
</main>
</div>
<main class="admin-main">
<div class="main-content">
{@render children()}
</div>
</main>
</div>
{/if}
<style>
.loading-guard {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.admin-layout {
min-height: 100vh;
display: flex;

View File

@@ -0,0 +1,884 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface TeacherAssignment {
id: string;
teacherId: string;
classId: string;
subjectId: string;
academicYearId: string;
status: string;
startDate: string;
endDate: string | null;
createdAt: string;
}
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
roles: string[];
}
interface SchoolClass {
id: string;
name: string;
level: string | null;
status: string;
}
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
status: string;
}
// State
let assignments = $state<TeacherAssignment[]>([]);
let teachers = $state<User[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Create modal
let showCreateModal = $state(false);
let selectedTeacherId = $state('');
let selectedClassId = $state('');
let selectedSubjectId = $state('');
let isSubmitting = $state(false);
// Delete state
let showDeleteModal = $state(false);
let assignmentToDelete = $state<TeacherAssignment | null>(null);
let isDeleting = $state(false);
// Load everything on mount
onMount(() => {
loadAll();
});
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
// Load reference data in parallel
const [teachersRes, classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/users?role=ROLE_PROF`),
authenticatedFetch(`${apiUrl}/classes`),
authenticatedFetch(`${apiUrl}/subjects`)
]);
if (!teachersRes.ok) throw new Error('Erreur lors du chargement des enseignants');
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
if (!subjectsRes.ok) throw new Error('Erreur lors du chargement des matières');
const [teachersData, classesData, subjectsData] = await Promise.all([
teachersRes.json(),
classesRes.json(),
subjectsRes.json()
]);
teachers = extractCollection(teachersData);
classes = extractCollection(classesData);
subjects = extractCollection(subjectsData);
// Load assignments for each class in parallel
await loadAssignments();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function loadAssignments() {
const apiUrl = getApiBaseUrl();
if (classes.length === 0) {
assignments = [];
return;
}
const results = await Promise.all(
classes.map(async (cls) => {
try {
const res = await authenticatedFetch(`${apiUrl}/classes/${cls.id}/teachers`);
if (!res.ok) return [];
const data = await res.json();
return extractCollection(data) as TeacherAssignment[];
} catch {
return [];
}
})
);
assignments = results.flat();
}
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 getTeacherName(teacherId: string): string {
const teacher = teachers.find((t) => t.id === teacherId);
return teacher ? `${teacher.firstName} ${teacher.lastName}` : teacherId;
}
function getClassName(classId: string): string {
const cls = classes.find((c) => c.id === classId);
return cls ? cls.name : classId;
}
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;
}
function openCreateModal() {
showCreateModal = true;
selectedTeacherId = '';
selectedClassId = '';
selectedSubjectId = '';
error = null;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
if (!selectedTeacherId || !selectedClassId || !selectedSubjectId) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/teacher-assignments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
teacherId: selectedTeacherId,
classId: selectedClassId,
subjectId: selectedSubjectId,
academicYearId: 'current'
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? `Erreur (${response.status})`;
throw new Error(message);
}
successMessage = 'Affectation créée avec succès';
closeCreateModal();
await loadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function openDeleteModal(assignment: TeacherAssignment) {
assignmentToDelete = assignment;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
assignmentToDelete = null;
}
async function handleDelete() {
if (!assignmentToDelete) return;
try {
isDeleting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/teacher-assignments/${assignmentToDelete.id}`,
{ method: 'DELETE' }
);
if (!response.ok) {
const data = await response.json().catch(() => null);
const message =
data?.['hydra:description'] ?? data?.message ?? data?.detail ?? `Erreur (${response.status})`;
throw new Error(message);
}
successMessage = 'Affectation retirée avec succès';
closeDeleteModal();
await loadAssignments();
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
} finally {
isDeleting = false;
}
}
// Only show active assignments
const activeAssignments = $derived(assignments.filter((a) => a.status === 'active'));
</script>
<svelte:head>
<title>Affectations enseignants - Classeo</title>
</svelte:head>
<div class="assignments-page">
<header class="page-header">
<div class="header-content">
<h1>Affectations enseignants</h1>
<p class="subtitle">Affectez les enseignants à leurs classes et matières</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvelle affectation
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">!</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>x</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
{successMessage}
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement des affectations...</p>
</div>
{:else if activeAssignments.length === 0}
<div class="empty-state">
<span class="empty-icon">&#x1F4CB;</span>
<h2>Aucune affectation</h2>
<p>Commencez par affecter un enseignant à une classe et une matière</p>
<button class="btn-primary" onclick={openCreateModal}>Nouvelle affectation</button>
</div>
{:else}
<div class="table-container">
<table class="assignments-table">
<thead>
<tr>
<th>Enseignant</th>
<th>Classe</th>
<th>Matière</th>
<th>Statut</th>
<th>Depuis le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each activeAssignments as assignment (assignment.id)}
<tr>
<td class="teacher-cell">
<span class="teacher-name">{getTeacherName(assignment.teacherId)}</span>
</td>
<td>{getClassName(assignment.classId)}</td>
<td>
{#if getSubjectColor(assignment.subjectId)}
<span
class="subject-badge"
style="background-color: {getSubjectColor(assignment.subjectId)}; color: white"
>
{getSubjectName(assignment.subjectId)}
</span>
{:else}
{getSubjectName(assignment.subjectId)}
{/if}
</td>
<td>
<span class="status-badge status-active">Active</span>
</td>
<td class="date-cell">
{new Date(assignment.startDate).toLocaleDateString('fr-FR')}
</td>
<td class="actions-cell">
<button
class="btn-remove"
onclick={() => openDeleteModal(assignment)}
>
Retirer
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
>
<header class="modal-header">
<h2 id="create-modal-title">Nouvelle affectation</h2>
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">x</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="form-group">
<label for="assignment-teacher">Enseignant *</label>
<select id="assignment-teacher" bind:value={selectedTeacherId} required>
<option value="">-- Sélectionner un enseignant --</option>
{#each teachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="assignment-class">Classe *</label>
<select id="assignment-class" bind:value={selectedClassId} required>
<option value="">-- Sélectionner une classe --</option>
{#each classes as cls (cls.id)}
<option value={cls.id}>{cls.name}{cls.level ? ` (${cls.level})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="assignment-subject">Matière *</label>
<select id="assignment-subject" bind:value={selectedSubjectId} required>
<option value="">-- Sélectionner une matière --</option>
{#each subjects as subject (subject.id)}
<option value={subject.id}>{subject.name} ({subject.code})</option>
{/each}
</select>
</div>
<div class="form-hint">
L'affectation sera créée pour l'année scolaire en cours.
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !selectedTeacherId || !selectedClassId || !selectedSubjectId}
>
{#if isSubmitting}
Création...
{:else}
Affecter
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && assignmentToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Retirer l'affectation</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">x</button>
</header>
<div class="modal-body">
<p id="delete-modal-description">
Retirer <strong>{getTeacherName(assignmentToDelete.teacherId)}</strong>
de <strong>{getSubjectName(assignmentToDelete.subjectId)}</strong>
en <strong>{getClassName(assignmentToDelete.classId)}</strong> ?
</p>
<p class="delete-warning">
Les notes existantes seront conservées, mais l'enseignant ne pourra plus en ajouter.
</p>
</div>
<div class="modal-actions modal-actions-padded">
<button type="button" class="btn-secondary" onclick={closeDeleteModal} disabled={isDeleting}>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleDelete} disabled={isDeleting}>
{#if isDeleting}
Retrait...
{:else}
Retirer
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.assignments-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
/* Buttons */
.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.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-remove:hover {
background: #fee2e2;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
/* Alerts */
.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: #16a34a;
}
.alert-icon {
flex-shrink: 0;
font-weight: bold;
}
.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;
}
/* Loading & Empty */
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #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);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
/* Table */
.table-container {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
overflow-x: auto;
}
.assignments-table {
width: 100%;
border-collapse: collapse;
}
.assignments-table th {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
}
.assignments-table td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #f3f4f6;
}
.assignments-table tr:last-child td {
border-bottom: none;
}
.assignments-table tr:hover td {
background: #f9fafb;
}
.teacher-cell {
white-space: nowrap;
}
.teacher-name {
font-weight: 500;
color: #1f2937;
}
.subject-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.status-active {
background: #f0fdf4;
color: #16a34a;
}
.date-cell {
white-space: nowrap;
color: #6b7280;
}
.actions-cell {
white-space: nowrap;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
background: white;
}
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
padding: 0.75rem 1rem;
background: #eff6ff;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #3b82f6;
margin-bottom: 1.25rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.modal-actions-padded {
padding: 1rem 1.5rem;
}
/* Delete confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
@media (min-width: 768px) {
.assignments-table th:nth-child(4),
.assignments-table td:nth-child(4),
.assignments-table th:nth-child(5),
.assignments-table td:nth-child(5) {
display: table-cell;
}
}
</style>

View File

@@ -17,6 +17,28 @@
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);
@@ -24,6 +46,13 @@
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);
@@ -37,10 +66,11 @@
const classId = $derived(page.params.id);
// Load class on mount
// Load class and teachers on mount
$effect(() => {
if (classId) {
loadClass(classId);
loadClassTeachers(classId);
}
});
@@ -127,7 +157,7 @@
originalDescription = updatedClass.description;
// Clear success message after 3 seconds
window.setTimeout(() => {
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
@@ -137,6 +167,54 @@
}
}
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');
}
@@ -251,13 +329,53 @@
</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: 600px;
max-width: 700px;
margin: 0 auto;
}
@@ -484,4 +602,81 @@
.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>

View File

@@ -124,7 +124,7 @@
successMessage = 'Matière mise à jour avec succès';
// Clear success message after 3 seconds
window.setTimeout(() => {
globalThis.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {

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>