276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
'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 : 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>
|
||
);
|
||
}
|