feat: add the one-off schedule changes page
The page opens on the gesture the shop actually makes: changing today's hours from a phone, behind the counter. "Closed today", "opens later at" and "closes earlier at" are one tap plus a time, and each shows the hours it would produce before it is applied. The derivation is the delicate part and is pure and tested. Opening at 14:00 drops a 10:00-13:00 morning rather than keeping it, and trims the slot the new time falls inside rather than dropping it. Closing early is the mirror. Both are computed against what the day would normally be, ignoring any exception already recorded, since that is what "late" is late relative to. The upcoming list is built by resolving each of the next sixty days, not by reading the exception table. Vacations and public holidays are never materialised as rows, so the table alone would quietly omit most of what is actually in effect. Each entry is badged with the rule that produced it, and only stored exceptions offer a delete. Ranges are capped at 92 days and point at the holidays page beyond that: a typo in a year field should produce an error, not thirty thousand rows. Editing the French note clears the English one. A translation of text that has changed is worse than no translation. Three exports were removed from the actions module before committing: every export in a 'use server' file becomes a publicly callable endpoint, and those three had ended up unused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -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<string, { text: string; className: string }> = {
|
||||||
|
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 (
|
||||||
|
<div className="mt-8 space-y-8">
|
||||||
|
{message ? (
|
||||||
|
<p
|
||||||
|
role="status"
|
||||||
|
className={`rounded-[var(--radius-md)] px-4 py-3 text-sm ${
|
||||||
|
message.kind === 'ok'
|
||||||
|
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||||||
|
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canEdit ? (
|
||||||
|
<TodayPanel
|
||||||
|
today={today}
|
||||||
|
todayLabel={todayLabel}
|
||||||
|
usualSlots={usualSlots}
|
||||||
|
usualText={usualText}
|
||||||
|
effectiveText={effectiveText}
|
||||||
|
todayIsException={todayIsException}
|
||||||
|
pending={pending}
|
||||||
|
run={run}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canEdit ? <RangePanel pending={pending} run={run} /> : null}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-lg">À venir</h2>
|
||||||
|
{upcoming.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-[var(--ink-muted)]">
|
||||||
|
Aucun changement prévu dans les deux prochains mois.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
|
||||||
|
{upcoming.map((day) => {
|
||||||
|
const badge = KIND_LABELS[day.kind ?? 'MANUAL'] ?? KIND_LABELS.MANUAL!;
|
||||||
|
return (
|
||||||
|
<li key={day.date} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||||
|
<span className="font-medium">
|
||||||
|
{day.label}
|
||||||
|
{day.isToday ? ' (aujourd’hui)' : ''}
|
||||||
|
</span>
|
||||||
|
<span className={day.isClosed ? 'text-[var(--ink-muted)]' : ''}>
|
||||||
|
{day.hoursText}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-[var(--radius-sm)] px-2 py-0.5 text-xs ${badge.className}`}
|
||||||
|
>
|
||||||
|
{badge.text}
|
||||||
|
</span>
|
||||||
|
{day.note ? (
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">« {day.note} »</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canEdit && day.canDelete ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
run(() => deleteException(day.date), 'Changement supprimé.')
|
||||||
|
}
|
||||||
|
className="ml-auto text-sm text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section className="rounded-[var(--radius-md)] border-2 border-[var(--accent)] bg-[var(--surface)] p-5">
|
||||||
|
<h2 className="text-lg">Modifier les horaires d’aujourd’hui</h2>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ink-muted)]">
|
||||||
|
{todayLabel} — habituellement {usualText}.
|
||||||
|
{todayIsException ? ` Actuellement : ${effectiveText}.` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => apply([], 'Fermeture exceptionnelle', 'La boutique est fermée aujourd’hui.')}
|
||||||
|
className="w-full rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-3 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40 sm:w-auto"
|
||||||
|
>
|
||||||
|
Fermé aujourd’hui
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpenToday ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending || lateResult.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
apply(lateResult, `Ouverture à ${openFrom}`, `Ouverture repoussée à ${openFrom}.`)
|
||||||
|
}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Ouvre plus tard, à
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label="Nouvelle heure d’ouverture"
|
||||||
|
value={openFrom}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">
|
||||||
|
→ {lateResult.length === 0 ? 'fermé' : summarise(lateResult)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending || earlyResult.length === 0}
|
||||||
|
onClick={() =>
|
||||||
|
apply(earlyResult, `Fermeture à ${closeAt}`, `Fermeture avancée à ${closeAt}.`)
|
||||||
|
}
|
||||||
|
className="rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Ferme plus tôt, à
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label="Nouvelle heure de fermeture"
|
||||||
|
value={closeAt}
|
||||||
|
onChange={(event) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[var(--ink-muted)]">
|
||||||
|
→ {earlyResult.length === 0 ? 'fermé' : summarise(earlyResult)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--ink-muted)]">
|
||||||
|
La boutique est déjà fermée aujourd’hui : pour une ouverture exceptionnelle,
|
||||||
|
utilisez le formulaire ci-dessous.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||||
|
<h2 className="text-lg">Fermer une date ou une période</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-[var(--ink-muted)]">Du</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={start}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStart(event.target.value);
|
||||||
|
if (!end) {
|
||||||
|
setEnd(event.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">
|
||||||
|
<span className="block text-[var(--ink-muted)]">Au (inclus)</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={end}
|
||||||
|
onChange={(event) => setEnd(event.target.value)}
|
||||||
|
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="min-w-48 flex-1 text-sm">
|
||||||
|
<span className="block text-[var(--ink-muted)]">Note affichée (facultatif)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={note}
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Fermeture exceptionnelle"
|
||||||
|
onChange={(event) => setNote(event.target.value)}
|
||||||
|
className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending || !start || !end}
|
||||||
|
onClick={() =>
|
||||||
|
run(
|
||||||
|
() => saveExceptionRange({ start, end, noteFr: note.trim() || null }),
|
||||||
|
'Période enregistrée.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="mt-4 rounded-[var(--radius-md)] border border-[var(--line-strong)] px-4 py-2.5 transition-colors hover:bg-[var(--surface-muted)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Fermer ces dates
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-[var(--ink-muted)]">
|
||||||
|
Pour une fermeture longue (congés annuels), utilisez plutôt la page Vacances.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarise(slots: Slot[]): string {
|
||||||
|
return slots.map((slot) => `${slot.open} – ${slot.close}`).join(' · ');
|
||||||
|
}
|
||||||
@@ -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<ActionResult> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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<ActionResult> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||||
|
<h1 className="text-2xl">Changements ponctuels</h1>
|
||||||
|
<p className="mt-2 max-w-prose text-[var(--ink-muted)]">
|
||||||
|
Une fermeture ou un horaire différent pour une date précise. Ces changements priment sur les
|
||||||
|
horaires fixes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ExceptionsManager
|
||||||
|
canEdit={session?.user?.role === 'admin'}
|
||||||
|
today={today}
|
||||||
|
todayLabel={formatDateFr(today)}
|
||||||
|
usualSlots={usual.slots}
|
||||||
|
usualText={usual.slots.length > 0 ? formatHoursText(usual.slots) : 'Fermé'}
|
||||||
|
effectiveText={effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé'}
|
||||||
|
todayIsException={effective.isException}
|
||||||
|
upcoming={upcoming}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { auth, signOut } from '@/lib/auth';
|
|||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: '/admin', label: 'Tableau de bord' },
|
{ href: '/admin', label: 'Tableau de bord' },
|
||||||
{ href: '/admin/horaires', label: 'Horaires' },
|
{ href: '/admin/horaires', label: 'Horaires' },
|
||||||
|
{ href: '/admin/exceptions', label: 'Exceptions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user