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:
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let {
|
||||
warnings,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting = false,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
onSubmit: (justification: string, ruleTypes: string[]) => void;
|
||||
onClose: () => void;
|
||||
isSubmitting?: boolean;
|
||||
} = $props();
|
||||
|
||||
let justification = $state('');
|
||||
let charCount = $derived(justification.length);
|
||||
let isValid = $derived(charCount >= 20);
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (modalElement) {
|
||||
modalElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid || isSubmitting) return;
|
||||
const ruleTypes = warnings.map((w) => w.ruleType);
|
||||
onSubmit(justification, ruleTypes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={onClose} role="presentation">
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="exception-request-title"
|
||||
aria-describedby="exception-request-description"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<header class="modal-header modal-header-exception">
|
||||
<h2 id="exception-request-title">Demander une exception</h2>
|
||||
<button class="modal-close" onclick={onClose} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="modal-body"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<p id="exception-request-description">
|
||||
Ce devoir enfreint les règles suivantes. Veuillez justifier votre demande d'exception :
|
||||
</p>
|
||||
|
||||
<ul class="rule-list">
|
||||
{#each warnings as warning}
|
||||
<li class="rule-item">
|
||||
<span class="rule-icon">🚫</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exception-justification">
|
||||
Justification <span class="required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="exception-justification"
|
||||
bind:value={justification}
|
||||
placeholder="Expliquez pourquoi ce devoir nécessite une exception aux règles..."
|
||||
rows="4"
|
||||
minlength="20"
|
||||
required
|
||||
></textarea>
|
||||
<div class="char-counter" class:char-counter-valid={isValid} class:char-counter-invalid={charCount > 0 && !isValid}>
|
||||
{charCount}/20 caractères minimum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="exception-notice">
|
||||
Le devoir sera créé immédiatement. La direction sera notifiée de cette exception.
|
||||
</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={onClose} disabled={isSubmitting}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={!isValid || isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
Création...
|
||||
{:else}
|
||||
Créer avec exception
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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: 32rem;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.modal-header-exception {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.modal-header-exception h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.rule-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-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.char-counter {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.char-counter-valid {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.char-counter-invalid {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.exception-notice {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.75rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
@@ -10,11 +10,13 @@
|
||||
suggestedDates = [],
|
||||
onSelectDate,
|
||||
onClose,
|
||||
onRequestException,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
suggestedDates: string[];
|
||||
onSelectDate: (date: string) => void;
|
||||
onClose: () => void;
|
||||
onRequestException?: () => void;
|
||||
} = $props();
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
@@ -86,11 +88,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="rule-blocked-exception">
|
||||
<span class="exception-link-placeholder">
|
||||
Besoin d'une exception ? Contactez votre administration.
|
||||
</span>
|
||||
</p>
|
||||
{#if onRequestException}
|
||||
<div class="rule-blocked-exception">
|
||||
<button type="button" class="btn-exception" onclick={onRequestException}>
|
||||
Demander une exception
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -202,10 +206,39 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exception-link-placeholder {
|
||||
color: #6b7280;
|
||||
.btn-exception {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-exception:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
|
||||
Reference in New Issue
Block a user