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.
1945 lines
49 KiB
Svelte
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">⚠</span>
|
|
{error}
|
|
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if duplicateWarnings.length > 0}
|
|
<div class="alert alert-warning">
|
|
<span class="alert-icon">⚠</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 = [])}>×</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">📚</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; }}
|
|
>⚠ Exception</button>
|
|
{:else if hw.hasRuleOverride}
|
|
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">⚠</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">🏫</span>
|
|
{hw.className ?? getClassName(hw.classId)}
|
|
</span>
|
|
<span class="meta-item" title="Matière">
|
|
<span class="meta-icon">📖</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">📅</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">×</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">×</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">×</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">×</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">⚠</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">⚠</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">×</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>
|