Files
Classeo/frontend/src/routes/dashboard/teacher/homework/+page.svelte
Mathias STRASSER 9b868ae5c4
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
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.
2026-03-20 11:18:04 +01:00

1945 lines
49 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
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';
interface Homework {
id: string;
classId: string;
subjectId: string;
teacherId: string;
title: string;
description: string | null;
dueDate: string;
status: string;
className: string | null;
subjectName: string | null;
hasRuleOverride: boolean;
hasRuleException?: boolean;
ruleExceptionJustification?: string | null;
ruleExceptionRuleType?: string | null;
createdAt: string;
updatedAt: string;
}
interface RuleWarning {
ruleType: string;
message: string;
params: Record<string, unknown>;
}
interface TeacherAssignment {
id: string;
classId: string;
subjectId: string;
status: string;
}
interface SchoolClass {
id: string;
name: string;
}
interface Subject {
id: string;
name: string;
code: string;
}
// State
let homeworks = $state<Homework[]>([]);
let assignments = $state<TeacherAssignment[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Create modal
let showCreateModal = $state(false);
let newClassId = $state('');
let newSubjectId = $state('');
let newTitle = $state('');
let newDescription = $state('');
let newDueDate = $state('');
let isSubmitting = $state(false);
// Edit modal
let showEditModal = $state(false);
let editHomework = $state<Homework | null>(null);
let editTitle = $state('');
let editDescription = $state('');
let editDueDate = $state('');
let isUpdating = $state(false);
// Delete modal
let showDeleteModal = $state(false);
let homeworkToDelete = $state<Homework | null>(null);
let isDeleting = $state(false);
// Duplicate modal
let showDuplicateModal = $state(false);
let homeworkToDuplicate = $state<Homework | null>(null);
let selectedTargetClassIds = $state<string[]>([]);
let dueDatesByClass = $state<Record<string, string>>({});
let isDuplicating = $state(false);
let duplicateError = $state<string | null>(null);
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
// 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[]>([]);
// 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);
// Class filter
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
// Derived: available subjects for selected class
let availableSubjectsForCreate = $derived.by(() => {
if (!newClassId) return [];
const assignedSubjectIds = assignments
.filter((a) => a.classId === newClassId && a.status === 'active')
.map((a) => a.subjectId);
return subjects.filter((s) => assignedSubjectIds.includes(s.id));
});
// Derived: minimum due date (tomorrow)
let minDueDate = $derived.by(() => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const y = tomorrow.getFullYear();
const m = String(tomorrow.getMonth() + 1).padStart(2, '0');
const d = String(tomorrow.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
});
// Load on mount
$effect(() => {
untrack(() => loadAll());
});
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 loadAssignments() {
const userId = await getAuthenticatedUserId();
if (!userId) return;
const apiUrl = getApiBaseUrl();
const res = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`);
if (!res.ok) throw new Error('Erreur lors du chargement des affectations');
const data = await res.json();
assignments = extractCollection<TeacherAssignment>(data);
}
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
// classesRes/subjectsRes are used below; loadAssignments & loadHomeworks
// apply side-effects internally and their results are intentionally ignored
const [classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`),
loadAssignments().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations';
}),
loadHomeworks().catch((e) => {
homeworks = [];
totalItems = 0;
error = e instanceof Error ? e.message : 'Erreur inconnue';
}),
]);
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
if (!subjectsRes.ok) throw new Error('Erreur lors du chargement des matières');
const [classesData, subjectsData] = await Promise.all([
classesRes.json(),
subjectsRes.json(),
]);
classes = extractCollection<SchoolClass>(classesData);
subjects = extractCollection<Subject>(subjectsData);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
let loadAbortController: AbortController | null = null;
async function loadHomeworks() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des devoirs');
}
const data = await response.json();
homeworks = extractCollection<Homework>(data);
totalItems = (data as Record<string, unknown>)['hydra:totalItems'] as number ?? (data as Record<string, unknown>)['totalItems'] as number ?? homeworks.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
throw e;
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadHomeworks().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadHomeworks().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
function getClassName(classId: string): string {
return classes.find((c) => c.id === classId)?.name ?? classId;
}
function getSubjectName(subjectId: string): string {
return subjects.find((s) => s.id === subjectId)?.name ?? subjectId;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
function isOverdue(dueDate: string): boolean {
const due = new Date(dueDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
due.setHours(0, 0, 0, 0);
return due < today;
}
// Available classes = classes the teacher is assigned to
let availableClasses = $derived.by(() => {
const assignedClassIds = [...new Set(assignments.filter((a) => a.status === 'active').map((a) => a.classId))];
return classes.filter((c) => assignedClassIds.includes(c.id));
});
// Available target classes for duplication (same subject, exclude source class)
let availableTargetClasses = $derived.by(() => {
if (!homeworkToDuplicate) return [];
const sourceSubjectId = homeworkToDuplicate.subjectId;
const sourceClassId = homeworkToDuplicate.classId;
const assignedClassIds = assignments
.filter((a) => a.status === 'active' && a.subjectId === sourceSubjectId)
.map((a) => a.classId);
return classes.filter((c) => assignedClassIds.includes(c.id) && c.id !== sourceClassId);
});
function handleClassFilter(newClassId: string) {
filterClassId = newClassId;
currentPage = 1;
updateUrl();
loadHomeworks().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
// --- Create ---
function openCreateModal() {
showCreateModal = true;
newClassId = '';
newSubjectId = '';
newTitle = '';
newDescription = '';
newDueDate = '';
ruleConformMinDate = '';
dueDateError = null;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate(acknowledgeWarning = false) {
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
dueDateError = validateDueDateLocally(newDueDate);
if (dueDateError) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classId: newClassId,
subjectId: newSubjectId,
title: newTitle.trim(),
description: newDescription.trim() || null,
dueDate: newDueDate,
acknowledgeWarning,
}),
});
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)) {
ruleWarnings = data.warnings;
showCreateModal = false;
showRuleWarningModal = true;
return;
}
}
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 (${response.status})`;
throw new Error(msg);
}
closeCreateModal();
showRuleWarningModal = false;
ruleWarnings = [];
showRuleBlockedModal = false;
ruleBlockedWarnings = [];
ruleBlockedSuggestedDates = [];
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function handleContinueDespiteWarning() {
showRuleWarningModal = false;
handleCreate(true);
}
function computeConformMinDate(warnings: RuleWarning[]): string {
let minDate = new Date();
minDate.setDate(minDate.getDate() + 1); // au moins demain
for (const w of warnings) {
if (w.ruleType === 'minimum_delay' && typeof w.params['days'] === 'number') {
const ruleMin = new Date();
ruleMin.setDate(ruleMin.getDate() + (w.params['days'] as number));
if (ruleMin > minDate) minDate = ruleMin;
}
if (w.ruleType === 'no_monday_after') {
// Si le lundi est interdit (deadline dépassée), proposer mardi
// car le problème ne concerne que les devoirs pour lundi
const nextTuesday = new Date();
nextTuesday.setDate(nextTuesday.getDate() + ((9 - nextTuesday.getDay()) % 7 || 7));
if (nextTuesday > minDate) minDate = nextTuesday;
}
}
// Sauter les weekends
const day = minDate.getDay();
if (day === 0) minDate.setDate(minDate.getDate() + 1);
if (day === 6) minDate.setDate(minDate.getDate() + 2);
const y = minDate.getFullYear();
const m = String(minDate.getMonth() + 1).padStart(2, '0');
const d = String(minDate.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function handleModifyDate() {
ruleConformMinDate = computeConformMinDate(ruleWarnings);
showRuleWarningModal = false;
showCreateModal = true;
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 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);
showRuleBlockedModal = false;
ruleBlockedWarnings = [];
ruleBlockedSuggestedDates = [];
ruleConformMinDate = conformDate;
newDueDate = conformDate;
showCreateModal = true;
}
// --- Edit ---
function openEditModal(hw: Homework) {
editHomework = hw;
editTitle = hw.title;
editDescription = hw.description ?? '';
editDueDate = hw.dueDate;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editHomework = null;
}
async function handleUpdate() {
if (!editHomework || !editTitle.trim() || !editDueDate) return;
try {
isUpdating = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({
title: editTitle.trim(),
description: editDescription.trim() || null,
dueDate: editDueDate,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const msg =
errorData?.['hydra:description'] ??
errorData?.message ??
errorData?.detail ??
`Erreur lors de la modification (${response.status})`;
throw new Error(msg);
}
closeEditModal();
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
} finally {
isUpdating = false;
}
}
// --- Duplicate ---
function openDuplicateModal(hw: Homework) {
homeworkToDuplicate = hw;
selectedTargetClassIds = [];
dueDatesByClass = {};
duplicateError = null;
duplicateValidationResults = [];
duplicateWarnings = [];
showDuplicateModal = true;
}
function closeDuplicateModal() {
showDuplicateModal = false;
homeworkToDuplicate = null;
}
function toggleTargetClass(classId: string) {
if (selectedTargetClassIds.includes(classId)) {
selectedTargetClassIds = selectedTargetClassIds.filter((id) => id !== classId);
const { [classId]: _, ...rest } = dueDatesByClass;
dueDatesByClass = rest;
} else {
selectedTargetClassIds = [...selectedTargetClassIds, classId];
}
}
async function handleDuplicate() {
if (!homeworkToDuplicate || selectedTargetClassIds.length === 0) return;
try {
isDuplicating = true;
duplicateError = null;
duplicateValidationResults = [];
const apiUrl = getApiBaseUrl();
const dueDates: Record<string, string> = {};
for (const classId of selectedTargetClassIds) {
if (dueDatesByClass[classId]) {
dueDates[classId] = dueDatesByClass[classId];
}
}
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDuplicate.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetClassIds: selectedTargetClassIds,
dueDates: Object.keys(dueDates).length > 0 ? dueDates : undefined,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
if (errorData?.results) {
duplicateValidationResults = errorData.results;
duplicateError = 'Certaines classes ne passent pas la validation.';
return;
}
throw new Error(
errorData?.['hydra:description'] ??
errorData?.error ??
errorData?.message ??
`Erreur lors de la duplication (${response.status})`,
);
}
const responseData = await response.json();
if (responseData?.warnings?.length > 0) {
duplicateWarnings = responseData.warnings;
}
closeDuplicateModal();
await loadHomeworks();
} catch (e) {
duplicateError = e instanceof Error ? e.message : 'Erreur lors de la duplication';
} finally {
isDuplicating = false;
}
}
// --- Delete ---
function openDeleteModal(hw: Homework) {
homeworkToDelete = hw;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
homeworkToDelete = null;
}
async function handleDelete() {
if (!homeworkToDelete) return;
try {
isDeleting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDelete.id}`, {
method: 'DELETE',
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
const msg =
errorData?.['hydra:description'] ??
errorData?.message ??
errorData?.detail ??
`Erreur lors de la suppression (${response.status})`;
throw new Error(msg);
}
closeDeleteModal();
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
} finally {
isDeleting = false;
}
}
</script>
<svelte:head>
<title>Mes devoirs - Classeo</title>
</svelte:head>
<div class="homework-page">
<header class="page-header">
<div class="header-content">
<h1>Mes devoirs</h1>
<p class="subtitle">Créez et gérez les devoirs pour vos classes</p>
</div>
<button class="btn-primary" onclick={openCreateModal} disabled={availableClasses.length === 0}>
<span class="btn-icon">+</span>
Nouveau devoir
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">&#9888;</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>&times;</button>
</div>
{/if}
{#if duplicateWarnings.length > 0}
<div class="alert alert-warning">
<span class="alert-icon">&#9888;</span>
<div>
<strong>Duplication effectuée avec avertissements :</strong>
<ul class="warning-list">
{#each duplicateWarnings as w}
<li>{getClassName(w.classId)} : {w.warning}</li>
{/each}
</ul>
</div>
<button class="alert-close" onclick={() => (duplicateWarnings = [])}>&times;</button>
</div>
{/if}
<div class="filters-row">
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
<div class="class-filter">
<select
value={filterClassId}
aria-label="Filtrer par classe"
onchange={(e) => handleClassFilter((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les classes</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
</div>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des devoirs...</p>
</div>
{:else if homeworks.length === 0}
<div class="empty-state">
<span class="empty-icon">&#128218;</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucun devoir ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucun devoir</h2>
<p>Commencez par créer votre premier devoir</p>
<button class="btn-primary" onclick={openCreateModal} disabled={availableClasses.length === 0}>
Créer un devoir
</button>
{/if}
</div>
{:else}
<div class="homework-list">
{#each homeworks as hw (hw.id)}
<div class="homework-card" class:overdue={isOverdue(hw.dueDate)}>
<div class="homework-header">
<h3 class="homework-title">{hw.title}</h3>
<div class="homework-badges">
{#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'}>
{hw.status === 'published' ? 'Publié' : 'Supprimé'}
</span>
</div>
</div>
<div class="homework-meta">
<span class="meta-item" title="Classe">
<span class="meta-icon">&#127979;</span>
{hw.className ?? getClassName(hw.classId)}
</span>
<span class="meta-item" title="Matière">
<span class="meta-icon">&#128214;</span>
{hw.subjectName ?? getSubjectName(hw.subjectId)}
</span>
<span class="meta-item" class:overdue-date={isOverdue(hw.dueDate)} title="Date d'échéance">
<span class="meta-icon">&#128197;</span>
{formatDate(hw.dueDate)}
</span>
</div>
{#if hw.description}
<p class="homework-description">{hw.description}</p>
{/if}
{#if hw.status === 'published'}
<div class="homework-actions">
<button class="btn-secondary btn-sm" onclick={() => openEditModal(hw)}>
Modifier
</button>
<button class="btn-secondary btn-sm" onclick={() => openDuplicateModal(hw)}>
Dupliquer
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(hw)}>
Supprimer
</button>
</div>
{/if}
</div>
{/each}
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
>
<header class="modal-header">
<h2 id="create-modal-title">Nouveau devoir</h2>
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">&times;</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="form-group">
<label for="hw-class">Classe *</label>
<select
id="hw-class"
bind:value={newClassId}
required
onchange={() => (newSubjectId = '')}
>
<option value="">-- Sélectionner une classe --</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="hw-subject">Matière *</label>
<select id="hw-subject" bind:value={newSubjectId} required disabled={!newClassId}>
<option value="">-- Sélectionner une matière --</option>
{#each availableSubjectsForCreate as subj (subj.id)}
<option value={subj.id}>{subj.name}</option>
{/each}
</select>
{#if newClassId && availableSubjectsForCreate.length === 0}
<small class="form-hint form-hint-warning">Aucune matière affectée pour cette classe</small>
{/if}
</div>
<div class="form-group">
<label for="hw-title">Titre *</label>
<input
type="text"
id="hw-title"
bind:value={newTitle}
placeholder="ex: Exercices chapitre 5"
required
minlength="2"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="hw-description">Description</label>
<textarea
id="hw-description"
bind:value={newDescription}
placeholder="Consignes, pages à lire, liens utiles..."
rows="4"
></textarea>
</div>
<div class="form-group">
<label for="hw-due-date">Date d'échéance *</label>
<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>
{:else}
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
{/if}
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !newClassId || !newSubjectId || !newTitle.trim() || !newDueDate}
>
{#if isSubmitting}
Création...
{:else}
Créer le devoir
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Modal -->
{#if showEditModal && editHomework}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeEditModal(); }}
>
<header class="modal-header">
<h2 id="edit-modal-title">Modifier le devoir</h2>
<button class="modal-close" onclick={closeEditModal} aria-label="Fermer">&times;</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleUpdate();
}}
>
<div class="form-info">
<span class="info-label">Classe :</span>
<span>{editHomework.className ?? getClassName(editHomework.classId)}</span>
<span class="info-label">Matière :</span>
<span>{editHomework.subjectName ?? getSubjectName(editHomework.subjectId)}</span>
</div>
<div class="form-group">
<label for="edit-title">Titre *</label>
<input
type="text"
id="edit-title"
bind:value={editTitle}
required
minlength="2"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea
id="edit-description"
bind:value={editDescription}
placeholder="Consignes, pages à lire, liens utiles..."
rows="4"
></textarea>
</div>
<div class="form-group">
<label for="edit-due-date">Date d'échéance *</label>
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeEditModal} disabled={isUpdating}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isUpdating || !editTitle.trim() || !editDueDate}
>
{#if isUpdating}
Enregistrement...
{:else}
Enregistrer
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && homeworkToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Supprimer le devoir</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<p id="delete-modal-description">
Êtes-vous sûr de vouloir supprimer le devoir <strong>{homeworkToDelete.title}</strong> ?
</p>
<p class="delete-warning">Les élèves et parents ne verront plus ce devoir.</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeDeleteModal} disabled={isDeleting}>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleDelete} disabled={isDeleting}>
{#if isDeleting}
Suppression...
{:else}
Supprimer
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Duplicate Modal -->
{#if showDuplicateModal && homeworkToDuplicate}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDuplicateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="duplicate-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDuplicateModal(); }}
>
<header class="modal-header">
<h2 id="duplicate-modal-title">Dupliquer le devoir</h2>
<button class="modal-close" onclick={closeDuplicateModal} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<div class="form-info">
<span class="info-label">Devoir :</span>
<span>{homeworkToDuplicate.title}</span>
<span class="info-label">Classe source :</span>
<span>{homeworkToDuplicate.className ?? getClassName(homeworkToDuplicate.classId)}</span>
<span class="info-label">Matière :</span>
<span>{homeworkToDuplicate.subjectName ?? getSubjectName(homeworkToDuplicate.subjectId)}</span>
</div>
{#if duplicateError}
<div class="alert alert-error" style="margin-bottom: 1rem;">
<span class="alert-icon">&#9888;</span>
{duplicateError}
</div>
{/if}
{#if availableTargetClasses.length === 0}
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
{:else}
<div class="form-group" role="group" aria-label="Classes cibles">
<span class="field-label">Classes cibles *</span>
<div class="checkbox-list">
{#each availableTargetClasses as cls (cls.id)}
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
<div class="checkbox-item" class:validation-error={validationResult && !validationResult.valid}>
<label class="checkbox-label">
<input
type="checkbox"
checked={selectedTargetClassIds.includes(cls.id)}
onchange={() => toggleTargetClass(cls.id)}
/>
{cls.name}
</label>
{#if selectedTargetClassIds.includes(cls.id)}
<div class="due-date-inline">
<label for="due-{cls.id}">Date :</label>
<input
type="date"
id="due-{cls.id}"
value={dueDatesByClass[cls.id] ?? homeworkToDuplicate.dueDate}
min={minDueDate}
onchange={(e) => {
dueDatesByClass = { ...dueDatesByClass, [cls.id]: (e.target as HTMLInputElement).value };
}}
/>
</div>
{/if}
{#if validationResult && !validationResult.valid}
<small class="form-hint form-hint-warning">{validationResult.error}</small>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeDuplicateModal} disabled={isDuplicating}>
Annuler
</button>
<button
type="button"
class="btn-primary"
onclick={handleDuplicate}
disabled={isDuplicating || selectedTargetClassIds.length === 0}
>
{#if isDuplicating}
Duplication...
{:else}
Dupliquer ({selectedTargetClassIds.length} classe{selectedTargetClassIds.length > 1 ? 's' : ''})
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Rule Warning Modal -->
{#if showRuleWarningModal && ruleWarnings.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" role="presentation">
<div
class="modal modal-confirm"
role="alertdialog"
aria-modal="true"
aria-labelledby="rule-warning-title"
aria-describedby="rule-warning-description"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') handleModifyDate(); }}
>
<header class="modal-header modal-header-warning">
<h2 id="rule-warning-title">Avertissement</h2>
</header>
<div class="modal-body">
<p id="rule-warning-description">
Ce devoir ne respecte pas les règles configurées par votre établissement :
</p>
<ul class="rule-warning-list">
{#each ruleWarnings as warning}
<li class="rule-warning-item">
<span class="rule-warning-icon">&#9888;</span>
<span>{warning.message}</span>
</li>
{/each}
</ul>
<p class="rule-warning-notice">Votre choix sera enregistré.</p>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={handleModifyDate} disabled={isSubmitting}>
Modifier la date
</button>
<button type="button" class="btn-primary" onclick={handleContinueDespiteWarning} disabled={isSubmitting}>
{#if isSubmitting}
Création...
{:else}
Continuer malgré tout
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Rule Blocked Modal (Hard Mode) -->
{#if showRuleBlockedModal && ruleBlockedWarnings.length > 0}
<RuleBlockedModal
warnings={ruleBlockedWarnings}
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;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.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;
}
/* Buttons */
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--btn-primary-bg, #3b82f6);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--btn-primary-hover-bg, #2563eb);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
padding: 0.5rem 1rem;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #fee2e2;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
/* Alert */
.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;
}
.alert-close:hover {
opacity: 1;
}
.alert-warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.warning-list {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
font-size: 0.875rem;
}
/* Loading & Empty states */
.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 0 1.5rem;
color: #6b7280;
}
/* Homework list */
.homework-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.homework-card {
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: box-shadow 0.2s;
}
.homework-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.homework-card.overdue {
border-left: 4px solid #dc2626;
}
.homework-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.homework-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.homework-status {
flex-shrink: 0;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.status-published {
background: #dcfce7;
color: #16a34a;
}
.status-deleted {
background: #f3f4f6;
color: #6b7280;
}
.homework-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;
}
.overdue-date {
color: #dc2626;
font-weight: 600;
}
.homework-description {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #4b5563;
line-height: 1.5;
white-space: pre-line;
}
.homework-actions {
display: flex;
gap: 0.5rem;
}
/* Modal */
.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 {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label,
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input[type='text'],
.form-group input[type='date'],
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.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 input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group select:disabled {
background: #f9fafb;
color: #9ca3af;
cursor: not-allowed;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.form-hint-warning {
color: #d97706;
}
.form-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 1.25rem;
background: #f9fafb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.info-label {
font-weight: 600;
color: #374151;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Delete confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-confirm .modal-actions {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
/* Filters row */
.filters-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters-row > :first-child {
flex: 1;
min-width: 200px;
}
.class-filter select {
padding: 0.625rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
}
.class-filter select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Duplicate modal */
.checkbox-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.checkbox-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
.checkbox-item.validation-error {
border-color: #fecaca;
background: #fef2f2;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type='checkbox'] {
width: 1rem;
height: 1rem;
}
.due-date-inline {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.due-date-inline label {
font-size: 0.875rem;
color: #6b7280;
white-space: nowrap;
}
.due-date-inline input[type='date'] {
padding: 0.375rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.empty-target-classes {
text-align: center;
color: #6b7280;
padding: 2rem 0;
}
/* Rule conforming date hint */
.form-hint-rule {
color: #d97706;
font-weight: 500;
}
.form-hint-error {
color: #dc2626;
font-weight: 500;
}
/* Rule override badge */
.homework-badges {
display: flex;
align-items: center;
gap: 0.5rem;
}
.badge-rule-override {
font-size: 0.75rem;
color: #d97706;
opacity: 0.7;
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;
}
.modal-header-warning h2 {
color: #d97706;
}
.rule-warning-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.rule-warning-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
color: #92400e;
}
.rule-warning-icon {
color: #d97706;
flex-shrink: 0;
}
.rule-warning-notice {
font-size: 0.875rem;
color: #6b7280;
font-style: italic;
margin-top: 1rem;
}
</style>