feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
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

Les administrateurs devaient recréer manuellement l'emploi du temps chaque
semaine. Cette implémentation introduit un système de récurrence hebdomadaire
avec gestion des exceptions par occurrence, permettant de modifier ou annuler
un cours spécifique sans affecter les autres semaines.

Le ScheduleResolver calcule dynamiquement l'EDT réel en combinant les créneaux
récurrents, les exceptions ponctuelles et le calendrier scolaire (vacances/fériés).
This commit is contained in:
2026-03-04 20:03:12 +01:00
parent e156755b86
commit ae640e91ac
35 changed files with 3550 additions and 81 deletions

View File

@@ -120,11 +120,18 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) {
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
timeout: 15000
});
}
async function chooseScopeAndEdit(page: import('@playwright/test').Page) {
// Scope modal appears - choose "this occurrence"
const scopeModal = page.getByRole('dialog');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await scopeModal.getByText('Cette occurrence uniquement').click();
}
async function fillSlotForm(
dialog: import('@playwright/test').Locator,
options: {
@@ -228,7 +235,7 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
// AC3: Slot Modification & Deletion
// ==========================================================================
test.describe('AC3: Slot Modification & Deletion', () => {
test('clicking a slot opens edit modal', async ({ page }) => {
test('clicking a slot opens scope modal then edit modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
@@ -246,11 +253,13 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the created slot
// Click on the created slot — scope modal should appear
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
await chooseScopeAndEdit(page);
// Edit modal should appear
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
@@ -277,17 +286,24 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot to edit
// Click on the slot — scope modal then edit modal
const slotCard = page.locator('.slot-card').first();
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
await chooseScopeAndEdit(page);
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Click delete button
// Click delete button — opens scope modal again for delete scope
await dialog.getByRole('button', { name: /supprimer/i }).click();
// Scope modal appears for delete action
const scopeModal = page.locator('.modal-scope');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await scopeModal.getByText('Cette occurrence uniquement').click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
@@ -323,9 +339,11 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
await expect(slotCard).toBeVisible({ timeout: 10000 });
await expect(slotCard.getByText('A101')).toBeVisible();
// Click to open edit modal
// Click to open scope modal then edit modal
await slotCard.click();
await chooseScopeAndEdit(page);
dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(
@@ -520,5 +538,61 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
const dialog = page.getByRole('dialog');
await expect(dialog).not.toBeVisible({ timeout: 3000 });
});
test('recurring slots do not appear on vacation days in next week (AC4)', async ({
page
}) => {
// Compute next week Thursday (use local format to avoid UTC shift)
const thisThursday = new Date(getWeekdayInCurrentWeek(4) + 'T00:00:00');
const nextThursday = new Date(thisThursday);
nextThursday.setDate(thisThursday.getDate() + 7);
const y = nextThursday.getFullYear();
const m = String(nextThursday.getMonth() + 1).padStart(2, '0');
const d = String(nextThursday.getDate()).padStart(2, '0');
const nextThursdayStr = `${y}-${m}-${d}`;
// Clean calendar entries and seed a vacation on next Thursday only
cleanupCalendarEntries();
seedBlockedDate(nextThursdayStr, 'Vacances AC4', 'vacation');
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a Thursday slot (if none exists) so we have a recurring slot on Thursday
const dayColumns = page.locator('.day-column');
// Check if Thursday column (index 3) already has a slot
const thursdaySlots = dayColumns.nth(3).locator('.slot-card');
const existingCount = await thursdaySlots.count();
if (existingCount === 0) {
// Create a slot on Thursday
const thursdayCell = dayColumns.nth(3).locator('.time-cell').first();
await thursdayCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { dayValue: '4', startTime: '09:00', endTime: '10:00' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
await waitForScheduleReady(page);
}
// Verify slot is visible on current week's Thursday
await expect(dayColumns.nth(3).locator('.slot-card').first()).toBeVisible({
timeout: 10000
});
// Navigate to next week
await page.getByRole('button', { name: 'Semaine suivante' }).click();
await waitForScheduleReady(page);
// Next week Thursday should be blocked
await expect(dayColumns.nth(3)).toHaveClass(/day-blocked/, { timeout: 10000 });
// No slot card should appear on the blocked Thursday
await expect(dayColumns.nth(3).locator('.slot-card')).toHaveCount(0);
});
});
});

View File

@@ -94,7 +94,7 @@ async function waitForScheduleReady(page: import('@playwright/test').Page) {
timeout: 15000
});
// Wait for either the grid or the empty state to appear
await expect(page.locator('.schedule-grid, .empty-state, .alert-error')).toBeVisible({
await expect(page.locator('.schedule-grid, .empty-state')).toBeVisible({
timeout: 15000
});
}
@@ -415,3 +415,244 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
});
});
});
test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create admin user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
cleanupScheduleData();
clearCache();
});
test.beforeEach(async () => {
cleanupScheduleData();
try {
runSql(`DELETE FROM schedule_exceptions WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
try {
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
} catch {
// Table may not exist
}
seedTeacherAssignments();
clearCache();
});
// ==========================================================================
// AC2: Week Navigation
// ==========================================================================
test.describe('AC2: Week Navigation', () => {
test('displays week navigation controls', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Week navigation should be visible
const weekNav = page.locator('.week-nav');
await expect(weekNav).toBeVisible();
// Previous/next buttons
await expect(weekNav.getByLabel('Semaine précédente')).toBeVisible();
await expect(weekNav.getByLabel('Semaine suivante')).toBeVisible();
// Week label
await expect(weekNav.locator('.week-label')).toBeVisible();
});
test('can navigate to next and previous weeks', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const weekLabel = page.locator('.week-label');
const initialLabel = await weekLabel.textContent();
// Navigate to next week
await page.getByLabel('Semaine suivante').click();
await expect(weekLabel).not.toHaveText(initialLabel!, { timeout: 5000 });
const nextLabel = await weekLabel.textContent();
// Navigate back
await page.getByLabel('Semaine précédente').click();
await expect(weekLabel).toHaveText(initialLabel!, { timeout: 5000 });
// Navigate to next again
await page.getByLabel('Semaine suivante').click();
await expect(weekLabel).toHaveText(nextLabel!, { timeout: 5000 });
});
test('today button appears when not on current week', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// "Aujourd'hui" button should not be visible on current week
await expect(page.locator('.week-nav-today')).not.toBeVisible();
// Navigate away
await page.getByLabel('Semaine suivante').click();
// "Aujourd'hui" button should appear
await expect(page.locator('.week-nav-today')).toBeVisible({ timeout: 5000 });
// Click it to go back
await page.locator('.week-nav-today').click();
// Should disappear again
await expect(page.locator('.week-nav-today')).not.toBeVisible({ timeout: 5000 });
});
});
// ==========================================================================
// AC1/AC2: Recurring indicator
// ==========================================================================
test.describe('AC1: Recurring Indicator', () => {
test('recurring slots show recurring badge', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot first
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(dialog, { room: 'B201' });
await dialog.getByRole('button', { name: /créer/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Slot card should have the recurring badge
const slotCard = page.locator('.slot-card');
await expect(slotCard).toBeVisible({ timeout: 10000 });
await expect(slotCard.locator('.slot-badge-recurring')).toBeVisible();
});
});
// ==========================================================================
// AC3: Scope Choice Modal
// ==========================================================================
test.describe('AC3: Scope Choice Modal', () => {
test('clicking a slot opens scope choice modal', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const createDialog = page.getByRole('dialog');
await expect(createDialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(createDialog, { room: 'C301' });
await createDialog.getByRole('button', { name: /créer/i }).click();
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot card
const slotCard = page.locator('.slot-card');
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
// Scope modal should appear
const scopeModal = page.locator('.modal-scope');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await expect(scopeModal.getByText('Cette occurrence uniquement')).toBeVisible();
await expect(scopeModal.getByText('Toutes les occurrences futures')).toBeVisible();
});
test('scope modal can be closed with Escape', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const createDialog = page.getByRole('dialog');
await expect(createDialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(createDialog);
await createDialog.getByRole('button', { name: /créer/i }).click();
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot card
const slotCard = page.locator('.slot-card');
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
// Scope modal appears
const scopeModal = page.locator('.modal-scope');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
// Close with Escape
await page.keyboard.press('Escape');
await expect(scopeModal).not.toBeVisible({ timeout: 5000 });
});
test('choosing "this occurrence" opens edit form', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
// Create a slot
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const createDialog = page.getByRole('dialog');
await expect(createDialog).toBeVisible({ timeout: 10000 });
await fillSlotForm(createDialog, { room: 'D401' });
await createDialog.getByRole('button', { name: /créer/i }).click();
await expect(createDialog).not.toBeVisible({ timeout: 10000 });
// Click on the slot card
const slotCard = page.locator('.slot-card');
await expect(slotCard).toBeVisible({ timeout: 10000 });
await slotCard.click();
// Choose "this occurrence"
const scopeModal = page.locator('.modal-scope');
await expect(scopeModal).toBeVisible({ timeout: 10000 });
await scopeModal.getByText('Cette occurrence uniquement').click();
// Edit form should appear
const editDialog = page.getByRole('dialog');
await expect(editDialog).toBeVisible({ timeout: 10000 });
await expect(
editDialog.getByRole('heading', { name: /modifier le créneau/i })
).toBeVisible();
});
});
});

View File

@@ -4,19 +4,6 @@
import { untrack } from 'svelte';
// Types
interface ScheduleSlot {
id: string;
classId: string;
subjectId: string;
teacherId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
room: string | null;
isRecurring: boolean;
conflicts?: Array<{ type: string; description: string; slotId: string }>;
}
interface SchoolClass {
id: string;
name: string;
@@ -51,6 +38,21 @@
type: string;
}
interface ResolvedSlot {
id: string;
slotId: string;
classId: string;
subjectId: string;
teacherId: string;
dayOfWeek: number;
startTime: string;
endTime: string;
room: string | null;
date: string;
isModified: boolean;
exceptionId: string | null;
}
// Constants
const DAYS = [
{ value: 1, label: 'Lundi' },
@@ -68,8 +70,22 @@
TIME_SLOTS.push(`${String(h).padStart(2, '0')}:30`);
}
function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function getInitialMonday(): string {
const now = new Date();
const d = new Date(now);
d.setDate(now.getDate() - ((now.getDay() + 6) % 7));
return formatLocalDate(d);
}
// State
let slots = $state<ScheduleSlot[]>([]);
let slots = $state<ResolvedSlot[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let teachers = $state<User[]>([]);
@@ -84,9 +100,13 @@
let selectedClassId = $state('');
let selectedTeacherFilter = $state('');
// Week navigation
let currentWeekStart = $state(getInitialMonday());
// Create/Edit modal
let showSlotModal = $state(false);
let editingSlot = $state<ScheduleSlot | null>(null);
let editingSlot = $state<ResolvedSlot | null>(null);
let editScope = $state<'this_occurrence' | 'all_future'>('this_occurrence');
let formClassId = $state('');
let formSubjectId = $state('');
let formTeacherId = $state('');
@@ -100,11 +120,18 @@
// Delete modal
let showDeleteModal = $state(false);
let slotToDelete = $state<ScheduleSlot | null>(null);
let slotToDelete = $state<ResolvedSlot | null>(null);
let isDeleting = $state(false);
let deleteScope = $state<'this_occurrence' | 'all_future'>('this_occurrence');
// Scope choice modal
let showScopeModal = $state(false);
let scopeAction = $state<'edit' | 'delete' | 'move'>('edit');
let scopeSlot = $state<ResolvedSlot | null>(null);
// Drag state
let draggedSlot = $state<ScheduleSlot | null>(null);
let draggedSlot = $state<ResolvedSlot | null>(null);
let pendingDrop = $state<{ slot: ResolvedSlot; day: number; time: string } | null>(null);
// Mobile: selected day tab
let mobileSelectedDay = $state(1);
@@ -144,6 +171,16 @@
let subjectMap = $derived(new Map(subjects.map((s) => [s.id, s])));
let teacherMap = $derived(new Map(teachers.map((t) => [t.id, t])));
let weekLabel = $derived.by(() => {
const start = new Date(currentWeekStart + 'T00:00:00');
const end = new Date(start);
end.setDate(start.getDate() + 4);
const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long' };
const optsY: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' };
return `${start.toLocaleDateString('fr-FR', opts)} - ${end.toLocaleDateString('fr-FR', optsY)}`;
});
let isCurrentWeek = $derived(currentWeekStart === getInitialMonday());
// Load on mount
$effect(() => {
@@ -189,11 +226,15 @@
}
}
// Load slots whenever filter changes
// Load slots whenever filter or week changes
$effect(() => {
const classId = selectedClassId;
const teacherId = selectedTeacherFilter;
untrack(() => loadSlots(classId, teacherId));
const _week = currentWeekStart;
untrack(() => {
loadSlots(classId, teacherId);
loadBlockedDates();
});
});
// Load assignments when class filter or form class changes
@@ -219,20 +260,21 @@
try {
isSlotsLoading = true;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
if (classId) params.set('classId', classId);
if (teacherId) params.set('teacherId', teacherId);
const response = await authenticatedFetch(
`${apiUrl}/schedule/slots?${params.toString()}`,
{ signal: controller.signal }
);
const url = classId
? `${apiUrl}/schedule/week/${currentWeekStart}?classId=${classId}`
: `${apiUrl}/schedule/slots?teacherId=${teacherId}`;
const response = await authenticatedFetch(url, { signal: controller.signal });
if (controller.signal.aborted) return;
if (!response.ok) throw new Error('Erreur lors du chargement de l\u2019emploi du temps.');
const data = await response.json();
slots = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
const items: ResolvedSlot[] = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
// Apply teacher filter client-side when using resolved API
slots = teacherId ? items.filter((s) => s.teacherId === teacherId) : items;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : 'Erreur inconnue.';
@@ -265,14 +307,12 @@
}
function getCurrentWeekDates(): Map<number, string> {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
const monday = new Date(currentWeekStart + 'T00:00:00');
const dates = new Map<number, string>();
for (let i = 0; i < 5; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
dates.set(i + 1, d.toISOString().split('T')[0]!);
dates.set(i + 1, formatLocalDate(d));
}
return dates;
}
@@ -302,12 +342,29 @@
}
}
// Week navigation
function prevWeek() {
const d = new Date(currentWeekStart + 'T00:00:00');
d.setDate(d.getDate() - 7);
currentWeekStart = formatLocalDate(d);
}
function nextWeek() {
const d = new Date(currentWeekStart + 'T00:00:00');
d.setDate(d.getDate() + 7);
currentWeekStart = formatLocalDate(d);
}
function goToThisWeek() {
currentWeekStart = getInitialMonday();
}
// Grid helpers
function getSlotTop(slot: ScheduleSlot): number {
function getSlotTop(slot: ResolvedSlot): number {
return timeToMinutes(slot.startTime) - timeToMinutes(`${String(HOURS_START).padStart(2, '0')}:00`);
}
function getSlotHeight(slot: ScheduleSlot): number {
function getSlotHeight(slot: ResolvedSlot): number {
return timeToMinutes(slot.endTime) - timeToMinutes(slot.startTime);
}
@@ -338,7 +395,7 @@
}
// Unique slots for a day column (deduplicated by slot id)
function getSlotsForDay(day: number): ScheduleSlot[] {
function getSlotsForDay(day: number): ResolvedSlot[] {
return slots.filter((s) => s.dayOfWeek === day);
}
@@ -361,7 +418,34 @@
showSlotModal = true;
}
function openEditModal(slot: ScheduleSlot) {
function handleSlotClick(slot: ResolvedSlot) {
scopeSlot = slot;
scopeAction = 'edit';
showScopeModal = true;
}
function handleScopeChoice(scope: 'this_occurrence' | 'all_future') {
showScopeModal = false;
if (scopeAction === 'edit') {
editScope = scope;
openEditModal(scopeSlot!);
} else if (scopeAction === 'delete') {
deleteScope = scope;
openDeleteModal(scopeSlot!);
} else if (scopeAction === 'move' && pendingDrop) {
executeDrop(pendingDrop.slot, pendingDrop.day, pendingDrop.time, scope);
pendingDrop = null;
}
scopeSlot = null;
}
function closeScopeModal() {
showScopeModal = false;
scopeSlot = null;
pendingDrop = null;
}
function openEditModal(slot: ResolvedSlot) {
editingSlot = slot;
formClassId = slot.classId;
formSubjectId = slot.subjectId;
@@ -381,11 +465,19 @@
formConflicts = [];
}
function openDeleteModal(slot: ScheduleSlot) {
function openDeleteModal(slot: ResolvedSlot) {
slotToDelete = slot;
showDeleteModal = true;
}
function handleDeleteFromEdit() {
const slot = editingSlot!;
closeSlotModal();
scopeSlot = slot;
scopeAction = 'delete';
showScopeModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
slotToDelete = null;
@@ -413,11 +505,14 @@
};
const response = editingSlot
? await authenticatedFetch(`${apiUrl}/schedule/slots/${editingSlot.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify(body)
})
? await authenticatedFetch(
`${apiUrl}/schedule/slots/${editingSlot.slotId}/occurrence/${editingSlot.date}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...body, scope: editScope })
}
)
: await authenticatedFetch(`${apiUrl}/schedule/slots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -431,7 +526,7 @@
);
}
const result: ScheduleSlot = await response.json();
const result = await response.json();
// Conflits détectés et non forcés : garder la modale ouverte
if (result.conflicts && result.conflicts.length > 0 && !formForceConflicts) {
@@ -461,16 +556,23 @@
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/schedule/slots/${slotToDelete.id}`, {
method: 'DELETE'
});
const occurrenceDate = slotToDelete.date;
if (!occurrenceDate) return;
const deleteUrl = deleteScope === 'all_future'
? `${apiUrl}/schedule/slots/${slotToDelete.slotId}/occurrence/${occurrenceDate}?scope=all_future`
: `${apiUrl}/schedule/slots/${slotToDelete.slotId}/occurrence/${occurrenceDate}`;
const response = await authenticatedFetch(deleteUrl, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.message ?? data?.detail ?? 'Erreur lors de la suppression.');
}
successMessage = 'Créneau supprimé.';
successMessage = deleteScope === 'this_occurrence'
? 'Occurrence annulée.'
: 'Occurrences futures supprimées.';
closeDeleteModal();
await loadSlots(selectedClassId, selectedTeacherFilter);
@@ -485,11 +587,11 @@
}
// Drag & Drop
function handleDragStart(event: DragEvent, slot: ScheduleSlot) {
function handleDragStart(event: DragEvent, slot: ResolvedSlot) {
draggedSlot = slot;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', slot.id);
event.dataTransfer.setData('text/plain', slot.slotId);
}
}
@@ -501,14 +603,31 @@
}
}
async function handleDrop(event: DragEvent, day: number, time: string) {
function handleDrop(event: DragEvent, day: number, time: string) {
event.preventDefault();
if (!draggedSlot) return;
const slot = draggedSlot;
draggedSlot = null;
// Calculate new end time preserving duration
if (day !== slot.dayOfWeek) {
// Cross-day moves require splitting the recurrence — skip scope modal
executeDrop(slot, day, time, 'all_future');
return;
}
pendingDrop = { slot, day, time };
scopeSlot = slot;
scopeAction = 'move';
showScopeModal = true;
}
async function executeDrop(
slot: ResolvedSlot,
day: number,
time: string,
scope: 'this_occurrence' | 'all_future'
) {
const duration = timeToMinutes(slot.endTime) - timeToMinutes(slot.startTime);
const newEndMinutes = timeToMinutes(time) + duration;
const newEndH = Math.floor(newEndMinutes / 60);
@@ -519,16 +638,23 @@
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/schedule/slots/${slot.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({
dayOfWeek: day,
startTime: time,
endTime: newEndTime,
forceConflicts: false
})
});
const response = await authenticatedFetch(
`${apiUrl}/schedule/slots/${slot.slotId}/occurrence/${slot.date}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classId: slot.classId,
subjectId: slot.subjectId,
teacherId: slot.teacherId,
dayOfWeek: day,
startTime: time,
endTime: newEndTime,
room: slot.room,
scope
})
}
);
if (!response.ok) {
const data = await response.json().catch(() => null);
@@ -537,7 +663,7 @@
);
}
const result: ScheduleSlot = await response.json();
const result: { conflicts?: Array<{ description: string }> } = await response.json();
if (result.conflicts && result.conflicts.length > 0) {
error = `Conflit détecté : ${result.conflicts.map((c) => c.description).join(', ')}`;
} else {
@@ -560,7 +686,8 @@
<svelte:window onkeydown={(e) => {
if (e.key === 'Escape') {
if (showDeleteModal) closeDeleteModal();
if (showScopeModal) closeScopeModal();
else if (showDeleteModal) closeDeleteModal();
else if (showSlotModal) closeSlotModal();
}
}} />
@@ -613,6 +740,16 @@
</div>
</div>
<!-- Week navigation -->
<div class="week-nav" aria-label="Navigation par semaine">
<button class="week-nav-btn" onclick={prevWeek} aria-label="Semaine précédente">&lt;</button>
<span class="week-label">{weekLabel}</span>
<button class="week-nav-btn" onclick={nextWeek} aria-label="Semaine suivante">&gt;</button>
{#if !isCurrentWeek}
<button class="week-nav-today" onclick={goToThisWeek}>Aujourd'hui</button>
{/if}
</div>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
@@ -690,6 +827,7 @@
<div
class="slot-card"
class:dragging={draggedSlot?.id === slot.id}
class:slot-modified={slot.isModified}
style="
top: {getSlotTop(slot)}px;
height: {getSlotHeight(slot)}px;
@@ -700,13 +838,20 @@
draggable="true"
ondragstart={(e) => handleDragStart(e, slot)}
ondragend={handleDragEnd}
onclick={(e) => { e.stopPropagation(); openEditModal(slot); }}
onclick={(e) => { e.stopPropagation(); handleSlotClick(slot); }}
role="button"
tabindex="0"
onkeydown={(e) => { if (e.key === 'Enter') openEditModal(slot); }}
onkeydown={(e) => { if (e.key === 'Enter') handleSlotClick(slot); }}
title="{getSubjectName(slot.subjectId)} - {getTeacherName(slot.teacherId)}"
>
<span class="slot-subject">{getSubjectName(slot.subjectId)}</span>
<div class="slot-header-row">
<span class="slot-subject">{getSubjectName(slot.subjectId)}</span>
{#if slot.isModified}
<span class="slot-badge slot-badge-modified" title="Occurrence modifiée">M</span>
{:else}
<span class="slot-badge slot-badge-recurring" title="Cours récurrent"></span>
{/if}
</div>
<span class="slot-teacher">{getTeacherName(slot.teacherId)}</span>
{#if slot.room}
<span class="slot-room">{slot.room}</span>
@@ -852,7 +997,7 @@
<button
type="button"
class="btn-danger"
onclick={() => { const slot = editingSlot!; closeSlotModal(); openDeleteModal(slot); }}
onclick={handleDeleteFromEdit}
disabled={isSubmitting}
>
Supprimer
@@ -884,18 +1029,31 @@
onclick={(e) => e.stopPropagation()}
>
<header class="modal-header modal-header-danger">
<h2 id="delete-title">Supprimer le créneau</h2>
<h2 id="delete-title">
{deleteScope === 'this_occurrence' ? 'Annuler cette occurrence' : 'Supprimer le créneau'}
</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<p id="delete-description">
Voulez-vous supprimer le créneau de
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
{#if deleteScope === 'this_occurrence'}
Voulez-vous annuler le cours de
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
du {slotToDelete!.date} ({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
{:else}
Voulez-vous supprimer le créneau récurrent de
<strong>{getSubjectName(slotToDelete!.subjectId)}</strong>
({DAYS.find((d) => d.value === slotToDelete!.dayOfWeek)?.label}
{slotToDelete!.startTime} - {slotToDelete!.endTime}) ?
{/if}
</p>
<p class="delete-warning">
{deleteScope === 'this_occurrence'
? 'Le cours sera annulé uniquement pour cette date.'
: 'Cette action supprimera toutes les occurrences futures.'}
</p>
<p class="delete-warning">Cette action est irréversible.</p>
</div>
<div class="modal-actions">
@@ -924,6 +1082,59 @@
</div>
{/if}
<!-- Scope Choice Modal -->
{#if showScopeModal && scopeSlot}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeScopeModal} role="presentation">
<div
class="modal modal-scope"
role="dialog"
aria-modal="true"
aria-labelledby="scope-title"
onclick={(e) => e.stopPropagation()}
>
<header class="modal-header">
<h2 id="scope-title">
{scopeAction === 'edit' ? 'Modifier le cours' : 'Supprimer le cours'}
</h2>
<button class="modal-close" onclick={closeScopeModal} aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<p class="scope-description">
Ce cours de <strong>{getSubjectName(scopeSlot.subjectId)}</strong> est récurrent.
Que souhaitez-vous {scopeAction === 'edit' ? 'modifier' : 'supprimer'} ?
</p>
<div class="scope-choices">
<button
class="scope-choice"
onclick={() => handleScopeChoice('this_occurrence')}
>
<span class="scope-choice-title">Cette occurrence uniquement</span>
<span class="scope-choice-desc">
{scopeAction === 'edit'
? 'Modifie uniquement le cours du ' + scopeSlot.date
: 'Annule uniquement le cours du ' + scopeSlot.date}
</span>
</button>
<button
class="scope-choice"
onclick={() => handleScopeChoice('all_future')}
>
<span class="scope-choice-title">Toutes les occurrences futures</span>
<span class="scope-choice-desc">
{scopeAction === 'edit'
? 'Modifie ce cours et toutes les semaines suivantes'
: 'Supprime ce cours et toutes les semaines suivantes'}
</span>
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.schedule-page {
padding: 1rem;
@@ -1451,6 +1662,130 @@
cursor: not-allowed;
}
/* Week navigation */
.week-nav {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.week-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
cursor: pointer;
font-size: 0.875rem;
color: #374151;
transition: background-color 0.15s;
}
.week-nav-btn:hover {
background: #f8fafc;
}
.week-label {
font-size: 0.9375rem;
font-weight: 600;
color: #1f2937;
}
.week-nav-today {
padding: 0.375rem 0.75rem;
border: 1px solid #3b82f6;
border-radius: 0.375rem;
background: white;
color: #3b82f6;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.week-nav-today:hover {
background: #eff6ff;
}
/* Slot indicators */
.slot-header-row {
display: flex;
align-items: center;
gap: 3px;
}
.slot-badge {
flex-shrink: 0;
font-size: 0.55rem;
line-height: 1;
padding: 1px 3px;
border-radius: 2px;
font-weight: 700;
}
.slot-badge-recurring {
opacity: 0.5;
}
.slot-badge-modified {
background: rgba(255, 255, 255, 0.3);
opacity: 0.8;
}
.slot-modified {
border-left-style: dashed !important;
}
/* Scope modal */
.modal-scope {
max-width: 420px;
}
.scope-description {
margin: 0 0 1rem;
font-size: 0.875rem;
color: #374151;
}
.scope-choices {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scope-choice {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
background: white;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background-color 0.15s;
}
.scope-choice:hover {
border-color: #3b82f6;
background: #f0f9ff;
}
.scope-choice-title {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.scope-choice-desc {
font-size: 0.75rem;
color: #64748b;
}
/* Day tabs (mobile only) */
.day-tabs {
display: none;