Revert "refactor: make the reference week a dense one-row-per-day table"

This reverts commit c8235763b0.
This commit is contained in:
2026-09-20 19:08:01 +02:00
parent c8235763b0
commit 2090584e0a
3 changed files with 137 additions and 164 deletions
+133 -149
View File
@@ -6,7 +6,6 @@ import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate';
import { import {
changedDays, changedDays,
DAY_NAMES_FR, DAY_NAMES_FR,
DAY_SHORT_FR,
duplicateToOpenDays, duplicateToOpenDays,
validateWeek, validateWeek,
type WeekDraft, type WeekDraft,
@@ -20,11 +19,11 @@ type Props = {
}; };
/** /**
* The reference week, as one row per day. * The reference week.
* *
* Density is the point: all seven days have to be readable at a glance on a * Built for a phone held in one hand behind the counter: one card per day,
* phone held behind the counter, so the whole week fits on one screen and a * native time inputs so the platform keyboard does the work, and the
* change can be checked without scrolling back up. * consequences of a change shown before it is saved rather than after.
*/ */
export default function WeekEditor({ initial, canEdit }: Props) { export default function WeekEditor({ initial, canEdit }: Props) {
const [week, setWeek] = useState<WeekDraft>(initial); const [week, setWeek] = useState<WeekDraft>(initial);
@@ -37,20 +36,15 @@ export default function WeekEditor({ initial, canEdit }: Props) {
const pendingChanges = useMemo(() => changedDays(saved, week), [saved, week]); const pendingChanges = useMemo(() => changedDays(saved, week), [saved, week]);
const blocked = Object.keys(errors).length > 0; const blocked = Object.keys(errors).length > 0;
function touch() { function update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
setMessage(null); setMessage(null);
setServerErrors({}); setServerErrors({});
}
function update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
touch();
setWeek((current) => setWeek((current) =>
current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)), current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
); );
} }
function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) { function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) {
touch();
setWeek((current) => setWeek((current) =>
current.map((day) => current.map((day) =>
day.dayOfWeek === dayOfWeek day.dayOfWeek === dayOfWeek
@@ -63,21 +57,36 @@ export default function WeekEditor({ initial, canEdit }: Props) {
: day, : day,
), ),
); );
setMessage(null);
}
function addSlot(dayOfWeek: number) {
update(dayOfWeek, {
slots: [
...(week.find((day) => day.dayOfWeek === dayOfWeek)?.slots ?? []),
{ open: '14:00', close: '18:30' },
],
});
}
function removeSlot(dayOfWeek: number, index: number) {
const day = week.find((entry) => entry.dayOfWeek === dayOfWeek);
update(dayOfWeek, { slots: (day?.slots ?? []).filter((_, position) => position !== index) });
} }
function submit() { function submit() {
touch(); setMessage(null);
setServerErrors({});
startTransition(async () => { startTransition(async () => {
const result = await saveWeek(week); const result = await saveWeek(week);
if (result.ok) { if (result.ok) {
setSaved(week); setSaved(week);
const count = result.changed.length;
setMessage({ setMessage({
kind: 'ok', kind: 'ok',
text: text:
count === 0 result.changed.length === 0
? 'Aucun changement à enregistrer.' ? 'Aucun changement à enregistrer.'
: `Enregistré — ${count} jour${count > 1 ? 's' : ''} modifié${count > 1 ? 's' : ''}.`, : `Horaires enregistrés (${result.changed.length} jour${result.changed.length > 1 ? 's' : ''} modifié${result.changed.length > 1 ? 's' : ''}).`,
}); });
} else { } else {
setServerErrors(result.dayErrors ?? {}); setServerErrors(result.dayErrors ?? {});
@@ -87,166 +96,138 @@ export default function WeekEditor({ initial, canEdit }: Props) {
} }
return ( return (
<div className="mt-6"> <div className="mt-8">
{!canEdit ? ( {!canEdit ? (
<p className="mb-4 rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface-muted)] px-3 py-2 text-sm text-[var(--ink-muted)]"> <p className="mb-6 rounded-[var(--radius-md)] border border-[var(--line-strong)] bg-[var(--surface-muted)] px-4 py-3 text-sm text-[var(--ink-muted)]">
Compte en lecture seule&nbsp;: consultation uniquement. Ce compte est en lecture seule&nbsp;: les horaires sont consultables mais pas modifiables.
</p> </p>
) : null} ) : null}
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]"> <div className="space-y-3">
{week.map((day, position) => { {week.map((day) => {
const dayErrors = [ const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])];
...(errors[day.dayOfWeek] ?? []),
...(serverErrors[day.dayOfWeek] ?? []),
];
const isChanged = pendingChanges.includes(day.dayOfWeek); const isChanged = pendingChanges.includes(day.dayOfWeek);
const fullName = DAY_NAMES_FR[day.dayOfWeek] ?? '';
return ( return (
<div <section
key={day.dayOfWeek} key={day.dayOfWeek}
className={`${position > 0 ? 'border-t border-[var(--line)]' : ''} ${ className={`rounded-[var(--radius-md)] border bg-[var(--surface)] p-4 ${
dayErrors.length > 0 ? 'bg-[var(--danger-tint)]' : '' dayErrors.length > 0 ? 'border-[var(--danger)]' : 'border-[var(--line)]'
}`} }`}
> >
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2"> <div className="flex flex-wrap items-center gap-3">
<span <h2 className="min-w-28 text-base font-medium">{DAY_NAMES_FR[day.dayOfWeek]}</h2>
className={`w-9 shrink-0 text-sm font-medium tabular-nums ${
day.isClosed ? 'text-[var(--ink-muted)]' : ''
}`}
>
<span aria-hidden>{DAY_SHORT_FR[day.dayOfWeek]}</span>
<span className="sr-only">{fullName}</span>
</span>
<input <label className="flex items-center gap-2 text-sm">
type="checkbox" <input
role="switch" type="checkbox"
aria-label={`Ouvert le ${fullName.toLowerCase()}`} className="size-4 accent-[var(--accent)]"
className="size-4 shrink-0 accent-[var(--accent)]" checked={!day.isClosed}
checked={!day.isClosed} disabled={!canEdit}
disabled={!canEdit} onChange={(event) =>
onChange={(event) => update(day.dayOfWeek, {
update(day.dayOfWeek, { isClosed: !event.target.checked,
isClosed: !event.target.checked, slots:
slots: event.target.checked && day.slots.length === 0
event.target.checked && day.slots.length === 0 ? [{ open: '10:00', close: '18:30' }]
? [{ open: '10:00', close: '18:30' }] : day.slots,
: day.slots, })
}) }
} />
/> Ouvert
</label>
{day.isClosed ? (
<span className="text-sm text-[var(--ink-muted)]">Fermé</span>
) : (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
{day.slots.map((slot, index) => (
<span key={index} className="flex items-center gap-1">
<input
type="time"
aria-label={`${fullName} — ouverture ${index + 1}`}
value={slot.open}
disabled={!canEdit}
onChange={(event) =>
updateSlot(day.dayOfWeek, index, 'open', event.target.value)
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-1.5 py-1 text-sm tabular-nums"
/>
<span aria-hidden className="text-[var(--ink-muted)]">
–
</span>
<input
type="time"
aria-label={`${fullName} — fermeture ${index + 1}`}
value={slot.close}
disabled={!canEdit}
onChange={(event) =>
updateSlot(day.dayOfWeek, index, 'close', event.target.value)
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-1.5 py-1 text-sm tabular-nums"
/>
{canEdit && day.slots.length > 1 ? (
<button
type="button"
title={`Retirer cette plage du ${fullName.toLowerCase()}`}
aria-label={`Retirer la plage ${index + 1} du ${fullName.toLowerCase()}`}
onClick={() =>
update(day.dayOfWeek, {
slots: day.slots.filter((_, at) => at !== index),
})
}
className="rounded-[var(--radius-sm)] px-1 text-[var(--ink-muted)] hover:text-[var(--danger)]"
>
×
</button>
) : null}
</span>
))}
</div>
)}
{canEdit && !day.isClosed ? (
<span className="ml-auto flex shrink-0 items-center gap-1">
{day.slots.length < MAX_SLOTS_PER_DAY ? (
<button
type="button"
title="Ajouter une plage (pause de midi)"
aria-label={`Ajouter une plage le ${fullName.toLowerCase()}`}
onClick={() =>
update(day.dayOfWeek, {
slots: [...day.slots, { open: '14:00', close: '18:30' }],
})
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-2 py-0.5 text-sm text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink)]"
>
+&nbsp;plage
</button>
) : null}
<button
type="button"
title="Appliquer ces horaires aux autres jours ouverts"
aria-label={`Appliquer les horaires du ${fullName.toLowerCase()} aux autres jours ouverts`}
onClick={() => {
touch();
setWeek(duplicateToOpenDays(week, day.dayOfWeek));
}}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] px-2 py-0.5 text-sm text-[var(--ink-muted)] hover:bg-[var(--surface-muted)] hover:text-[var(--ink)]"
>
copier
</button>
</span>
) : null}
{isChanged ? ( {isChanged ? (
<span <span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
aria-label="modifié" modifié
title="Modifié, pas encore enregistré" </span>
className="size-1.5 shrink-0 rounded-full bg-[var(--accent)]"
/>
) : null} ) : null}
</div> </div>
{day.isClosed ? (
<p className="mt-3 text-sm text-[var(--ink-muted)]">Fermé toute la journée.</p>
) : (
<div className="mt-3 space-y-2">
{day.slots.map((slot, index) => (
<div key={index} className="flex flex-wrap items-center gap-2">
<input
type="time"
aria-label={`${DAY_NAMES_FR[day.dayOfWeek]} — ouverture ${index + 1}`}
value={slot.open}
disabled={!canEdit}
onChange={(event) =>
updateSlot(day.dayOfWeek, index, 'open', event.target.value)
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums"
/>
<span aria-hidden className="text-[var(--ink-muted)]">
–
</span>
<input
type="time"
aria-label={`${DAY_NAMES_FR[day.dayOfWeek]} — fermeture ${index + 1}`}
value={slot.close}
disabled={!canEdit}
onChange={(event) =>
updateSlot(day.dayOfWeek, index, 'close', event.target.value)
}
className="rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-1.5 text-base tabular-nums"
/>
{canEdit && day.slots.length > 1 ? (
<button
type="button"
onClick={() => removeSlot(day.dayOfWeek, index)}
className="rounded-[var(--radius-sm)] px-2 py-1.5 text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
>
Retirer
</button>
) : null}
</div>
))}
{canEdit && day.slots.length < MAX_SLOTS_PER_DAY ? (
<button
type="button"
onClick={() => addSlot(day.dayOfWeek)}
className="text-sm text-[var(--accent-ink)] underline-offset-2 hover:underline"
>
+ Ajouter une plage (pause de midi)
</button>
) : null}
{canEdit ? (
<div>
<button
type="button"
onClick={() => setWeek(duplicateToOpenDays(week, day.dayOfWeek))}
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
>
Dupliquer sur les autres jours ouverts
</button>
</div>
) : null}
</div>
)}
{dayErrors.length > 0 ? ( {dayErrors.length > 0 ? (
<ul role="alert" className="px-3 pb-2 text-sm text-[var(--danger)]"> <ul role="alert" className="mt-3 space-y-1 text-sm text-[var(--danger)]">
{dayErrors.map((error) => ( {dayErrors.map((error) => (
<li key={error}>{error}</li> <li key={error}>{error}</li>
))} ))}
</ul> </ul>
) : null} ) : null}
</div> </section>
); );
})} })}
</div> </div>
{canEdit ? ( {canEdit ? (
<div className="sticky bottom-0 mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-[var(--line)] bg-[var(--canvas)] py-3"> <div className="sticky bottom-0 mt-6 flex flex-wrap items-center gap-3 border-t border-[var(--line)] bg-[var(--canvas)] py-4">
<button <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={pending || blocked || pendingChanges.length === 0} disabled={pending || blocked || pendingChanges.length === 0}
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-40" className="rounded-[var(--radius-md)] bg-[var(--accent)] px-5 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-40"
> >
{pending ? 'Enregistrement…' : 'Enregistrer'} {pending ? 'Enregistrement…' : 'Enregistrer'}
</button> </button>
@@ -256,20 +237,23 @@ export default function WeekEditor({ initial, canEdit }: Props) {
type="button" type="button"
onClick={() => { onClick={() => {
setWeek(saved); setWeek(saved);
touch(); setMessage(null);
setServerErrors({});
}} }}
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline" className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
> >
Annuler Annuler les modifications
</button> </button>
) : null} ) : null}
<p className="text-sm text-[var(--ink-muted)]" aria-live="polite"> <p className="text-sm text-[var(--ink-muted)]" aria-live="polite">
{blocked {blocked
? 'Corrigez les erreurs signalées.' ? 'Corrigez les erreurs ci-dessus.'
: pendingChanges.length === 0 : pendingChanges.length === 0
? 'Aucune modification.' ? 'Aucune modification.'
: `Sera modifié : ${pendingChanges.map((d) => DAY_SHORT_FR[d]).join(', ')}.`} : `${pendingChanges.length} jour${pendingChanges.length > 1 ? 's' : ''} sera modifié : ${pendingChanges
.map((dayOfWeek) => DAY_NAMES_FR[dayOfWeek])
.join(', ')}.`}
</p> </p>
</div> </div>
) : null} ) : null}
@@ -277,7 +261,7 @@ export default function WeekEditor({ initial, canEdit }: Props) {
{message ? ( {message ? (
<p <p
role="status" role="status"
className={`mt-3 rounded-[var(--radius-md)] px-3 py-2 text-sm ${ className={`mt-4 rounded-[var(--radius-md)] px-4 py-3 text-sm ${
message.kind === 'ok' message.kind === 'ok'
? 'bg-[var(--success-tint)] text-[var(--success)]' ? 'bg-[var(--success-tint)] text-[var(--success)]'
: 'bg-[var(--danger-tint)] text-[var(--danger)]' : 'bg-[var(--danger-tint)] text-[var(--danger)]'
+4 -4
View File
@@ -22,11 +22,11 @@ export default async function HoursPage() {
); );
return ( return (
<main className="mx-auto max-w-3xl px-4 py-8"> <main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl">Horaires fixes</h1> <h1 className="text-2xl">Horaires fixes</h1>
<p className="mt-1 text-sm text-[var(--ink-muted)]"> <p className="mt-2 max-w-prose text-[var(--ink-muted)]">
Semaine de référence. Fermetures ponctuelles, vacances et jours fériés priment sur ces La semaine de référence. Les fermetures ponctuelles, les vacances et les jours fériés se
horaires. règlent ailleurs et priment sur ces horaires.
</p> </p>
<WeekEditor initial={initial} canEdit={session?.user?.role === 'admin'} /> <WeekEditor initial={initial} canEdit={session?.user?.role === 'admin'} />
-11
View File
@@ -12,17 +12,6 @@ import { validateSlots } from './validate';
/** Monday first: how the week is read, and how the screen draws it. */ /** Monday first: how the week is read, and how the screen draws it. */
export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; export const WEEK_ORDER = [1, 2, 3, 4, 5, 6, 0] as const;
/** Three letters, for the dense week table. */
export const DAY_SHORT_FR: Record<number, string> = {
0: 'Dim',
1: 'Lun',
2: 'Mar',
3: 'Mer',
4: 'Jeu',
5: 'Ven',
6: 'Sam',
};
export const DAY_NAMES_FR: Record<number, string> = { export const DAY_NAMES_FR: Record<number, string> = {
0: 'Dimanche', 0: 'Dimanche',
1: 'Lundi', 1: 'Lundi',