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.

+ ) : ( + + )} +
+
+ ); +} 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); + }); +});