"Opens later at" and "closes earlier at" were prefilled with 14:00 and 16:00 — arbitrary values, wrong on most days, which left the preview showing hours nobody had asked for until both fields were corrected. They now start from the day's real opening and closing, so any adjustment is relative to what that day actually is. A day with a lunch break is bounded by its first opening and its last closing, which is what these two actions act on. Changing the date changes the hours the times are relative to, so a value typed for Tuesday must not survive into Thursday. The fields live in a component keyed on the date and React resets them by remounting — the React compiler rightly refuses an effect that writes state back, and it is the clearer arrangement anyway. The end-to-end test picks a trading day rather than assuming today is one. The first version guarded the assertions behind a visibility check, which would have passed in silence on exactly the days the controls matter least. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useTransition } from 'react';
|
||
|
||
import { earlyClosing, lateOpening, usualBounds } 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;
|
||
};
|
||
|
||
/** A day the quick actions can target, with the hours it would normally have. */
|
||
export type SelectableDay = {
|
||
date: string;
|
||
label: string;
|
||
usualSlots: Slot[];
|
||
usualText: string;
|
||
effectiveText: string;
|
||
isException: boolean;
|
||
};
|
||
|
||
type Props = {
|
||
canEdit: boolean;
|
||
today: string;
|
||
days: SelectableDay[];
|
||
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, days, 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 ? <QuickPanel today={today} days={days} 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 quick 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.
|
||
* The date defaults to today and changing it costs one control: closing this
|
||
* afternoon and closing next Thursday are the same decision, and neither
|
||
* should cost more clicks than the other.
|
||
*/
|
||
function QuickPanel({
|
||
today,
|
||
days,
|
||
pending,
|
||
run,
|
||
}: {
|
||
today: string;
|
||
days: SelectableDay[];
|
||
pending: boolean;
|
||
run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void;
|
||
}) {
|
||
const [date, setDate] = useState(today);
|
||
|
||
const day = days.find((candidate) => candidate.date === date) ?? days[0];
|
||
const isToday = day?.date === today;
|
||
const isOpenThatDay = (day?.usualSlots.length ?? 0) > 0;
|
||
|
||
function apply(slots: Slot[], note: string, success: string) {
|
||
if (!day) {
|
||
return;
|
||
}
|
||
run(
|
||
() =>
|
||
saveException({
|
||
date: day.date,
|
||
isClosed: slots.length === 0,
|
||
slots,
|
||
reason: 'TEMPORARY',
|
||
noteFr: note,
|
||
}),
|
||
success,
|
||
);
|
||
}
|
||
|
||
const when = isToday ? 'aujourd’hui' : `le ${day?.label.toLowerCase() ?? ''}`;
|
||
|
||
return (
|
||
<section className="rounded-[var(--radius-md)] border-2 border-[var(--accent)] bg-[var(--surface)] p-5">
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<h2 className="text-lg">Modifier les horaires</h2>
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<span className="text-[var(--ink-muted)]">du</span>
|
||
<select
|
||
// "du" alone collides with the range form's "Du" field below;
|
||
// the accessible name has to say which control this is.
|
||
aria-label="Jour à modifier"
|
||
value={date}
|
||
onChange={(event) => setDate(event.target.value)}
|
||
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base"
|
||
>
|
||
{days.map((candidate) => (
|
||
<option key={candidate.date} value={candidate.date}>
|
||
{candidate.date === today ? `${candidate.label} (aujourd’hui)` : candidate.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<p className="mt-2 text-sm text-[var(--ink-muted)]">
|
||
Habituellement {day?.usualText ?? 'Fermé'}.
|
||
{day?.isException ? ` Actuellement : ${day.effectiveText}.` : ''}
|
||
</p>
|
||
|
||
<div className="mt-4 space-y-3">
|
||
<button
|
||
type="button"
|
||
disabled={pending}
|
||
onClick={() =>
|
||
apply([], 'Fermeture exceptionnelle', `La boutique sera fermée ${when}.`)
|
||
}
|
||
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"
|
||
>
|
||
{isToday ? 'Fermé aujourd’hui' : `Fermé ${when}`}
|
||
</button>
|
||
|
||
{isOpenThatDay && day ? (
|
||
// Keyed on the date: changing the day changes the hours these times
|
||
// are relative to, and React resets the fields by remounting rather
|
||
// than by an effect writing state back.
|
||
<AdjustHours key={day.date} day={day} pending={pending} apply={apply} />
|
||
) : (
|
||
<p className="text-sm text-[var(--ink-muted)]">
|
||
La boutique est déjà fermée {when} : 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(' · ');
|
||
}
|
||
|
||
/**
|
||
* "Opens later at" and "closes earlier at", for one day.
|
||
*
|
||
* The fields start from the day's own hours. An arbitrary 14:00 is wrong on
|
||
* most days and leaves the preview showing hours nobody asked for until both
|
||
* have been corrected.
|
||
*/
|
||
function AdjustHours({
|
||
day,
|
||
pending,
|
||
apply,
|
||
}: {
|
||
day: SelectableDay;
|
||
pending: boolean;
|
||
apply: (slots: Slot[], note: string, success: string) => void;
|
||
}) {
|
||
const bounds = usualBounds(day.usualSlots);
|
||
const [openFrom, setOpenFrom] = useState(bounds.open);
|
||
const [closeAt, setCloseAt] = useState(bounds.close);
|
||
|
||
const lateResult = lateOpening(day.usualSlots, openFrom);
|
||
const earlyResult = earlyClosing(day.usualSlots, closeAt);
|
||
|
||
return (
|
||
<>
|
||
<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>
|
||
</>
|
||
);
|
||
}
|