Twenty-six tests across two viewports, covering what the spec asks for: changing today's hours reaches the panel, a message survives the translation service being unavailable, a closure period closes the days it covers — plus the authorisation paths and the sign-out regression. They run against the standalone build served the way the container serves it, not `next start`, which refuses to work with standalone output anyway. The suite therefore exercises the artifact that ships rather than a second arrangement that could drift from it. Sign-in mints the session cookie Auth.js would have issued rather than driving Authentik. What is under test is the application's behaviour for a given role; the handshake itself is verified against the live provider separately, and standing up an identity provider per run would trade a lot of machinery for coverage of somebody else's code. The secret lives in one module imported by both the config and the fixtures — when it differed, every signed-in test failed at once while looking like an authorisation bug. Database access goes through plain SQL rather than the Prisma client, whose generated module format Playwright's loader and Next's bundler disagree about. That traded one problem for a subtler one: node-postgres parses a DATE column into a local-midnight Date, so reading it back shifted the day at UTC+2. Dates are read as text now. The mobile profile runs on Chromium: WebKit needs system packages only root can install, and a suite nobody can run locally is a suite nobody runs. The config says how to switch to the real engine. Two real defects surfaced, both found by the tests rather than by reading. The seven "Ouvert" checkboxes on the hours page were indistinguishable to a screen reader; each now names its day. And on a phone the signed-in address appeared nowhere at all — the header hides it to save room — so nobody could tell which account was about to sign an audit entry on a device the shop shares. It is on the dashboard now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
280 lines
10 KiB
TypeScript
280 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"
|
||
// Seven checkboxes labelled "Ouvert" are indistinguishable
|
||
// to a screen reader; the visible label stays short while
|
||
// the accessible name says which day it is.
|
||
aria-label={`Ouvert le ${(DAY_NAMES_FR[day.dayOfWeek] ?? '').toLowerCase()}`}
|
||
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>
|
||
);
|
||
}
|