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:
2026-09-20 19:11:28 +02:00
co-authored by Claude Opus 5
parent 2090584e0a
commit 7d23b14ad8
6 changed files with 818 additions and 0 deletions
+338
View File
@@ -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&nbsp;: 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(' · ');
}