diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index d98bd4d..241ec64 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -8,6 +8,7 @@ const NAV = [
{ href: '/admin', label: 'Tableau de bord' },
{ href: '/admin/horaires', label: 'Horaires' },
{ href: '/admin/exceptions', label: 'Exceptions' },
+ { href: '/admin/vacances', label: 'Vacances' },
];
/**
diff --git a/app/admin/vacances/VacationManager.tsx b/app/admin/vacances/VacationManager.tsx
new file mode 100644
index 0000000..5d29cfc
--- /dev/null
+++ b/app/admin/vacances/VacationManager.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+
+import { countDays } from '@/lib/schedule/vacations';
+
+import { createVacation, deleteVacation } from './actions';
+
+export type VacationRow = {
+ id: string;
+ startDate: string;
+ endDate: string;
+ labelFr: string;
+ labelEn: string | null;
+ isCurrent: boolean;
+};
+
+export default function VacationManager({
+ canEdit,
+ periods,
+}: {
+ canEdit: boolean;
+ periods: VacationRow[];
+}) {
+ const [startDate, setStartDate] = useState('');
+ const [endDate, setEndDate] = useState('');
+ const [labelFr, setLabelFr] = useState('');
+ const [pending, startTransition] = useTransition();
+ const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
+
+ function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) {
+ setMessage(null);
+ startTransition(async () => {
+ const result = await work();
+ if (result.ok) {
+ setMessage({ kind: 'ok', text: success });
+ setStartDate('');
+ setEndDate('');
+ setLabelFr('');
+ } else {
+ setMessage({ kind: 'error', text: result.error });
+ }
+ });
+ }
+
+ return (
+
+ {message ? (
+
+ {message.text}
+
+ ) : null}
+
+ {canEdit ? (
+
+ ) : null}
+
+
+ Périodes enregistrées
+ {periods.length === 0 ? (
+ Aucune fermeture prévue.
+ ) : (
+
+ {periods.map((period) => (
+
+ {period.labelFr}
+
+ du {period.startDate} au {period.endDate} ·{' '}
+ {countDays({ startDate: period.startDate, endDate: period.endDate })} jours
+
+ {period.isCurrent ? (
+
+ en cours
+
+ ) : null}
+ {period.labelEn ? (
+ « {period.labelEn} »
+ ) : (
+ traduction à venir
+ )}
+
+ {canEdit ? (
+ run(() => deleteVacation(period.id), 'Période supprimée.')}
+ className="ml-auto text-sm text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
+ >
+ Supprimer
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/app/admin/vacances/YearCalendar.tsx b/app/admin/vacances/YearCalendar.tsx
new file mode 100644
index 0000000..5e345a2
--- /dev/null
+++ b/app/admin/vacances/YearCalendar.tsx
@@ -0,0 +1,117 @@
+import { addCivilDays, civilDayOfWeek } from '@/lib/schedule/civil';
+import { periodsOverlap, type Period } from '@/lib/schedule/vacations';
+
+const MONTHS_FR = [
+ 'Janvier',
+ 'Février',
+ 'Mars',
+ 'Avril',
+ 'Mai',
+ 'Juin',
+ 'Juillet',
+ 'Août',
+ 'Septembre',
+ 'Octobre',
+ 'Novembre',
+ 'Décembre',
+];
+
+type LabelledPeriod = Period & { labelFr: string };
+
+type Day = { date: string; isWeekend: boolean; label: string | null };
+type Month = { key: string; label: string; days: Day[] };
+
+/**
+ * Twelve months from today, with closure days filled in.
+ *
+ * A list of date ranges is precise and hard to picture; a year at a glance is
+ * how anyone actually checks "have I left a gap in August?". Rendered on the
+ * server: it only changes when the data does.
+ */
+export default function YearCalendar({
+ from,
+ periods,
+}: {
+ from: string;
+ periods: LabelledPeriod[];
+}) {
+ const months = buildMonths(from, periods);
+
+ return (
+
+
+
+ Calendrier des douze prochains mois, jours de fermeture marqués
+
+
+ {months.map((month) => (
+
+
+ {month.label}
+
+
+
+ {month.days.map((day) => (
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+
+ fermeture
+
+
+ week-end
+
+
+
+ );
+}
+
+function buildMonths(from: string, periods: LabelledPeriod[]): Month[] {
+ const months: Month[] = [];
+ let cursor = `${from.slice(0, 7)}-01`;
+
+ for (let index = 0; index < 12; index += 1) {
+ const prefix = cursor.slice(0, 7);
+ const [year, month] = prefix.split('-').map(Number);
+ const days: Day[] = [];
+
+ let day = cursor;
+ while (day.startsWith(prefix)) {
+ const covering = periods.find((period) =>
+ periodsOverlap({ startDate: day, endDate: day }, period),
+ );
+ days.push({
+ date: day,
+ isWeekend: [0, 6].includes(civilDayOfWeek(day)),
+ label: covering?.labelFr ?? null,
+ });
+ day = addCivilDays(day, 1);
+ }
+
+ months.push({ key: prefix, label: `${MONTHS_FR[(month ?? 1) - 1]} ${year}`, days });
+ cursor = day;
+ }
+
+ return months;
+}
diff --git a/app/admin/vacances/actions.ts b/app/admin/vacances/actions.ts
new file mode 100644
index 0000000..e33b249
--- /dev/null
+++ b/app/admin/vacances/actions.ts
@@ -0,0 +1,118 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+
+import { diffOf, recordAudit } from '@/lib/audit';
+import { requireAdmin } from '@/lib/auth/actions';
+import { prisma } from '@/lib/db';
+import type { CivilDate } from '@/lib/schedule/civil';
+import { countDays, findOverlap, validatePeriod } from '@/lib/schedule/vacations';
+
+export type ActionResult = { ok: true } | { ok: false; error: string };
+
+export async function createVacation(input: {
+ startDate: CivilDate;
+ endDate: CivilDate;
+ labelFr: string;
+}): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const labelFr = input.labelFr.trim();
+ const problems = validatePeriod({ ...input, labelFr });
+ if (problems.length > 0) {
+ return { ok: false, error: problems.join(' ') };
+ }
+
+ // Two periods covering the same day would both be in effect with no way to
+ // say which; refuse rather than merge silently.
+ const existing = await prisma.vacationPeriod.findMany();
+ const clash = findOverlap(
+ { startDate: input.startDate, endDate: input.endDate },
+ existing.map((period) => ({
+ id: period.id,
+ labelFr: period.labelFr,
+ startDate: asCivil(period.startDate),
+ endDate: asCivil(period.endDate),
+ })),
+ );
+ if (clash) {
+ return {
+ ok: false,
+ error: `Cette période chevauche « ${clash.labelFr} » (${clash.startDate} au ${clash.endDate}).`,
+ };
+ }
+
+ const created = await prisma.vacationPeriod.create({
+ data: {
+ startDate: asDate(input.startDate),
+ endDate: asDate(input.endDate),
+ labelFr,
+ createdBy: gate.value.email,
+ },
+ });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'vacation.create',
+ entity: 'VacationPeriod',
+ entityId: created.id,
+ diff: diffOf(null, {
+ libellé: labelFr,
+ du: input.startDate,
+ au: input.endDate,
+ jours: countDays(input),
+ }),
+ });
+
+ revalidateAll();
+ return { ok: true };
+}
+
+export async function deleteVacation(id: string): Promise {
+ const gate = await requireAdmin();
+ if (!gate.ok) {
+ return { ok: false, error: gate.error };
+ }
+
+ const existing = await prisma.vacationPeriod.findUnique({ where: { id } });
+ if (!existing) {
+ return { ok: false, error: 'Cette période a déjà été supprimée.' };
+ }
+
+ await prisma.vacationPeriod.delete({ where: { id } });
+
+ await recordAudit({
+ userEmail: gate.value.email,
+ action: 'vacation.delete',
+ entity: 'VacationPeriod',
+ entityId: id,
+ diff: diffOf(
+ {
+ libellé: existing.labelFr,
+ du: asCivil(existing.startDate),
+ au: asCivil(existing.endDate),
+ },
+ null,
+ ),
+ });
+
+ revalidateAll();
+ return { ok: true };
+}
+
+function asCivil(value: Date): CivilDate {
+ return value.toISOString().slice(0, 10);
+}
+
+function asDate(value: CivilDate): Date {
+ return new Date(`${value}T00:00:00.000Z`);
+}
+
+function revalidateAll(): void {
+ revalidatePath('/admin');
+ revalidatePath('/admin/vacances');
+ revalidatePath('/admin/exceptions');
+}
diff --git a/app/admin/vacances/page.tsx b/app/admin/vacances/page.tsx
new file mode 100644
index 0000000..724b5d6
--- /dev/null
+++ b/app/admin/vacances/page.tsx
@@ -0,0 +1,52 @@
+import { auth } from '@/lib/auth';
+import { prisma } from '@/lib/db';
+import { toCivilDate } from '@/lib/schedule/civil';
+import { loadSettings } from '@/lib/schedule/context';
+import { periodsOverlap, upcomingPeriods } from '@/lib/schedule/vacations';
+
+import VacationManager, { type VacationRow } from './VacationManager';
+import YearCalendar from './YearCalendar';
+
+export const metadata = { title: 'Vacances — ITA ITO' };
+
+export default async function VacationsPage() {
+ const [session, settings] = await Promise.all([auth(), loadSettings()]);
+ const today = toCivilDate(new Date(), settings.timezone);
+
+ const rows = await prisma.vacationPeriod.findMany({ orderBy: { startDate: 'asc' } });
+
+ const periods: VacationRow[] = rows.map((row) => {
+ const startDate = row.startDate.toISOString().slice(0, 10);
+ const endDate = row.endDate.toISOString().slice(0, 10);
+ return {
+ id: row.id,
+ startDate,
+ endDate,
+ labelFr: row.labelFr,
+ labelEn: row.labelEn,
+ isCurrent: periodsOverlap({ startDate, endDate }, { startDate: today, endDate: today }),
+ };
+ });
+
+ return (
+
+ Vacances
+
+ Les fermetures longues. Une exception ponctuelle posée sur un de ces jours continue de
+ primer : rien n’est écrasé.
+
+
+
+
+
+ Vue sur douze mois
+
+
+
+
+
+ );
+}
diff --git a/lib/schedule/vacations.test.ts b/lib/schedule/vacations.test.ts
new file mode 100644
index 0000000..afbab05
--- /dev/null
+++ b/lib/schedule/vacations.test.ts
@@ -0,0 +1,150 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ countDays,
+ findOverlap,
+ MAX_PERIOD_DAYS,
+ periodsOverlap,
+ upcomingPeriods,
+ validatePeriod,
+ type NamedPeriod,
+} from './vacations';
+
+const period = (startDate: string, endDate: string): { startDate: string; endDate: string } => ({
+ startDate,
+ endDate,
+});
+
+const named = (id: string, startDate: string, endDate: string): NamedPeriod => ({
+ id,
+ labelFr: id,
+ startDate,
+ endDate,
+});
+
+describe('countDays', () => {
+ it('counts both ends', () => {
+ // The shop reads "closed from the 20th to the 22nd" as three days.
+ expect(countDays(period('2026-09-20', '2026-09-22'))).toBe(3);
+ });
+
+ it('counts a single day as one', () => {
+ expect(countDays(period('2026-09-20', '2026-09-20'))).toBe(1);
+ });
+
+ it('crosses a year boundary', () => {
+ expect(countDays(period('2026-12-30', '2027-01-02'))).toBe(4);
+ });
+});
+
+describe('periodsOverlap', () => {
+ it('detects a full overlap', () => {
+ expect(periodsOverlap(period('2026-09-20', '2026-09-30'), period('2026-09-22', '2026-09-24'))).toBe(
+ true,
+ );
+ });
+
+ it('detects a partial overlap from either side', () => {
+ expect(periodsOverlap(period('2026-09-20', '2026-09-25'), period('2026-09-24', '2026-09-28'))).toBe(
+ true,
+ );
+ expect(periodsOverlap(period('2026-09-24', '2026-09-28'), period('2026-09-20', '2026-09-25'))).toBe(
+ true,
+ );
+ });
+
+ it('counts a single shared day as an overlap', () => {
+ expect(periodsOverlap(period('2026-09-20', '2026-09-22'), period('2026-09-22', '2026-09-24'))).toBe(
+ true,
+ );
+ });
+
+ it('leaves adjacent periods alone', () => {
+ // Ending on the 22nd and starting on the 23rd share nothing.
+ expect(periodsOverlap(period('2026-09-20', '2026-09-22'), period('2026-09-23', '2026-09-25'))).toBe(
+ false,
+ );
+ });
+
+ it('leaves distant periods alone', () => {
+ expect(periodsOverlap(period('2026-01-01', '2026-01-10'), period('2026-08-01', '2026-08-10'))).toBe(
+ false,
+ );
+ });
+});
+
+describe('findOverlap', () => {
+ const existing = [named('été', '2026-07-20', '2026-08-10'), named('noël', '2026-12-24', '2027-01-02')];
+
+ it('names the period that collides', () => {
+ expect(findOverlap(period('2026-08-05', '2026-08-15'), existing)?.id).toBe('été');
+ });
+
+ it('returns nothing when the candidate is free', () => {
+ expect(findOverlap(period('2026-09-01', '2026-09-10'), existing)).toBeNull();
+ });
+
+ it('returns nothing against an empty list', () => {
+ expect(findOverlap(period('2026-09-01', '2026-09-10'), [])).toBeNull();
+ });
+});
+
+describe('validatePeriod', () => {
+ it('accepts a sound period', () => {
+ expect(validatePeriod({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: 'Congés' })).toEqual(
+ [],
+ );
+ });
+
+ it('refuses missing dates', () => {
+ expect(validatePeriod({ startDate: '', endDate: '2026-08-10', labelFr: 'Congés' })).toHaveLength(1);
+ });
+
+ it('refuses a reversed range', () => {
+ expect(
+ validatePeriod({ startDate: '2026-08-10', endDate: '2026-07-20', labelFr: 'Congés' }).join(' '),
+ ).toMatch(/précède/);
+ });
+
+ it('refuses a period longer than a year', () => {
+ expect(
+ validatePeriod({ startDate: '2026-01-01', endDate: '2027-06-01', labelFr: 'Congés' }).join(' '),
+ ).toMatch(String(MAX_PERIOD_DAYS));
+ });
+
+ it('requires a label, because it is shown on the screen', () => {
+ expect(
+ validatePeriod({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: ' ' }).join(' '),
+ ).toMatch(/libellé/);
+ });
+
+ it('reports several problems at once', () => {
+ expect(
+ validatePeriod({ startDate: '2026-08-10', endDate: '2026-07-20', labelFr: '' }),
+ ).toHaveLength(2);
+ });
+});
+
+describe('upcomingPeriods', () => {
+ const periods = [
+ named('noël', '2026-12-24', '2027-01-02'),
+ named('passé', '2026-02-01', '2026-02-10'),
+ named('été', '2026-07-20', '2026-08-10'),
+ ];
+
+ it('drops periods that have finished', () => {
+ expect(upcomingPeriods(periods, '2026-09-20').map((p) => p.id)).toEqual(['noël']);
+ });
+
+ it('keeps a period currently in progress', () => {
+ expect(upcomingPeriods(periods, '2026-08-01').map((p) => p.id)).toEqual(['été', 'noël']);
+ });
+
+ it('keeps a period ending today', () => {
+ expect(upcomingPeriods(periods, '2026-08-10').map((p) => p.id)).toContain('été');
+ });
+
+ it('sorts by start date', () => {
+ expect(upcomingPeriods(periods, '2026-01-01').map((p) => p.id)).toEqual(['passé', 'été', 'noël']);
+ });
+});
diff --git a/lib/schedule/vacations.ts b/lib/schedule/vacations.ts
new file mode 100644
index 0000000..b1db0e5
--- /dev/null
+++ b/lib/schedule/vacations.ts
@@ -0,0 +1,65 @@
+/**
+ * Closure periods.
+ *
+ * A vacation period is a source of truth, never expanded into exception rows:
+ * the resolver reads it directly. That is why editing one can never orphan
+ * anything, and why overlapping periods are worth refusing rather than
+ * silently merging — two rows covering the same day would both be "in effect"
+ * with no way to say which.
+ */
+
+import { civilDaysBetween, compareCivil, type CivilDate } from './civil';
+
+/** Longer than a year is a data-entry accident, not a closure. */
+export const MAX_PERIOD_DAYS = 366;
+
+export type Period = {
+ startDate: CivilDate;
+ endDate: CivilDate;
+};
+
+export type NamedPeriod = Period & { id: string; labelFr: string };
+
+/** Inclusive on both ends, as the shop would read it off a note on the door. */
+export function countDays(period: Period): number {
+ return civilDaysBetween(period.startDate, period.endDate) + 1;
+}
+
+export function periodsOverlap(a: Period, b: Period): boolean {
+ return compareCivil(a.startDate, b.endDate) <= 0 && compareCivil(b.startDate, a.endDate) <= 0;
+}
+
+/** The first existing period a candidate would collide with, if any. */
+export function findOverlap(candidate: Period, existing: readonly NamedPeriod[]): NamedPeriod | null {
+ return existing.find((period) => periodsOverlap(candidate, period)) ?? null;
+}
+
+export function validatePeriod(input: {
+ startDate: CivilDate;
+ endDate: CivilDate;
+ labelFr: string;
+}): string[] {
+ const errors: string[] = [];
+
+ if (!input.startDate || !input.endDate) {
+ errors.push('Indiquez une date de début et une date de fin.');
+ return errors;
+ }
+ if (compareCivil(input.startDate, input.endDate) > 0) {
+ errors.push('La date de fin précède la date de début.');
+ } else if (countDays(input) > MAX_PERIOD_DAYS) {
+ errors.push(`Une période ne peut pas dépasser ${MAX_PERIOD_DAYS} jours.`);
+ }
+ if (input.labelFr.trim().length === 0) {
+ errors.push('Donnez un libellé à cette période (il s’affiche sur l’écran).');
+ }
+
+ return errors;
+}
+
+/** Periods still to come or in progress, soonest first. */
+export function upcomingPeriods(periods: readonly T[], today: CivilDate): T[] {
+ return periods
+ .filter((period) => compareCivil(period.endDate, today) >= 0)
+ .sort((a, b) => compareCivil(a.startDate, b.startDate));
+}
diff --git a/tests/integration/vacations-action.test.ts b/tests/integration/vacations-action.test.ts
new file mode 100644
index 0000000..f936aaf
--- /dev/null
+++ b/tests/integration/vacations-action.test.ts
@@ -0,0 +1,116 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { prisma } from '@/lib/db';
+
+import { hasDatabase, resetDatabase } from './helpers';
+
+const caller = { email: 'test@ita-ito.test', role: 'admin' as const };
+const gate = vi.hoisted(() => ({
+ result: { ok: true, value: { email: 'test@ita-ito.test', role: 'admin' as const } },
+}));
+
+vi.mock('@/lib/auth/actions', () => ({ requireAdmin: async () => gate.result }));
+vi.mock('next/cache', () => ({ revalidatePath: () => undefined }));
+
+const { createVacation, deleteVacation } = await import('@/app/admin/vacances/actions');
+
+describe.skipIf(!hasDatabase)('vacation actions', () => {
+ beforeEach(async () => {
+ gate.result = { ok: true, value: caller };
+ await resetDatabase();
+ await prisma.auditLog.deleteMany();
+ });
+
+ it('creates a period and records who did it', async () => {
+ const result = await createVacation({
+ startDate: '2026-07-20',
+ endDate: '2026-08-10',
+ labelFr: ' Congés d’été ',
+ });
+
+ expect(result).toEqual({ ok: true });
+ const stored = await prisma.vacationPeriod.findMany();
+ expect(stored).toHaveLength(1);
+ // The label is trimmed: it goes straight onto the shop window.
+ expect(stored[0]?.labelFr).toBe('Congés d’été');
+ expect(stored[0]?.createdBy).toBe(caller.email);
+
+ const entry = await prisma.auditLog.findFirst();
+ expect(entry).toMatchObject({ action: 'vacation.create', entity: 'VacationPeriod' });
+ expect(entry?.diff).toMatchObject({ jours: { after: 22 } });
+ });
+
+ it('refuses a period overlapping an existing one, naming it', async () => {
+ await createVacation({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: 'Été' });
+
+ const result = await createVacation({
+ startDate: '2026-08-05',
+ endDate: '2026-08-20',
+ labelFr: 'Encore',
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ // Two periods covering one day would both be in effect with no way to
+ // say which, so the message has to name the collision.
+ expect(result.error).toContain('Été');
+ }
+ expect(await prisma.vacationPeriod.count()).toBe(1);
+ });
+
+ it('accepts a period starting the day after another ends', async () => {
+ await createVacation({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: 'Été' });
+ const result = await createVacation({
+ startDate: '2026-08-11',
+ endDate: '2026-08-20',
+ labelFr: 'Suite',
+ });
+
+ expect(result).toEqual({ ok: true });
+ expect(await prisma.vacationPeriod.count()).toBe(2);
+ });
+
+ it('refuses a reversed range and an empty label', async () => {
+ expect(
+ (await createVacation({ startDate: '2026-08-10', endDate: '2026-07-20', labelFr: 'X' })).ok,
+ ).toBe(false);
+ expect(
+ (await createVacation({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: ' ' })).ok,
+ ).toBe(false);
+ expect(await prisma.vacationPeriod.count()).toBe(0);
+ });
+
+ it('deletes a period and logs what it was', async () => {
+ await createVacation({ startDate: '2026-07-20', endDate: '2026-08-10', labelFr: 'Été' });
+ const created = await prisma.vacationPeriod.findFirstOrThrow();
+ await prisma.auditLog.deleteMany();
+
+ expect(await deleteVacation(created.id)).toEqual({ ok: true });
+ expect(await prisma.vacationPeriod.count()).toBe(0);
+
+ const entry = await prisma.auditLog.findFirst();
+ expect(entry?.action).toBe('vacation.delete');
+ expect(entry?.diff).toMatchObject({ libellé: { before: 'Été', after: null } });
+ });
+
+ it('reports a period that has already gone', async () => {
+ expect(await deleteVacation('does-not-exist')).toEqual({
+ ok: false,
+ error: 'Cette période a déjà été supprimée.',
+ });
+ });
+
+ it('refuses a read-only account', async () => {
+ gate.result = { ok: false, error: 'Ce compte est en lecture seule.' } as never;
+
+ const result = await createVacation({
+ startDate: '2026-07-20',
+ endDate: '2026-08-10',
+ labelFr: 'Été',
+ });
+
+ expect(result.ok).toBe(false);
+ expect(await prisma.vacationPeriod.count()).toBe(0);
+ expect(await prisma.auditLog.count()).toBe(0);
+ });
+});