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:
@@ -41,6 +41,11 @@
|
||||
<span class="action-label">Gérer les matières</span>
|
||||
<span class="action-hint">Créer et gérer</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/assignments">
|
||||
<span class="action-icon">📋</span>
|
||||
<span class="action-label">Affectations</span>
|
||||
<span class="action-hint">Enseignants et classes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
|
||||
@@ -192,8 +192,14 @@
|
||||
|
||||
{#if showAddModal}
|
||||
<div class="modal-overlay" onclick={() => { showAddModal = false; }} role="presentation">
|
||||
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { showAddModal = false; } }}
|
||||
>
|
||||
<header class="modal-header">
|
||||
<h2>Ajouter un parent/tuteur</h2>
|
||||
<button class="modal-close" onclick={() => { showAddModal = false; }}>×</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
884
frontend/src/routes/admin/assignments/+page.svelte
Normal file
884
frontend/src/routes/admin/assignments/+page.svelte
Normal 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">📋</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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