From c430205bf2f5a9643dfd5367b55e2024d10c9c86 Mon Sep 17 00:00:00 2001 From: vl Date: Sun, 20 Sep 2026 20:10:58 +0200 Subject: [PATCH] feat: add the closure periods page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a period with a start, an end and a label, see the twelve months ahead at a glance, delete one. The label is required and trimmed: it goes straight onto the shop window. Overlapping periods are refused, and the message names the one they collide with. Two rows covering the same day would both be "in effect" with no way to say which, and silently merging them would lose whichever label the owner meant. Periods that merely touch — one ending the 10th, the next starting the 11th — are fine. The year view exists because a list of date ranges is precise and hard to picture, while "have I left a gap in August?" is the question people actually ask. It renders on the server; it only changes when the data does. A vacation period stays a source of truth and is never expanded into exception rows, so a one-off exception placed inside one still wins and nothing is overwritten. The page says so rather than leaving it to be discovered. The first version of the calendar built the months but never applied the periods to them, so no closure would ever have shown. Caught before commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd --- app/admin/layout.tsx | 1 + app/admin/vacances/VacationManager.tsx | 168 +++++++++++++++++++++ app/admin/vacances/YearCalendar.tsx | 117 ++++++++++++++ app/admin/vacances/actions.ts | 118 +++++++++++++++ app/admin/vacances/page.tsx | 52 +++++++ lib/schedule/vacations.test.ts | 150 ++++++++++++++++++ lib/schedule/vacations.ts | 65 ++++++++ tests/integration/vacations-action.test.ts | 116 ++++++++++++++ 8 files changed, 787 insertions(+) create mode 100644 app/admin/vacances/VacationManager.tsx create mode 100644 app/admin/vacances/YearCalendar.tsx create mode 100644 app/admin/vacances/actions.ts create mode 100644 app/admin/vacances/page.tsx create mode 100644 lib/schedule/vacations.test.ts create mode 100644 lib/schedule/vacations.ts create mode 100644 tests/integration/vacations-action.test.ts 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 ? ( +
+

Nouvelle période de fermeture

+ +
+ + + +
+ +
+ + + {startDate && endDate && startDate <= endDate ? ( +

+ {countDays({ startDate, endDate })} jour + {countDays({ startDate, endDate }) > 1 ? 's' : ''} de fermeture. +

+ ) : null} +
+
+ ) : 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 ? ( + + ) : 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 ( +
+ + + + {months.map((month) => ( + + + + + ))} + +
+ Calendrier des douze prochains mois, jours de fermeture marqués +
+ {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); + }); +});