Files
ita-ito-horaires/app/admin/exceptions/ExceptionsManager.tsx
T
vliaudatandClaude Opus 5 7312a121df feat: let the quick schedule changes target any day, not just today
Opening later and closing earlier were fixed to today, which was an
assumption rather than a requirement: closing next Thursday afternoon is
the same decision as closing this one, and the shop knows about it in
advance more often than not.

The date is a single control and still defaults to today, so the
two-click path for the most frequent case is unchanged.

The usual hours differ from one weekday to the next, and "opens later"
is late relative to them, so the whole sixty-day window is resolved on
the server and handed over at once. Sixty small entries cost less than a
round trip per change of date.

The date control's accessible name is explicit: "du" alone collided with
the "Du" field of the range form below it, which the end-to-end run
caught as an ambiguous locator — and an ambiguous locator is an
ambiguous label for anyone using a screen reader.

The end-to-end suite ran before this was pushed, unlike last time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
2026-09-22 23:17:26 +02:00

353 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
};
/** 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 [openFrom, setOpenFrom] = useState('14:00');
const [closeAt, setCloseAt] = useState('16:00');
const day = days.find((candidate) => candidate.date === date) ?? days[0];
const isToday = day?.date === today;
const usualSlots = day?.usualSlots ?? [];
const isOpenThatDay = usualSlots.length > 0;
const lateResult = lateOpening(usualSlots, openFrom);
const earlyResult = earlyClosing(usualSlots, closeAt);
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 ? (
<>
<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 {when}&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(' · ');
}