Files
ita-ito-horaires/app/admin/horaires/WeekEditor.tsx
T

276 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useMemo, useState, useTransition } from 'react';
import { MAX_SLOTS_PER_DAY } from '@/lib/schedule/validate';
import {
changedDays,
DAY_NAMES_FR,
duplicateToOpenDays,
validateWeek,
type WeekDraft,
} from '@/lib/schedule/week';
import { saveWeek } from './actions';
type Props = {
initial: WeekDraft;
canEdit: boolean;
};
/**
* The reference week.
*
* 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);
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 update(dayOfWeek: number, patch: Partial<WeekDraft[number]>) {
setMessage(null);
setServerErrors({});
setWeek((current) =>
current.map((day) => (day.dayOfWeek === dayOfWeek ? { ...day, ...patch } : day)),
);
}
function updateSlot(dayOfWeek: number, index: number, field: 'open' | 'close', value: string) {
setWeek((current) =>
current.map((day) =>
day.dayOfWeek === dayOfWeek
? {
...day,
slots: day.slots.map((slot, position) =>
position === index ? { ...slot, [field]: value } : slot,
),
}
: 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() {
setMessage(null);
setServerErrors({});
startTransition(async () => {
const result = await saveWeek(week);
if (result.ok) {
setSaved(week);
setMessage({
kind: 'ok',
text:
result.changed.length === 0
? 'Aucun changement à enregistrer.'
: `Horaires enregistrés (${result.changed.length} jour${result.changed.length > 1 ? 's' : ''} modifié${result.changed.length > 1 ? 's' : ''}).`,
});
} else {
setServerErrors(result.dayErrors ?? {});
setMessage({ kind: 'error', text: result.error });
}
});
}
return (
<div className="mt-8">
{!canEdit ? (
<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&nbsp;: les horaires sont consultables mais pas modifiables.
</p>
) : null}
<div className="space-y-3">
{week.map((day) => {
const dayErrors = [...(errors[day.dayOfWeek] ?? []), ...(serverErrors[day.dayOfWeek] ?? [])];
const isChanged = pendingChanges.includes(day.dayOfWeek);
return (
<section
key={day.dayOfWeek}
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-3">
<h2 className="min-w-28 text-base font-medium">{DAY_NAMES_FR[day.dayOfWeek]}</h2>
<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 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="mt-3 space-y-1 text-sm text-[var(--danger)]">
{dayErrors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</section>
);
})}
</div>
{canEdit ? (
<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-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>
{pendingChanges.length > 0 ? (
<button
type="button"
onClick={() => {
setWeek(saved);
setMessage(null);
setServerErrors({});
}}
className="text-sm text-[var(--ink-muted)] underline-offset-2 hover:underline"
>
Annuler les modifications
</button>
) : null}
<p className="text-sm text-[var(--ink-muted)]" aria-live="polite">
{blocked
? 'Corrigez les erreurs ci-dessus.'
: pendingChanges.length === 0
? 'Aucune modification.'
: `${pendingChanges.length} jour${pendingChanges.length > 1 ? 's' : ''} sera modifié : ${pendingChanges
.map((dayOfWeek) => DAY_NAMES_FR[dayOfWeek])
.join(', ')}.`}
</p>
</div>
) : null}
{message ? (
<p
role="status"
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)]'
}`}
>
{message.text}
</p>
) : null}
</div>
);
}