feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

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:
2026-03-19 21:58:56 +01:00
parent d34d31976f
commit 9b868ae5c4
34 changed files with 3467 additions and 13 deletions

View File

@@ -4,6 +4,7 @@
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
@@ -20,6 +21,9 @@
className: string | null;
subjectName: string | null;
hasRuleOverride: boolean;
hasRuleException?: boolean;
ruleExceptionJustification?: string | null;
ruleExceptionRuleType?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -105,6 +109,15 @@
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
let ruleBlockedSuggestedDates = $state<string[]>([]);
// Exception justification viewing
let showJustificationModal = $state(false);
let justificationHomework = $state<Homework | null>(null);
// Exception request modal
let showExceptionModal = $state(false);
let exceptionWarnings = $state<RuleWarning[]>([]);
let isSubmittingException = $state(false);
// Inline date validation for hard mode
let dueDateError = $state<string | null>(null);
@@ -452,6 +465,55 @@
showCreateModal = true;
}
function handleRequestException() {
exceptionWarnings = ruleBlockedWarnings;
showRuleBlockedModal = false;
showExceptionModal = true;
}
async function handleExceptionSubmit(justification: string, ruleTypes: string[]) {
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
try {
isSubmittingException = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/with-exception`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classId: newClassId,
subjectId: newSubjectId,
title: newTitle.trim(),
description: newDescription.trim() || null,
dueDate: newDueDate,
justification,
ruleTypes,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const msg =
errorData?.['hydra:description'] ??
errorData?.message ??
errorData?.detail ??
`Erreur lors de la création avec exception (${response.status})`;
throw new Error(msg);
}
showExceptionModal = false;
exceptionWarnings = [];
ruleBlockedWarnings = [];
ruleBlockedSuggestedDates = [];
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création avec exception';
} finally {
isSubmittingException = false;
}
}
function handleBlockedClose() {
const firstSuggested = ruleBlockedSuggestedDates[0];
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
@@ -717,7 +779,14 @@
<div class="homework-header">
<h3 class="homework-title">{hw.title}</h3>
<div class="homework-badges">
{#if hw.hasRuleOverride}
{#if hw.hasRuleException}
<button
type="button"
class="badge-rule-exception"
title="Créé avec une exception aux règles — cliquer pour voir la justification"
onclick={() => { justificationHomework = hw; showJustificationModal = true; }}
>&#9888; Exception</button>
{:else if hw.hasRuleOverride}
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">&#9888;</span>
{/if}
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
@@ -1158,9 +1227,63 @@
suggestedDates={ruleBlockedSuggestedDates}
onSelectDate={handleBlockedSelectDate}
onClose={handleBlockedClose}
onRequestException={handleRequestException}
/>
{/if}
<!-- Exception Request Modal -->
{#if showExceptionModal && exceptionWarnings.length > 0}
<ExceptionRequestModal
warnings={exceptionWarnings}
onSubmit={handleExceptionSubmit}
onClose={() => {
showExceptionModal = false;
exceptionWarnings = [];
}}
isSubmitting={isSubmittingException}
/>
{/if}
<!-- Justification Viewing Modal -->
{#if showJustificationModal && justificationHomework}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={() => { showJustificationModal = false; justificationHomework = null; }} role="presentation">
<div
class="modal modal-confirm"
role="dialog"
aria-modal="true"
aria-labelledby="justification-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') { showJustificationModal = false; justificationHomework = null; } }}
>
<header class="modal-header modal-header-exception-view">
<h2 id="justification-title">Exception aux règles</h2>
<button class="modal-close" onclick={() => { showJustificationModal = false; justificationHomework = null; }} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<div class="justification-info">
<span class="justification-info-label">Devoir :</span>
<span>{justificationHomework.title}</span>
<span class="justification-info-label">Règle contournée :</span>
<span>{justificationHomework.ruleExceptionRuleType ?? 'N/A'}</span>
</div>
<div class="justification-content">
<span class="justification-content-label">Justification :</span>
<p class="justification-content-text">{justificationHomework.ruleExceptionJustification ?? 'Non disponible'}</p>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={() => { showJustificationModal = false; justificationHomework = null; }}>
Fermer
</button>
</div>
</div>
</div>
{/if}
<style>
.homework-page {
padding: 1.5rem;
@@ -1713,6 +1836,73 @@
cursor: help;
}
.badge-rule-exception {
font-size: 0.7rem;
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 9999px;
padding: 0.125rem 0.5rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.badge-rule-exception:hover {
background: #fef3c7;
border-color: #f59e0b;
}
/* Justification viewing modal */
.modal-header-exception-view {
background: #f59e0b;
color: white;
border-radius: 0.75rem 0.75rem 0 0;
}
.modal-header-exception-view h2 {
margin: 0;
font-size: 1.1rem;
}
.justification-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: #f9fafb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.justification-info-label {
font-weight: 600;
color: #374151;
}
.justification-content {
padding: 0.75rem 1rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.375rem;
}
.justification-content-label {
font-size: 0.75rem;
font-weight: 600;
color: #92400e;
text-transform: uppercase;
}
.justification-content-text {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #374151;
line-height: 1.5;
font-style: italic;
}
/* Rule Warning Modal */
.modal-header-warning {
border-bottom: 3px solid #f59e0b;