feat: Bloquer la création de devoirs non conformes en mode hard
Les établissements utilisant le mode "Hard" des règles de devoirs empêchent désormais les enseignants de créer des devoirs hors règles. Contrairement au mode "Soft" (avertissement avec possibilité de passer outre), le mode "Hard" est un blocage strict : même acknowledgeWarning ne permet pas de contourner. L'API retourne 422 (au lieu de 409 pour le soft) avec des dates conformes suggérées calculées via le calendrier scolaire (weekends, fériés, vacances exclus). Le frontend affiche un modal de blocage avec les raisons, des dates cliquables, et une validation client inline qui empêche la soumission de dates non conformes.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let {
|
||||
warnings,
|
||||
suggestedDates = [],
|
||||
onSelectDate,
|
||||
onClose,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
suggestedDates: string[];
|
||||
onSelectDate: (date: string) => void;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (modalElement) {
|
||||
modalElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" role="presentation">
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="modal modal-confirm"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="rule-blocked-title"
|
||||
aria-describedby="rule-blocked-description"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<header class="modal-header modal-header-blocked">
|
||||
<h2 id="rule-blocked-title">Impossible de créer ce devoir</h2>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<p id="rule-blocked-description">
|
||||
Les règles de votre établissement interdisent la création de ce devoir :
|
||||
</p>
|
||||
<ul class="rule-blocked-list">
|
||||
{#each warnings as warning}
|
||||
<li class="rule-blocked-item">
|
||||
<span class="rule-blocked-icon">🚫</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if suggestedDates.length > 0}
|
||||
<div class="suggested-dates" role="group" aria-label="Dates conformes suggérées">
|
||||
<p class="suggested-dates-label">Dates conformes suggérées :</p>
|
||||
<div class="suggested-dates-list">
|
||||
{#each suggestedDates as date}
|
||||
<button
|
||||
type="button"
|
||||
class="suggested-date-btn"
|
||||
aria-label="Sélectionner {formatDate(date)}"
|
||||
onclick={() => onSelectDate(date)}
|
||||
>
|
||||
{formatDate(date)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="rule-blocked-exception">
|
||||
<span class="exception-link-placeholder">
|
||||
Besoin d'une exception ? Contactez votre administration.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={onClose}>
|
||||
Modifier la date
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
max-width: 500px;
|
||||
width: 95%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.modal-header-blocked {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.modal-header-blocked h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.rule-blocked-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.rule-blocked-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.rule-blocked-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggested-dates {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background: #f0fdf4;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.suggested-dates-label {
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.suggested-dates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.suggested-date-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
background: white;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.suggested-date-btn:hover {
|
||||
background: #dcfce7;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.rule-blocked-exception {
|
||||
margin: 1rem 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exception-link-placeholder {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -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 RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -94,11 +95,19 @@
|
||||
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
|
||||
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
|
||||
|
||||
// Rule warning modal
|
||||
// Rule warning modal (soft mode)
|
||||
let showRuleWarningModal = $state(false);
|
||||
let ruleWarnings = $state<RuleWarning[]>([]);
|
||||
let ruleConformMinDate = $state('');
|
||||
|
||||
// Rule blocked modal (hard mode)
|
||||
let showRuleBlockedModal = $state(false);
|
||||
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
|
||||
let ruleBlockedSuggestedDates = $state<string[]>([]);
|
||||
|
||||
// Inline date validation for hard mode
|
||||
let dueDateError = $state<string | null>(null);
|
||||
|
||||
// Class filter
|
||||
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
|
||||
|
||||
@@ -300,6 +309,7 @@
|
||||
newDescription = '';
|
||||
newDueDate = '';
|
||||
ruleConformMinDate = '';
|
||||
dueDateError = null;
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
@@ -308,6 +318,8 @@
|
||||
|
||||
async function handleCreate(acknowledgeWarning = false) {
|
||||
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
|
||||
dueDateError = validateDueDateLocally(newDueDate);
|
||||
if (dueDateError) return;
|
||||
|
||||
try {
|
||||
isSubmitting = true;
|
||||
@@ -326,6 +338,17 @@
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data?.type === 'homework_rules_blocked' && Array.isArray(data.warnings)) {
|
||||
ruleBlockedWarnings = data.warnings;
|
||||
ruleBlockedSuggestedDates = Array.isArray(data.suggestedDates) ? data.suggestedDates : [];
|
||||
showCreateModal = false;
|
||||
showRuleBlockedModal = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 409) {
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data?.type === 'homework_rules_warning' && Array.isArray(data.warnings)) {
|
||||
@@ -349,6 +372,9 @@
|
||||
closeCreateModal();
|
||||
showRuleWarningModal = false;
|
||||
ruleWarnings = [];
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
await loadHomeworks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la création';
|
||||
@@ -399,6 +425,44 @@
|
||||
newDueDate = ruleConformMinDate;
|
||||
}
|
||||
|
||||
function validateDueDateLocally(dateStr: string): string | null {
|
||||
if (!dateStr) return null;
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
const day = date.getDay();
|
||||
if (day === 0 || day === 6) {
|
||||
return 'Les devoirs ne peuvent pas être fixés un weekend.';
|
||||
}
|
||||
if (ruleConformMinDate && dateStr < ruleConformMinDate) {
|
||||
return 'Cette date ne respecte pas les règles de l\'établissement. Choisissez une date ultérieure.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleDueDateChange(dateStr: string) {
|
||||
newDueDate = dateStr;
|
||||
dueDateError = validateDueDateLocally(dateStr);
|
||||
}
|
||||
|
||||
function handleBlockedSelectDate(date: string) {
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
ruleConformMinDate = date;
|
||||
newDueDate = date;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function handleBlockedClose() {
|
||||
const firstSuggested = ruleBlockedSuggestedDates[0];
|
||||
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
|
||||
showRuleBlockedModal = false;
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
ruleConformMinDate = conformDate;
|
||||
newDueDate = conformDate;
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
// --- Edit ---
|
||||
function openEditModal(hw: Homework) {
|
||||
editHomework = hw;
|
||||
@@ -779,8 +843,17 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hw-due-date">Date d'échéance *</label>
|
||||
<input type="date" id="hw-due-date" bind:value={newDueDate} required min={ruleConformMinDate || minDueDate} />
|
||||
{#if ruleConformMinDate}
|
||||
<input
|
||||
type="date"
|
||||
id="hw-due-date"
|
||||
value={newDueDate}
|
||||
oninput={(e) => handleDueDateChange((e.target as HTMLInputElement).value)}
|
||||
required
|
||||
min={ruleConformMinDate || minDueDate}
|
||||
/>
|
||||
{#if dueDateError}
|
||||
<small class="form-hint form-hint-error">{dueDateError}</small>
|
||||
{:else if ruleConformMinDate}
|
||||
<small class="form-hint form-hint-rule">
|
||||
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</small>
|
||||
@@ -1078,6 +1151,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rule Blocked Modal (Hard Mode) -->
|
||||
{#if showRuleBlockedModal && ruleBlockedWarnings.length > 0}
|
||||
<RuleBlockedModal
|
||||
warnings={ruleBlockedWarnings}
|
||||
suggestedDates={ruleBlockedSuggestedDates}
|
||||
onSelectDate={handleBlockedSelectDate}
|
||||
onClose={handleBlockedClose}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.homework-page {
|
||||
padding: 1.5rem;
|
||||
@@ -1611,6 +1694,11 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-hint-error {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Rule override badge */
|
||||
.homework-badges {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user