feat: Configurer les jours fériés et vacances du calendrier scolaire
Les administrateurs d'établissement avaient besoin de gérer le calendrier scolaire (FR80) pour que l'EDT et les devoirs respectent automatiquement les jours non travaillés. Sans cette configuration centralisée, chaque module devait gérer indépendamment les contraintes de dates. Le calendrier s'appuie sur l'API data.education.gouv.fr pour importer les vacances officielles par zone (A/B/C) et calcule les 11 jours fériés français (dont les fêtes mobiles liées à Pâques). Les enseignants sont notifiés par email lors de l'ajout d'une journée pédagogique. Un query IsSchoolDay et une validation des dates d'échéance de devoirs permettent aux autres modules de s'intégrer sans couplage direct.
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
<script lang="ts">
|
||||
interface CalendarEntry {
|
||||
id: string;
|
||||
type: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
let {
|
||||
entries = [],
|
||||
initialMonth,
|
||||
initialYear
|
||||
}: {
|
||||
entries: CalendarEntry[];
|
||||
initialMonth?: number;
|
||||
initialYear?: number;
|
||||
} = $props();
|
||||
|
||||
const DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] as const;
|
||||
|
||||
const MONTHS = [
|
||||
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
|
||||
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
|
||||
] as const;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
holiday: '#ef4444',
|
||||
vacation: '#3b82f6',
|
||||
pedagogical: '#f59e0b',
|
||||
bridge: '#8b5cf6',
|
||||
closure: '#6b7280'
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
holiday: 'Jour férié',
|
||||
vacation: 'Vacances',
|
||||
pedagogical: 'J. pédagogique',
|
||||
bridge: 'Pont',
|
||||
closure: 'Fermeture'
|
||||
};
|
||||
|
||||
let month = $state(initialMonth ?? new Date().getMonth());
|
||||
let year = $state(initialYear ?? new Date().getFullYear());
|
||||
|
||||
let monthLabel = $derived(`${MONTHS[month]} ${year}`);
|
||||
|
||||
let daysInMonth = $derived(new Date(year, month + 1, 0).getDate());
|
||||
|
||||
// Monday = 0, Sunday = 6 (ISO week)
|
||||
let firstDayOffset = $derived(() => {
|
||||
const jsDay = new Date(year, month, 1).getDay(); // 0=Sun, 1=Mon...
|
||||
return jsDay === 0 ? 6 : jsDay - 1;
|
||||
});
|
||||
|
||||
interface DayCell {
|
||||
day: number;
|
||||
dateStr: string;
|
||||
entries: CalendarEntry[];
|
||||
isToday: boolean;
|
||||
isWeekend: boolean;
|
||||
}
|
||||
|
||||
let calendarGrid = $derived(() => {
|
||||
const offset = firstDayOffset();
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const cells: (DayCell | null)[] = [];
|
||||
|
||||
// Leading blanks
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(null);
|
||||
}
|
||||
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
const dayOfWeek = (offset + d - 1) % 7; // 0=Mon, 5=Sat, 6=Sun
|
||||
|
||||
const dayEntries = entries.filter((e) => {
|
||||
return e.startDate <= dateStr && e.endDate >= dateStr;
|
||||
});
|
||||
|
||||
cells.push({
|
||||
day: d,
|
||||
dateStr,
|
||||
entries: dayEntries,
|
||||
isToday: dateStr === todayStr,
|
||||
isWeekend: dayOfWeek >= 5
|
||||
});
|
||||
}
|
||||
|
||||
// Split into rows of 7
|
||||
const rows: (DayCell | null)[][] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) {
|
||||
const row = cells.slice(i, i + 7);
|
||||
// Pad last row
|
||||
while (row.length < 7) {
|
||||
row.push(null);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Active types present in the current entries
|
||||
let activeTypes = $derived(() => {
|
||||
const types = new Set(entries.map((e) => e.type));
|
||||
return [...types].sort();
|
||||
});
|
||||
|
||||
// Tooltip state
|
||||
let tooltip = $state<{ x: number; y: number; entries: CalendarEntry[] } | null>(null);
|
||||
|
||||
function showTooltip(event: Event, dayEntries: CalendarEntry[]) {
|
||||
if (dayEntries.length === 0) return;
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const rect = target.getBoundingClientRect();
|
||||
tooltip = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
entries: dayEntries
|
||||
};
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip = null;
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (month === 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
} else {
|
||||
month--;
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (month === 11) {
|
||||
month = 0;
|
||||
year++;
|
||||
} else {
|
||||
month++;
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const now = new Date();
|
||||
month = now.getMonth();
|
||||
year = now.getFullYear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="calendar-view">
|
||||
<div class="calendar-nav">
|
||||
<button class="nav-btn" onclick={prevMonth} aria-label="Mois précédent">‹</button>
|
||||
<button class="nav-label" onclick={goToToday} title="Revenir à aujourd'hui">
|
||||
{monthLabel}
|
||||
</button>
|
||||
<button class="nav-btn" onclick={nextMonth} aria-label="Mois suivant">›</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid" role="grid" aria-label="Calendrier {monthLabel}">
|
||||
<div class="grid-header" role="row">
|
||||
{#each DAYS as day}
|
||||
<div class="grid-header-cell" role="columnheader">{day}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each calendarGrid() as row}
|
||||
<div class="grid-row" role="row">
|
||||
{#each row as cell}
|
||||
{#if cell === null}
|
||||
<div class="grid-cell grid-cell-empty" role="gridcell"></div>
|
||||
{:else}
|
||||
<div
|
||||
class="grid-cell"
|
||||
class:grid-cell-today={cell.isToday}
|
||||
class:grid-cell-weekend={cell.isWeekend}
|
||||
class:grid-cell-has-entries={cell.entries.length > 0}
|
||||
role="gridcell"
|
||||
aria-label="{cell.day} {MONTHS[month]} {year}{cell.entries.length > 0 ? `, ${cell.entries.length} événement${cell.entries.length > 1 ? 's' : ''}` : ''}"
|
||||
onmouseenter={(e) => showTooltip(e, cell.entries)}
|
||||
onmouseleave={hideTooltip}
|
||||
>
|
||||
<span class="day-number" class:day-number-today={cell.isToday}>{cell.day}</span>
|
||||
{#if cell.entries.length > 0}
|
||||
<div class="day-dots">
|
||||
{#each cell.entries.slice(0, 3) as entry}
|
||||
<span
|
||||
class="day-dot"
|
||||
style="background: {TYPE_COLORS[entry.type] ?? '#6b7280'};"
|
||||
></span>
|
||||
{/each}
|
||||
{#if cell.entries.length > 3}
|
||||
<span class="day-dot-more">+{cell.entries.length - 3}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTypes().length > 0}
|
||||
<div class="calendar-legend">
|
||||
{#each activeTypes() as type}
|
||||
<span class="legend-item">
|
||||
<span class="legend-dot" style="background: {TYPE_COLORS[type] ?? '#6b7280'};"></span>
|
||||
{TYPE_LABELS[type] ?? type}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if tooltip}
|
||||
<div
|
||||
class="tooltip"
|
||||
style="left: {tooltip.x}px; top: {tooltip.y}px;"
|
||||
role="tooltip"
|
||||
>
|
||||
{#each tooltip.entries as entry}
|
||||
<div class="tooltip-entry">
|
||||
<span class="tooltip-dot" style="background: {TYPE_COLORS[entry.type] ?? '#6b7280'};"></span>
|
||||
<span class="tooltip-label">{entry.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.calendar-view {
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.calendar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.nav-label:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
.calendar-grid {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.grid-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.grid-header-cell {
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #64748b);
|
||||
padding: 0.375rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.grid-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: default;
|
||||
transition: background 0.1s;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.grid-cell-empty {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid-cell:not(.grid-cell-empty):hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
}
|
||||
|
||||
.grid-cell-weekend {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
}
|
||||
|
||||
.grid-cell-weekend:hover {
|
||||
background: var(--border-subtle, #e2e8f0);
|
||||
}
|
||||
|
||||
.grid-cell-today {
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
}
|
||||
|
||||
.grid-cell-today:hover {
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
}
|
||||
|
||||
.grid-cell-has-entries {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Day number */
|
||||
.day-number {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #1f2937);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.day-number-today {
|
||||
font-weight: 700;
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
}
|
||||
|
||||
/* Dots */
|
||||
.day-dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-top: 0.125rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.day-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-dot-more {
|
||||
font-size: 0.5625rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
line-height: 6px;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.calendar-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid var(--border-subtle, #e2e8f0);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -100%);
|
||||
margin-top: -0.5rem;
|
||||
background: var(--text-primary, #1f2937);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
z-index: 400;
|
||||
pointer-events: none;
|
||||
max-width: 250px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tooltip-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tooltip-entry + .tooltip-entry {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tooltip-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tooltip-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.grid-cell {
|
||||
min-height: 3.5rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.day-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -56,6 +56,11 @@
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
<span class="action-hint">Trimestres et semestres</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/calendar">
|
||||
<span class="action-icon">🗓️</span>
|
||||
<span class="action-label">Calendrier scolaire</span>
|
||||
<span class="action-hint">Fériés et vacances</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/pedagogy">
|
||||
<span class="action-icon">🎓</span>
|
||||
<span class="action-label">Pédagogie</span>
|
||||
|
||||
Reference in New Issue
Block a user