diff --git a/app/admin/exceptions/ExceptionsManager.tsx b/app/admin/exceptions/ExceptionsManager.tsx new file mode 100644 index 0000000..a8c8d93 --- /dev/null +++ b/app/admin/exceptions/ExceptionsManager.tsx @@ -0,0 +1,338 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { earlyClosing, lateOpening } from '@/lib/schedule/exceptions'; +import type { ExceptionKind, Slot } from '@/lib/schedule/types'; + +import { deleteException, saveException, saveExceptionRange } from './actions'; + +export type UpcomingDay = { + date: string; + label: string; + isToday: boolean; + isClosed: boolean; + hoursText: string; + kind: ExceptionKind; + note: string | null; + /** Only a stored exception can be removed; holidays and vacations live elsewhere. */ + canDelete: boolean; +}; + +type Props = { + canEdit: boolean; + today: string; + todayLabel: string; + usualSlots: Slot[]; + usualText: string; + effectiveText: string; + todayIsException: boolean; + upcoming: UpcomingDay[]; +}; + +const KIND_LABELS: Record = { + MANUAL: { text: 'manuelle', className: 'bg-[var(--accent-tint)] text-[var(--accent-ink)]' }, + HOLIDAY: { text: 'jour férié', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' }, + VACATION: { text: 'vacances', className: 'bg-[var(--surface-muted)] text-[var(--ink-muted)]' }, +}; + +export default function ExceptionsManager({ + canEdit, + today, + todayLabel, + usualSlots, + usualText, + effectiveText, + todayIsException, + upcoming, +}: Props) { + 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(); + setMessage(result.ok ? { kind: 'ok', text: success } : { kind: 'error', text: result.error }); + }); + } + + return ( +
+ {message ? ( +

+ {message.text} +

+ ) : null} + + {canEdit ? ( + + ) : null} + + {canEdit ? : null} + +
+

À venir

+ {upcoming.length === 0 ? ( +

+ Aucun changement prévu dans les deux prochains mois. +

+ ) : ( +
    + {upcoming.map((day) => { + const badge = KIND_LABELS[day.kind ?? 'MANUAL'] ?? KIND_LABELS.MANUAL!; + return ( +
  • + + {day.label} + {day.isToday ? ' (aujourd’hui)' : ''} + + + {day.hoursText} + + + {badge.text} + + {day.note ? ( + « {day.note} » + ) : null} + + {canEdit && day.canDelete ? ( + + ) : null} +
  • + ); + })} +
+ )} +
+
+ ); +} + +/** + * The two-click path. This is the gesture the shop actually makes, from a + * phone, standing behind the counter — so it sits at the top and asks for as + * little as possible. + */ +function TodayPanel({ + today, + todayLabel, + usualSlots, + usualText, + effectiveText, + todayIsException, + pending, + run, +}: { + today: string; + todayLabel: string; + usualSlots: Slot[]; + usualText: string; + effectiveText: string; + todayIsException: boolean; + pending: boolean; + run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void; +}) { + const [openFrom, setOpenFrom] = useState('14:00'); + const [closeAt, setCloseAt] = useState('16:00'); + + const isOpenToday = usualSlots.length > 0; + const lateResult = lateOpening(usualSlots, openFrom); + const earlyResult = earlyClosing(usualSlots, closeAt); + + function apply(slots: Slot[], note: string, success: string) { + run( + () => + saveException({ + date: today, + isClosed: slots.length === 0, + slots, + reason: 'TEMPORARY', + noteFr: note, + }), + success, + ); + } + + return ( +
+

Modifier les horaires d’aujourd’hui

+

+ {todayLabel} — habituellement {usualText}. + {todayIsException ? ` Actuellement : ${effectiveText}.` : ''} +

+ +
+ + + {isOpenToday ? ( + <> +
+ + setOpenFrom(event.target.value)} + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" + /> + + → {lateResult.length === 0 ? 'fermé' : summarise(lateResult)} + +
+ +
+ + setCloseAt(event.target.value)} + className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base tabular-nums" + /> + + → {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)} + +
+ + ) : ( +

+ La boutique est déjà fermée aujourd’hui : pour une ouverture exceptionnelle, + utilisez le formulaire ci-dessous. +

+ )} +
+
+ ); +} + +/** A specific date, or a short run of days. Longer closures are holidays. */ +function RangePanel({ + pending, + run, +}: { + pending: boolean; + run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void; +}) { + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [note, setNote] = useState(''); + + return ( +
+

Fermer une date ou une période

+ +
+ + + +
+ + + +

+ Pour une fermeture longue (congés annuels), utilisez plutôt la page Vacances. +

+
+ ); +} + +function summarise(slots: Slot[]): string { + return slots.map((slot) => `${slot.open} – ${slot.close}`).join(' · '); +} diff --git a/app/admin/exceptions/actions.ts b/app/admin/exceptions/actions.ts new file mode 100644 index 0000000..861cbb2 --- /dev/null +++ b/app/admin/exceptions/actions.ts @@ -0,0 +1,179 @@ +'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 { checkRange, expandRange, MAX_RANGE_DAYS } from '@/lib/schedule/exceptions'; +import type { Slot } from '@/lib/schedule/types'; +import { validateSlots } from '@/lib/schedule/validate'; + +export type ExceptionReason = 'TEMPORARY' | 'SPECIAL_EVENT'; + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export type ExceptionInput = { + date: CivilDate; + isClosed: boolean; + slots: Slot[]; + reason: ExceptionReason; + noteFr: string | null; +}; + +export async function saveException(input: ExceptionInput): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const slots = input.isClosed ? [] : input.slots; + if (!input.isClosed) { + const problems = validateSlots(slots); + if (slots.length === 0) { + problems.push('Une ouverture exceptionnelle doit avoir au moins une plage horaire.'); + } + if (problems.length > 0) { + return { ok: false, error: problems.join(' ') }; + } + } + + const date = asDate(input.date); + const existing = await prisma.scheduleException.findUnique({ where: { date } }); + + await prisma.scheduleException.upsert({ + where: { date }, + update: { + isClosed: input.isClosed, + slots: input.isClosed ? undefined : slots, + reason: input.reason, + noteFr: input.noteFr, + // The French text changed, so any English translation is stale. + noteEn: existing?.noteFr === input.noteFr ? undefined : null, + source: 'MANUAL', + }, + create: { + date, + isClosed: input.isClosed, + slots: input.isClosed ? undefined : slots, + reason: input.reason, + noteFr: input.noteFr, + source: 'MANUAL', + createdBy: gate.value.email, + }, + }); + + await recordAudit({ + userEmail: gate.value.email, + action: existing ? 'exception.update' : 'exception.create', + entity: 'ScheduleException', + entityId: input.date, + diff: diffOf( + existing ? { fermé: existing.isClosed, horaires: summarise(existing.slots as Slot[] | null), note: existing.noteFr } : null, + { fermé: input.isClosed, horaires: summarise(slots), note: input.noteFr }, + ), + }); + + revalidateAll(); + return { ok: true }; +} + +export async function saveExceptionRange(input: { + start: CivilDate; + end: CivilDate; + noteFr: string | null; +}): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const problem = checkRange(input.start, input.end); + if (problem === 'EMPTY') { + return { ok: false, error: 'Indiquez une date de début et une date de fin.' }; + } + if (problem === 'REVERSED') { + return { ok: false, error: 'La date de fin précède la date de début.' }; + } + if (problem === 'TOO_LONG') { + return { + ok: false, + error: `Période trop longue (${MAX_RANGE_DAYS} jours au maximum). Utilisez les vacances pour une fermeture plus longue.`, + }; + } + + const dates = expandRange(input.start, input.end); + + await prisma.$transaction( + dates.map((date) => + prisma.scheduleException.upsert({ + where: { date: asDate(date) }, + update: { isClosed: true, slots: undefined, reason: 'TEMPORARY', noteFr: input.noteFr, noteEn: null, source: 'MANUAL' }, + create: { + date: asDate(date), + isClosed: true, + reason: 'TEMPORARY', + noteFr: input.noteFr, + source: 'MANUAL', + createdBy: gate.value.email, + }, + }), + ), + ); + + await recordAudit({ + userEmail: gate.value.email, + action: 'exception.create_range', + entity: 'ScheduleException', + entityId: `${input.start}…${input.end}`, + diff: { période: { before: null, after: `${dates.length} jours fermés` } }, + }); + + revalidateAll(); + return { ok: true }; +} + +export async function deleteException(date: CivilDate): Promise { + const gate = await requireAdmin(); + if (!gate.ok) { + return { ok: false, error: gate.error }; + } + + const existing = await prisma.scheduleException.findUnique({ where: { date: asDate(date) } }); + if (!existing) { + return { ok: false, error: 'Cette exception a déjà été supprimée.' }; + } + + await prisma.scheduleException.delete({ where: { date: asDate(date) } }); + + await recordAudit({ + userEmail: gate.value.email, + action: 'exception.delete', + entity: 'ScheduleException', + entityId: date, + diff: diffOf( + { fermé: existing.isClosed, horaires: summarise(existing.slots as Slot[] | null), note: existing.noteFr }, + null, + ), + }); + + revalidateAll(); + return { ok: true }; +} + +function summarise(slots: Slot[] | null): string { + if (!slots || slots.length === 0) { + return 'Fermé'; + } + return slots.map((slot) => `${slot.open}–${slot.close}`).join(' · '); +} + +function asDate(value: CivilDate): Date { + return new Date(`${value}T00:00:00.000Z`); +} + +function revalidateAll(): void { + revalidatePath('/admin'); + revalidatePath('/admin/exceptions'); +} diff --git a/app/admin/exceptions/page.tsx b/app/admin/exceptions/page.tsx new file mode 100644 index 0000000..376836d --- /dev/null +++ b/app/admin/exceptions/page.tsx @@ -0,0 +1,90 @@ +import { auth } from '@/lib/auth'; +import { prisma } from '@/lib/db'; +import { addCivilDays, toCivilDate } from '@/lib/schedule/civil'; +import { loadScheduleContext, loadSettings } from '@/lib/schedule/context'; +import { formatDateFr, formatHoursText } from '@/lib/schedule/format'; +import { resolveCivilDay } from '@/lib/schedule/resolver'; +import type { ScheduleContext } from '@/lib/schedule/types'; + +import ExceptionsManager, { type UpcomingDay } from './ExceptionsManager'; + +export const metadata = { title: 'Changements ponctuels — ITA ITO' }; + +/** How far ahead the list looks. Far enough for a season, short enough to read. */ +const HORIZON_DAYS = 60; + +export default async function ExceptionsPage() { + const [session, settings] = await Promise.all([auth(), loadSettings()]); + const now = new Date(); + const context = await loadScheduleContext(now, settings); + const today = toCivilDate(now, settings.timezone); + + const stored = await prisma.scheduleException.findMany({ + where: { date: { gte: new Date(`${today}T00:00:00.000Z`) } }, + select: { date: true }, + }); + const deletable = new Set(stored.map((row) => row.date.toISOString().slice(0, 10))); + + const upcoming = collectUpcoming(today, context, deletable); + + // What today would normally be, ignoring any exception already recorded: + // that is what "opening late" is late relative to. + const usual = resolveCivilDay(today, { ...context, exceptions: [] }); + const effective = resolveCivilDay(today, context); + + return ( +
+

Changements ponctuels

+

+ Une fermeture ou un horaire différent pour une date précise. Ces changements priment sur les + horaires fixes. +

+ + 0 ? formatHoursText(usual.slots) : 'Fermé'} + effectiveText={effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé'} + todayIsException={effective.isException} + upcoming={upcoming} + /> +
+ ); +} + +/** + * Every day ahead that departs from the reference week, whatever the cause. + * + * Built by resolving each day rather than by listing the exception table, + * because vacations and public holidays are never materialised as rows — so + * the table alone would quietly omit most of what is actually in effect. + */ +function collectUpcoming( + today: string, + context: ScheduleContext, + deletable: Set, +): UpcomingDay[] { + const days: UpcomingDay[] = []; + + for (let offset = 0; offset <= HORIZON_DAYS; offset += 1) { + const date = addCivilDays(today, offset); + const day = resolveCivilDay(date, context); + if (!day.isException) { + continue; + } + days.push({ + date, + label: formatDateFr(date), + isToday: offset === 0, + isClosed: !day.isOpen, + hoursText: day.slots.length > 0 ? formatHoursText(day.slots) : 'Fermé', + kind: day.exceptionKind ?? 'MANUAL', + note: day.noteFr, + canDelete: deletable.has(date), + }); + } + + return days; +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 6a90067..d98bd4d 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -7,6 +7,7 @@ import { auth, signOut } from '@/lib/auth'; const NAV = [ { href: '/admin', label: 'Tableau de bord' }, { href: '/admin/horaires', label: 'Horaires' }, + { href: '/admin/exceptions', label: 'Exceptions' }, ]; /** diff --git a/lib/schedule/exceptions.test.ts b/lib/schedule/exceptions.test.ts new file mode 100644 index 0000000..f24f9c9 --- /dev/null +++ b/lib/schedule/exceptions.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { checkRange, earlyClosing, expandRange, lateOpening, MAX_RANGE_DAYS } from './exceptions'; +import type { Slot } from './types'; + +const FULL: Slot[] = [{ open: '10:00', close: '18:30' }]; +const SPLIT: Slot[] = [ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, +]; + +describe('lateOpening', () => { + it('trims the only slot', () => { + expect(lateOpening(FULL, '14:00')).toEqual([{ open: '14:00', close: '18:30' }]); + }); + + it('drops a morning that is entirely before the new opening', () => { + // A shop opening at 14:00 does not keep its 10:00-13:00 morning. + expect(lateOpening(SPLIT, '14:00')).toEqual([{ open: '14:00', close: '18:30' }]); + }); + + it('trims the slot the new opening falls inside', () => { + expect(lateOpening(SPLIT, '11:00')).toEqual([ + { open: '11:00', close: '13:00' }, + { open: '14:00', close: '18:30' }, + ]); + }); + + it('changes nothing when the shop already opens later', () => { + expect(lateOpening(FULL, '09:00')).toEqual(FULL); + }); + + it('closes the day when the new opening is past every slot', () => { + expect(lateOpening(FULL, '19:00')).toEqual([]); + expect(lateOpening(SPLIT, '18:30')).toEqual([]); + }); + + it('leaves a closed day closed', () => { + expect(lateOpening([], '14:00')).toEqual([]); + }); + + it('does not mutate its input', () => { + const slots = SPLIT.map((slot) => ({ ...slot })); + lateOpening(slots, '11:00'); + expect(slots).toEqual(SPLIT); + }); +}); + +describe('earlyClosing', () => { + it('trims the only slot', () => { + expect(earlyClosing(FULL, '15:00')).toEqual([{ open: '10:00', close: '15:00' }]); + }); + + it('drops an afternoon that starts after the new closing', () => { + expect(earlyClosing(SPLIT, '13:00')).toEqual([{ open: '10:00', close: '13:00' }]); + }); + + it('trims the slot the new closing falls inside', () => { + expect(earlyClosing(SPLIT, '15:00')).toEqual([ + { open: '10:00', close: '13:00' }, + { open: '14:00', close: '15:00' }, + ]); + }); + + it('changes nothing when the shop already closes earlier', () => { + expect(earlyClosing(FULL, '19:00')).toEqual(FULL); + }); + + it('closes the day when the new closing is before every slot', () => { + expect(earlyClosing(FULL, '09:00')).toEqual([]); + expect(earlyClosing(SPLIT, '10:00')).toEqual([]); + }); + + it('is the mirror of lateOpening on a split day', () => { + // Opening late then closing early must leave the middle of the day. + expect(earlyClosing(lateOpening(SPLIT, '11:00'), '15:00')).toEqual([ + { open: '11:00', close: '13:00' }, + { open: '14:00', close: '15:00' }, + ]); + }); +}); + +describe('expandRange', () => { + it('includes both ends', () => { + expect(expandRange('2026-09-20', '2026-09-22')).toEqual([ + '2026-09-20', + '2026-09-21', + '2026-09-22', + ]); + }); + + it('handles a single day', () => { + expect(expandRange('2026-09-20', '2026-09-20')).toEqual(['2026-09-20']); + }); + + it('crosses a month and a year boundary', () => { + expect(expandRange('2026-12-30', '2027-01-02')).toEqual([ + '2026-12-30', + '2026-12-31', + '2027-01-01', + '2027-01-02', + ]); + }); + + it('returns nothing for a reversed range', () => { + expect(expandRange('2026-09-22', '2026-09-20')).toEqual([]); + }); + + it('refuses a range longer than the ceiling', () => { + // A typo in a year field should produce an error, not thirty thousand rows. + expect(expandRange('2026-01-01', '2030-01-01')).toEqual([]); + }); + + it('accepts a range exactly at the ceiling', () => { + const end = expandRange('2026-01-01', '2026-01-01'); + expect(end).toHaveLength(1); + expect(expandRange('2026-01-01', '2026-04-01').length).toBeLessThanOrEqual(MAX_RANGE_DAYS + 1); + }); +}); + +describe('checkRange', () => { + it('accepts a sound range', () => { + expect(checkRange('2026-09-20', '2026-09-22')).toBeNull(); + }); + + it('names each problem', () => { + expect(checkRange('', '2026-09-22')).toBe('EMPTY'); + expect(checkRange('2026-09-22', '')).toBe('EMPTY'); + expect(checkRange('2026-09-22', '2026-09-20')).toBe('REVERSED'); + expect(checkRange('2026-01-01', '2030-01-01')).toBe('TOO_LONG'); + }); +}); diff --git a/lib/schedule/exceptions.ts b/lib/schedule/exceptions.ts new file mode 100644 index 0000000..3d701c4 --- /dev/null +++ b/lib/schedule/exceptions.ts @@ -0,0 +1,78 @@ +/** + * Deriving a one-off day from the usual one. + * + * "Opening late today" and "closing early today" are the two changes the shop + * actually makes, and both are relative to whatever that day normally does — + * including days with a lunch break, where clipping the wrong slot produces + * hours nobody meant. Pure, so every shape is tested. + */ + +import { addCivilDays, compareCivil, minutesOfTime, type CivilDate } from './civil'; +import type { Slot } from './types'; + +/** A range longer than this is a holiday period, not an exception. */ +export const MAX_RANGE_DAYS = 92; + +/** + * The day's hours if it opens at `from` instead of its usual time. + * + * Slots that finish before the new opening disappear — a shop opening at 14:00 + * does not keep its 10:00-13:00 morning — and the slot straddling it is + * trimmed rather than dropped. + */ +export function lateOpening(slots: readonly Slot[], from: string): Slot[] { + const opensAt = minutesOfTime(from); + return slots + .filter((slot) => minutesOfTime(slot.close) > opensAt) + .map((slot) => (minutesOfTime(slot.open) < opensAt ? { ...slot, open: from } : { ...slot })); +} + +/** + * The day's hours if it closes at `until` instead of its usual time. + * + * The mirror of `lateOpening`: slots starting after the new closing time go, + * and the one straddling it is trimmed. + */ +export function earlyClosing(slots: readonly Slot[], until: string): Slot[] { + const closesAt = minutesOfTime(until); + return slots + .filter((slot) => minutesOfTime(slot.open) < closesAt) + .map((slot) => (minutesOfTime(slot.close) > closesAt ? { ...slot, close: until } : { ...slot })); +} + +/** + * Every date in `[start, end]`, inclusive. + * + * Bounded: a typo in a year field should produce an error, not thirty thousand + * rows. + */ +export function expandRange(start: CivilDate, end: CivilDate): CivilDate[] { + if (compareCivil(start, end) > 0) { + return []; + } + const dates: CivilDate[] = []; + let cursor = start; + while (compareCivil(cursor, end) <= 0) { + dates.push(cursor); + if (dates.length > MAX_RANGE_DAYS) { + return []; + } + cursor = addCivilDays(cursor, 1); + } + return dates; +} + +export type RangeProblem = 'EMPTY' | 'REVERSED' | 'TOO_LONG' | null; + +export function checkRange(start: CivilDate, end: CivilDate): RangeProblem { + if (!start || !end) { + return 'EMPTY'; + } + if (compareCivil(start, end) > 0) { + return 'REVERSED'; + } + if (expandRange(start, end).length === 0) { + return 'TOO_LONG'; + } + return null; +}