One card per day pushed the week to roughly 1100px, so checking a change meant scrolling back up past the day you had just edited. All seven days now fit on one phone screen. Each row carries the day, an open switch, its slots inline and its two actions. The per-row "duplicate onto the other open days" button was repeated five times in prose; it is now a short labelled control on the row itself, with the full sentence kept as its accessible name. Unsaved days are marked with a dot rather than a badge, and the footer still names which days are about to change — the point of the preview is that it is readable without leaving the week. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState, useTransition } from 'react';
|
||
|
||
import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate';
|
||
import {
|
||
changedDays,
|
||
DAY_NAMES_FR,
|
||
DAY_SHORT_FR,
|
||
duplicateToOpenDays,
|
||
validateWeek,
|
||
type WeekDraft,
|
||
} from '@/lib/schedule/week';
|
||
|
||
import { saveWeek } from './actions';
|
||
|
||
type Props = {
|
||
initial: WeekDraft;
|
||
canEdit: boolean;
|
||
};
|
||
|
||
/**
|
||
* The reference week, as one row per day.
|
||
*
|
||
* 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.
|
||
*/
|
||
export default function WeekEditor({ initial, canEdit }: Props) {
|
||
const [week, setWeek] = useState<WeekDraft>(initial);
|
||
const [saved, setSaved] = useState<WeekDraft>(initial);
|
||
const [pending, startTransition] = useTransition();
|
||
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||
const [serverErrors, setServerErrors] = useState<Record<number, string[]>>({});
|
||
|
||
const errors = useMemo(() => validateWeek(week), [week]);
|
||
const pendingChanges = useMemo(() => changedDays(saved, week), [saved, week]);
|
||
const blocked = Object.keys(errors).length > 0;
|
||
|
||
function touch() {
|
||
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
|
||
? {
|
||
...day,
|
||
slots: day.slots.map((slot, position) =>
|
||
position === index ? { ...slot, [field]: value } : slot,
|
||
),
|
||
}
|
||
: day,
|
||
),
|
||
);
|
||
}
|
||
|
||
function submit() {
|
||
touch();
|
||
startTransition(async () => {
|
||
const result = await saveWeek(week);
|
||
if (result.ok) {
|
||
setSaved(week);
|
||
const count = result.changed.length;
|
||
setMessage({
|
||
kind: 'ok',
|
||
text:
|
||
count === 0
|
||
? 'Aucun changement à enregistrer.'
|
||
: `Enregistré — ${count} jour${count > 1 ? 's' : ''} modifié${count > 1 ? 's' : ''}.`,
|
||
});
|
||
} else {
|
||
setServerErrors(result.dayErrors ?? {});
|
||
setMessage({ kind: 'error', text: result.error });
|
||
}
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div className="mt-6">
|
||
{!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>
|
||
) : 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] ?? []),
|
||
];
|
||
const isChanged = pendingChanges.includes(day.dayOfWeek);
|
||
const fullName = DAY_NAMES_FR[day.dayOfWeek] ?? '';
|
||
|
||
return (
|
||
<div
|
||
key={day.dayOfWeek}
|
||
className={`${position > 0 ? 'border-t border-[var(--line)]' : ''} ${
|
||
dayErrors.length > 0 ? 'bg-[var(--danger-tint)]' : ''
|
||
}`}
|
||
>
|
||
<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>
|
||
|
||
<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}
|
||
|
||
{isChanged ? (
|
||
<span
|
||
aria-label="modifié"
|
||
title="Modifié, pas encore enregistré"
|
||
className="size-1.5 shrink-0 rounded-full bg-[var(--accent)]"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
{dayErrors.length > 0 ? (
|
||
<ul role="alert" className="px-3 pb-2 text-sm text-[var(--danger)]">
|
||
{dayErrors.map((error) => (
|
||
<li key={error}>{error}</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</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">
|
||
<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"
|
||
>
|
||
{pending ? 'Enregistrement…' : 'Enregistrer'}
|
||
</button>
|
||
|
||
{pendingChanges.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setWeek(saved);
|
||
touch();
|
||
}}
|
||
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
|
||
>
|
||
Annuler
|
||
</button>
|
||
) : null}
|
||
|
||
<p className="text-sm text-[var(--ink-muted)]" aria-live="polite">
|
||
{blocked
|
||
? 'Corrigez les erreurs signalées.'
|
||
: pendingChanges.length === 0
|
||
? 'Aucune modification.'
|
||
: `Sera modifié : ${pendingChanges.map((d) => DAY_SHORT_FR[d]).join(', ')}.`}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{message ? (
|
||
<p
|
||
role="status"
|
||
className={`mt-3 rounded-[var(--radius-md)] px-3 py-2 text-sm ${
|
||
message.kind === 'ok'
|
||
? 'bg-[var(--success-tint)] text-[var(--success)]'
|
||
: 'bg-[var(--danger-tint)] text-[var(--danger)]'
|
||
}`}
|
||
>
|
||
{message.text}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|