feat: Permettre la définition d'une semaine type récurrente pour l'emploi du temps
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:
@@ -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"><</button>
|
||||
<span class="week-label">{weekLabel}</span>
|
||||
<button class="week-nav-btn" onclick={nextWeek} aria-label="Semaine suivante">></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;
|
||||
|
||||
Reference in New Issue
Block a user