Revert "refactor: make the reference week a dense one-row-per-day table"
This reverts commit c8235763b0.
This commit is contained in:
+133
-149
@@ -6,7 +6,6 @@ import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate';
|
||||
import {
|
||||
changedDays,
|
||||
DAY_NAMES_FR,
|
||||
DAY_SHORT_FR,
|
||||
duplicateToOpenDays,
|
||||
validateWeek,
|
||||
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
|
||||
* phone held behind the counter, so the whole week fits on one screen and a
|
||||
* change can be checked without scrolling back up.
|
||||
* Built for a phone held in one hand behind the counter: one card per day,
|
||||
* native time inputs so the platform keyboard does the work, and the
|
||||
* consequences of a change shown before it is saved rather than after.
|
||||
*/
|
||||
export default function WeekEditor({ initial, canEdit }: Props) {
|
||||
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 blocked = Object.keys(errors).length > 0;
|
||||
|
||||
function touch() {
|
||||
function update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
|
||||
setMessage(null);
|
||||
setServerErrors({});
|
||||
}
|
||||
|
||||
function update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
|
||||
touch();
|
||||
setWeek((current) =>
|
||||
current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
|
||||
);
|
||||
}
|
||||
|
||||
function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) {
|
||||
touch();
|
||||
setWeek((current) =>
|
||||
current.map((day) =>
|
||||
day.dayOfWeek === dayOfWeek
|
||||
@@ -63,21 +57,36 @@ export default function WeekEditor({ initial, canEdit }: Props) {
|
||||
: 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() {
|
||||
touch();
|
||||
setMessage(null);
|
||||
setServerErrors({});
|
||||
startTransition(async () => {
|
||||
const result = await saveWeek(week);
|
||||
if (result.ok) {
|
||||
setSaved(week);
|
||||
const count = result.changed.length;
|
||||
setMessage({
|
||||
kind: 'ok',
|
||||
text:
|
||||
count === 0
|
||||
result.changed.length === 0
|
||||
? '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 {
|
||||
setServerErrors(result.dayErrors ?? {});
|
||||
@@ -87,166 +96,138 @@ export default function WeekEditor({ initial, canEdit }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="mt-8">
|
||||
{!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)]">
|
||||
Compte en lecture seule : consultation uniquement.
|
||||
<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)]">
|
||||
Ce compte est en lecture seule : les horaires sont consultables mais pas modifiables.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
|
||||
{week.map((day, position) => {
|
||||
const dayErrors = [
|
||||
...(errors[day.dayOfWeek] ?? []),
|
||||
...(serverErrors[day.dayOfWeek] ?? []),
|
||||
];
|
||||
<div className="space-y-3">
|
||||
{week.map((day) => {
|
||||
const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])];
|
||||
const isChanged = pendingChanges.includes(day.dayOfWeek);
|
||||
const fullName = DAY_NAMES_FR[day.dayOfWeek] ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
<section
|
||||
key={day.dayOfWeek}
|
||||
className={`${position > 0 ? 'border-t border-[var(--line)]' : ''} ${
|
||||
dayErrors.length > 0 ? 'bg-[var(--danger-tint)]' : ''
|
||||
className={`rounded-[var(--radius-md)] border bg-[var(--surface)] p-4 ${
|
||||
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">
|
||||
<span
|
||||
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>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="min-w-28 text-base font-medium">{DAY_NAMES_FR[day.dayOfWeek]}</h2>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={`Ouvert le ${fullName.toLowerCase()}`}
|
||||
className="size-4 shrink-0 accent-[var(--accent)]"
|
||||
checked={!day.isClosed}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) =>
|
||||
update(day.dayOfWeek, {
|
||||
isClosed: !event.target.checked,
|
||||
slots:
|
||||
event.target.checked && day.slots.length === 0
|
||||
? [{ open: '10:00', close: '18:30' }]
|
||||
: day.slots,
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
||||
{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)]"
|
||||
>
|
||||
+ 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}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
checked={!day.isClosed}
|
||||
disabled={!canEdit}
|
||||
onChange={(event) =>
|
||||
update(day.dayOfWeek, {
|
||||
isClosed: !event.target.checked,
|
||||
slots:
|
||||
event.target.checked && day.slots.length === 0
|
||||
? [{ open: '10:00', close: '18:30' }]
|
||||
: day.slots,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Ouvert
|
||||
</label>
|
||||
|
||||
{isChanged ? (
|
||||
<span
|
||||
aria-label="modifié"
|
||||
title="Modifié, pas encore enregistré"
|
||||
className="size-1.5 shrink-0 rounded-full bg-[var(--accent)]"
|
||||
/>
|
||||
<span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
|
||||
modifié
|
||||
</span>
|
||||
) : null}
|
||||
</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 ? (
|
||||
<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) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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
|
||||
type="button"
|
||||
onClick={submit}
|
||||
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'}
|
||||
</button>
|
||||
@@ -256,20 +237,23 @@ export default function WeekEditor({ initial, canEdit }: Props) {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setWeek(saved);
|
||||
touch();
|
||||
setMessage(null);
|
||||
setServerErrors({});
|
||||
}}
|
||||
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
|
||||
>
|
||||
Annuler
|
||||
Annuler les modifications
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-[var(--ink-muted)]" aria-live="polite">
|
||||
{blocked
|
||||
? 'Corrigez les erreurs signalées.'
|
||||
? 'Corrigez les erreurs ci-dessus.'
|
||||
: pendingChanges.length === 0
|
||||
? '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>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -277,7 +261,7 @@ export default function WeekEditor({ initial, canEdit }: Props) {
|
||||
{message ? (
|
||||
<p
|
||||
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'
|
||||
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||||
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
|
||||
|
||||
Reference in New Issue
Block a user