feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent totalement la création. Or certains cas légitimes (sorties scolaires, événements exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception, l'enseignant est bloqué et doit contacter manuellement la direction. Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction est notifiée par email. Le handler vérifie côté serveur que les règles sont réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication de fausses exceptions via l'API. La direction dispose d'un rapport filtrable par période, enseignant et type de règle.
This commit is contained in:
@@ -68,7 +68,8 @@
|
||||
{ href: '/admin/image-rights', label: "Droit à l'image" },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie' },
|
||||
{ href: '/admin/branding', label: 'Identité visuelle' },
|
||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' }
|
||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' },
|
||||
{ href: '/admin/homework-exceptions', label: 'Exceptions devoirs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface HomeworkException {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
ruleType: string;
|
||||
justification: string;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
let exceptions = $state<HomeworkException[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let filterTeacherId = $state(page.url.searchParams.get('teacherId') ?? '');
|
||||
let filterRuleType = $state(page.url.searchParams.get('ruleType') ?? '');
|
||||
let filterStartDate = $state(page.url.searchParams.get('startDate') ?? '');
|
||||
let filterEndDate = $state(page.url.searchParams.get('endDate') ?? '');
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => loadExceptions());
|
||||
});
|
||||
|
||||
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||
const members = data['hydra:member'] ?? data['member'];
|
||||
if (Array.isArray(members)) return members as T[];
|
||||
if (Array.isArray(data)) return data as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadExceptions() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/admin/homework-exceptions?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des exceptions');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
exceptions = extractCollection<HomeworkException>(data);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleFilter() {
|
||||
updateUrl();
|
||||
loadExceptions();
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
filterStartDate = '';
|
||||
filterEndDate = '';
|
||||
filterTeacherId = '';
|
||||
filterRuleType = '';
|
||||
updateUrl();
|
||||
loadExceptions();
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatRuleType(ruleType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
minimum_delay: 'Délai minimum',
|
||||
no_monday_after: 'Pas de lundi après',
|
||||
};
|
||||
return ruleType
|
||||
.split(',')
|
||||
.map((t) => labels[t] ?? t)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
// Derive unique teachers from loaded exceptions for the filter dropdown
|
||||
let uniqueTeachers = $derived.by(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const ex of exceptions) {
|
||||
if (!seen.has(ex.teacherId)) {
|
||||
seen.set(ex.teacherId, ex.teacherName);
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||
});
|
||||
|
||||
let hasFilters = $derived(
|
||||
filterStartDate !== '' || filterEndDate !== '' || filterTeacherId !== '' || filterRuleType !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Exceptions aux règles de devoirs - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="exceptions-page">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Exceptions aux règles de devoirs</h1>
|
||||
<p class="subtitle">Rapport des contournements de règles par les enseignants</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<span class="alert-icon">⚠</span>
|
||||
{error}
|
||||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="filters-section">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label for="filter-start">Du</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-start"
|
||||
bind:value={filterStartDate}
|
||||
onchange={handleFilter}
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-end">Au</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-end"
|
||||
bind:value={filterEndDate}
|
||||
onchange={handleFilter}
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-teacher">Enseignant</label>
|
||||
<select id="filter-teacher" bind:value={filterTeacherId} onchange={handleFilter}>
|
||||
<option value="">Tous les enseignants</option>
|
||||
{#each uniqueTeachers as teacher (teacher.id)}
|
||||
<option value={teacher.id}>{teacher.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-rule">Règle</label>
|
||||
<select id="filter-rule" bind:value={filterRuleType} onchange={handleFilter}>
|
||||
<option value="">Toutes les règles</option>
|
||||
<option value="minimum_delay">Délai minimum</option>
|
||||
<option value="no_monday_after">Pas de lundi après</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if hasFilters}
|
||||
<button class="btn-clear" onclick={handleClearFilters}>Effacer les filtres</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des exceptions...</p>
|
||||
</div>
|
||||
{:else if exceptions.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">✅</span>
|
||||
<h2>Aucune exception</h2>
|
||||
<p>
|
||||
{#if hasFilters}
|
||||
Aucune exception ne correspond à vos critères de recherche.
|
||||
{:else}
|
||||
Aucun enseignant n'a demandé d'exception aux règles de devoirs.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results-summary">
|
||||
{exceptions.length} exception{exceptions.length > 1 ? 's' : ''} trouvée{exceptions.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div class="exceptions-list">
|
||||
{#each exceptions as ex (ex.id)}
|
||||
<div class="exception-card">
|
||||
<div class="exception-header">
|
||||
<h3 class="exception-homework">{ex.homeworkTitle}</h3>
|
||||
<span class="exception-rule">{formatRuleType(ex.ruleType)}</span>
|
||||
</div>
|
||||
<div class="exception-meta">
|
||||
<span class="meta-item" title="Enseignant">
|
||||
<span class="meta-icon">👤</span>
|
||||
{ex.teacherName}
|
||||
</span>
|
||||
<span class="meta-item" title="Date de l'exception">
|
||||
<span class="meta-icon">📅</span>
|
||||
{formatDate(ex.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="exception-justification">
|
||||
<span class="justification-label">Justification :</span>
|
||||
<p class="justification-text">{ex.justification}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.exceptions-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-group input,
|
||||
.filter-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.exceptions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.exception-card {
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.exception-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-homework {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.exception-rule {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exception-meta {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.exception-justification {
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.justification-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.justification-text {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user