feat: Désignation de remplaçants temporaires avec corrections sécurité

Permet aux administrateurs de désigner un enseignant remplaçant pour
un autre enseignant absent, sur des classes et matières précises, pour
une période donnée. Le dashboard enseignant affiche les remplacements
actifs avec les noms de classes/matières au lieu des identifiants bruts.

Inclut les corrections de la code review :
- Requête findActiveByTenant qui excluait les remplacements en cours
  mais incluait les futurs (manquait start_date <= :at)
- Validation tenant et rôle enseignant dans le handler de désignation
  pour empêcher l'affectation cross-tenant ou de non-enseignants
- Validation structurée du payload classes (Assert\Collection + UUID)
  pour éviter les erreurs serveur sur payloads malformés
- API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
2026-02-16 14:32:37 +01:00
parent fdc26eb334
commit c856dfdcda
63 changed files with 7694 additions and 236 deletions

View File

@@ -46,6 +46,11 @@
<span class="action-label">Affectations</span>
<span class="action-hint">Enseignants et classes</span>
</a>
<a class="action-card" href="/admin/replacements">
<span class="action-icon">🔄</span>
<span class="action-label">Remplacements</span>
<span class="action-hint">Enseignants absents</span>
</a>
<a class="action-card" href="/admin/academic-year/periods">
<span class="action-icon">📅</span>
<span class="action-label">Périodes scolaires</span>

View File

@@ -1,6 +1,9 @@
<script lang="ts">
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, isAuthenticated } from '$lib/auth';
import { untrack } from 'svelte';
let {
isLoading = false,
@@ -9,6 +12,53 @@
isLoading?: boolean;
hasRealData?: boolean;
} = $props();
interface ReplacedClass {
replacementId: string;
replacedTeacherId: string;
classId: string;
subjectId: string;
className: string;
subjectName: string;
startDate: string;
endDate: string;
}
let replacedClasses = $state<ReplacedClass[]>([]);
let replacementsLoading = $state(false);
$effect(() => {
untrack(() => {
if (isAuthenticated()) {
loadReplacedClasses();
}
});
});
async function loadReplacedClasses() {
try {
replacementsLoading = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/replaced-classes`);
if (!response.ok) return;
const data = await response.json();
replacedClasses = Array.isArray(data) ? data : (data['hydra:member'] ?? []);
} catch {
// Silently fail - not critical for dashboard display
} finally {
replacementsLoading = false;
}
}
function daysRemaining(endDate: string): number {
const end = new Date(endDate);
const now = new Date();
now.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
</script>
<div class="dashboard-teacher">
@@ -35,6 +85,44 @@
</div>
</div>
{#if replacedClasses.length > 0}
<DashboardSection
title="Classes en remplacement"
subtitle="Vous remplacez actuellement un enseignant"
>
<div class="replacement-list">
{#each replacedClasses as rc}
{@const days = daysRemaining(rc.endDate)}
<div class="replacement-card">
<div class="replacement-badge">Remplacement</div>
<div class="replacement-info">
<span class="replacement-class">{rc.className}</span>
<span class="replacement-subject">{rc.subjectName}</span>
</div>
<div class="replacement-dates">
{new Date(rc.startDate).toLocaleDateString('fr-FR')}
&rarr;
{new Date(rc.endDate).toLocaleDateString('fr-FR')}
<span class="replacement-countdown" class:urgent={days <= 3}>
{#if days > 1}
({days} jours restants)
{:else if days === 1}
(1 jour restant)
{:else}
(Dernier jour)
{/if}
</span>
</div>
</div>
{/each}
</div>
</DashboardSection>
{:else if replacementsLoading}
<DashboardSection title="Classes en remplacement">
<SkeletonList items={2} message="Chargement des remplacements..." />
</DashboardSection>
{/if}
<div class="dashboard-grid">
<DashboardSection
title="Mes classes aujourd'hui"
@@ -164,6 +252,62 @@
grid-template-columns: 1fr;
}
/* Replacement section */
.replacement-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.replacement-card {
padding: 0.75rem 1rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
}
.replacement-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #3b82f6;
color: white;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.replacement-info {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.25rem;
}
.replacement-class {
font-weight: 600;
color: #1f2937;
}
.replacement-subject {
color: #4b5563;
font-size: 0.875rem;
}
.replacement-dates {
font-size: 0.8125rem;
color: #6b7280;
}
.replacement-countdown {
font-weight: 500;
color: #16a34a;
}
.replacement-countdown.urgent {
color: #dc2626;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);