diff --git a/app/admin/exceptions/ExceptionsManager.tsx b/app/admin/exceptions/ExceptionsManager.tsx index a8c8d93..8a47e19 100644 --- a/app/admin/exceptions/ExceptionsManager.tsx +++ b/app/admin/exceptions/ExceptionsManager.tsx @@ -19,14 +19,20 @@ export type UpcomingDay = { canDelete: boolean; }; -type Props = { - canEdit: boolean; - today: string; - todayLabel: string; +/** 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; - todayIsException: boolean; + isException: boolean; +}; + +type Props = { + canEdit: boolean; + today: string; + days: SelectableDay[]; upcoming: UpcomingDay[]; }; @@ -36,16 +42,7 @@ const KIND_LABELS: Record = { 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) { +export default function ExceptionsManager({ canEdit, today, days, upcoming }: Props) { const [pending, startTransition] = useTransition(); const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null); @@ -72,18 +69,7 @@ export default function ExceptionsManager({

) : null} - {canEdit ? ( - - ) : null} + {canEdit ? : null} {canEdit ? : null} @@ -138,41 +124,45 @@ export default function ExceptionsManager({ } /** - * 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. + * 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 TodayPanel({ +function QuickPanel({ today, - todayLabel, - usualSlots, - usualText, - effectiveText, - todayIsException, + days, pending, run, }: { today: string; - todayLabel: string; - usualSlots: Slot[]; - usualText: string; - effectiveText: string; - todayIsException: boolean; + 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 isOpenToday = usualSlots.length > 0; + 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: today, + date: day.date, isClosed: slots.length === 0, slots, reason: 'TEMPORARY', @@ -182,25 +172,49 @@ function TodayPanel({ ); } + const when = isToday ? 'aujourd’hui' : `le ${day?.label.toLowerCase() ?? ''}`; + return (
-

Modifier les horaires d’aujourd’hui

-

- {todayLabel} — habituellement {usualText}. - {todayIsException ? ` Actuellement : ${effectiveText}.` : ''} +

+

Modifier les horaires

+ +
+ +

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

- {isOpenToday ? ( + {isOpenThatDay ? ( <>
diff --git a/app/admin/exceptions/page.tsx b/app/admin/exceptions/page.tsx index 376836d..189ffc7 100644 --- a/app/admin/exceptions/page.tsx +++ b/app/admin/exceptions/page.tsx @@ -6,7 +6,7 @@ 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'; +import ExceptionsManager, { type SelectableDay, type UpcomingDay } from './ExceptionsManager'; export const metadata = { title: 'Changements ponctuels — ITA ITO' }; @@ -26,11 +26,7 @@ export default async function ExceptionsPage() { 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); + const days = collectSelectableDays(today, context); return (
@@ -43,17 +39,41 @@ export default async function ExceptionsPage() { 0 ? formatHoursText(usual.slots) : 'Fermé'} - effectiveText={effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé'} - todayIsException={effective.isException} + days={days} upcoming={upcoming} />
); } +/** + * Each day the quick actions can target, with the hours it would normally + * have. + * + * "Opens later" is late relative to the day's usual hours, and those differ + * from one weekday to the next — so the whole window is resolved here rather + * than asking the server again every time the date changes. Sixty small + * entries cost less than a round trip per keystroke. + */ +function collectSelectableDays(today: string, context: ScheduleContext): SelectableDay[] { + const withoutExceptions: ScheduleContext = { ...context, exceptions: [] }; + + return Array.from({ length: HORIZON_DAYS + 1 }, (_, offset) => { + const date = addCivilDays(today, offset); + const usual = resolveCivilDay(date, withoutExceptions); + const effective = resolveCivilDay(date, context); + + return { + date, + label: formatDateFr(date), + usualSlots: usual.slots, + usualText: usual.slots.length > 0 ? formatHoursText(usual.slots) : 'Fermé', + effectiveText: effective.slots.length > 0 ? formatHoursText(effective.slots) : 'Fermé', + isException: effective.isException, + }; + }); +} + /** * Every day ahead that departs from the reference week, whatever the cause. * diff --git a/e2e/schedule.spec.ts b/e2e/schedule.spec.ts index df46658..c66b1eb 100644 --- a/e2e/schedule.spec.ts +++ b/e2e/schedule.spec.ts @@ -18,7 +18,7 @@ test('changing today’s hours reaches the panel', async ({ page, request }) => await page.goto('/admin/exceptions'); await page.getByRole('button', { name: /Fermé aujourd’hui/ }).click(); - await expect(page.getByText(/La boutique est fermée aujourd’hui/)).toBeVisible(); + await expect(page.getByRole('status')).toContainText(/sera fermée aujourd’hui/); // Stored as a manual exception for today, in the shop's timezone. // Read as text: node-postgres parses a DATE column into a local-midnight @@ -37,6 +37,26 @@ test('changing today’s hours reaches the panel', async ({ page, request }) => await expect(page.getByText(/aujourd’hui/).first()).toBeVisible(); }); +test('a quick change can target a day other than today', async ({ page }) => { + const target = today(3); + + await signIn(page, 'admin'); + await page.goto('/admin/exceptions'); + + // The date defaults to today; closing next Thursday must not cost more + // clicks than closing this afternoon. + await page.getByLabel('Jour à modifier').selectOption(target); + await page.getByRole('button', { name: /^Fermé le / }).click(); + + await expect(page.getByRole('status')).toContainText(/sera fermée/); + + const [exception] = await query<{ date: string; isClosed: boolean }>( + 'SELECT date::text AS date, "isClosed" FROM schedule_exceptions', + ); + expect(exception?.date).toBe(target); + expect(exception?.isClosed).toBe(true); +}); + test('the reference week can be edited and shows what will change', async ({ page }) => { await signIn(page, 'admin'); await page.goto('/admin/horaires');