'use client'; import { useState, useTransition } from 'react'; import TemplatePicker, { type Template } from '@/app/admin/components/TemplatePicker'; import { earlyClosing, lateOpening, usualBounds } from '@/lib/schedule/exceptions'; import type { ExceptionKind, Slot } from '@/lib/schedule/types'; import { MESSAGE_MAX_CHARS } from '@/lib/screen/contract'; import { deleteException, saveException, saveExceptionRange, translateExceptionNote, } 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[]; templates: Template[]; }; 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, days, upcoming, templates }: 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 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, templates, pending, run, }: { today: string; days: SelectableDay[]; templates: Template[]; pending: boolean; run: (work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) => void; }) { const [date, setDate] = useState(today); const [note, setNote] = useState(''); 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[], success: string) { if (!day) { return; } const text = note.trim(); run(async () => { const saved = await saveException({ date: day.date, isClosed: slots.length === 0, slots, reason: 'TEMPORARY', // Empty means no notice: the message now drives what the panel shows, // so it has to be something the shop chose to say rather than a // sentence the form filled in on its behalf. noteFr: text || null, }); if (saved.ok && text) { // Separate from the save: a message is worth having, but not at the // price of making the two-click path wait for the translation // service. A failure simply leaves the French alone on screen. await translateExceptionNote(day.date); } return saved; }, success); } const when = isToday ? 'aujourd’hui' : `le ${day?.label.toLowerCase() ?? ''}`; return (

Modifier les horaires

Habituellement {day?.usualText ?? 'Fermé'}. {day?.isException ? ` Actuellement : ${day.effectiveText}.` : ''}

Affiché sur l’écran tant que la boutique est fermée ce jour-là, et retiré dès qu’elle ouvre.

{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. ) : (

La boutique est déjà fermée {when} : pour une ouverture exceptionnelle, utilisez le formulaire ci-dessous.

)}
); } /** A specific date, or a short run of days. Longer closures are holidays. */ function RangePanel({ templates, pending, run, }: { templates: Template[]; 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(' · '); } /** * "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[], 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 ( <>
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)}
); }