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:
388
frontend/src/routes/admin/teachers/[id]/+page.svelte
Normal file
388
frontend/src/routes/admin/teachers/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user