feat: edit the reference week from the admin

The first administration page, built for the actual use case: 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
shown before the save rather than after — the footer names which days
are about to change, and the button stays disabled until something
actually has.

"Duplicate onto the other open days" leaves closed days closed. Someone
copying Tuesday's hours means "the days I open, I open like this", not
"open seven days a week".

Validation runs in the browser for the feedback and again in the action
before the write: the client is a convenience, not a guarantee, and this
is the schedule the shop window shows. A day being closed drops its
leftover slots rather than failing on them.

A save that changes nothing writes nothing — no rows, no audit entry,
and so no needless panel redraw. Reordering slots does not count as a
change. The audit diff stores one readable line per day in French, so
the log can be read without cross-referencing the schema.

The editing helpers are pure and tested, and the write path is tested
against a real database including the read-only refusal.

Test files now run sequentially: the integration files share one
database and each truncates it, so parallel files raced. The suite takes
six seconds; giving every file its own database would buy nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
2026-09-20 18:57:46 +02:00
co-authored by Claude Opus 5
parent 5c0b8119a4
commit eb23eb650b
8 changed files with 829 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
'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>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use server';
import { revalidatePath } from 'next/cache';
import { diffOf, recordAudit } from '@/lib/audit';
import { requireAdmin } from '@/lib/auth/actions';
import { prisma } from '@/lib/db';
import type { Slot } from '@/lib/schedule/types';
import {
changedDays,
DAY_NAMES_FR,
hasErrors,
normaliseWeek,
toDraft,
validateWeek,
type WeekDraft,
} from '@/lib/schedule/week';
export type SaveWeekResult =
| { ok: true; changed: number[] }
| { ok: false; error: string; dayErrors?: Record<number, string[]> };
/**
* Persists the reference week.
*
* Validation runs again here even though the form already checked: the client
* is a convenience, not a guarantee, and this is the schedule the shop window
* shows.
*/
export async function saveWeek(draft: WeekDraft): Promise<SaveWeekResult> {
const gate = await requireAdmin();
if (!gate.ok) {
return { ok: false, error: gate.error };
}
const week = normaliseWeek(draft);
const errors = validateWeek(week);
if (hasErrors(errors)) {
return { ok: false, error: 'Certains horaires sont invalides.', dayErrors: errors };
}
const existing = await prisma.weeklySchedule.findMany();
const before = toDraft(
existing.map((day) => ({
dayOfWeek: day.dayOfWeek,
isClosed: day.isClosed,
slots: (day.slots as Slot[] | null) ?? [],
})),
);
const changed = changedDays(before, week);
if (changed.length === 0) {
// Nothing moved: no write, no audit entry, no needless screen redraw.
return { ok: true, changed: [] };
}
await prisma.$transaction(
week.map((day) =>
prisma.weeklySchedule.upsert({
where: { dayOfWeek: day.dayOfWeek },
update: { isClosed: day.isClosed, slots: day.slots },
create: { dayOfWeek: day.dayOfWeek, isClosed: day.isClosed, slots: day.slots },
}),
),
);
await recordAudit({
userEmail: gate.value.email,
action: 'weekly_schedule.update',
entity: 'WeeklySchedule',
diff: diffOf(summary(before), summary(week)),
});
revalidatePath('/admin');
revalidatePath('/admin/horaires');
return { ok: true, changed };
}
/**
* One readable line per day for the audit trail. Storing the raw rows would
* make the diff accurate and unreadable; the point of the log is that a human
* can see what changed.
*/
function summary(week: WeekDraft): Record<string, string> {
return Object.fromEntries(
week.map((day) => [
DAY_NAMES_FR[day.dayOfWeek] ?? String(day.dayOfWeek),
day.isClosed || day.slots.length === 0
? 'Fermé'
: day.slots.map((slot) => `${slot.open}–${slot.close}`).join(' · '),
]),
);
}
+35
View File
@@ -0,0 +1,35 @@
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/db';
import type { Slot } from '@/lib/schedule/types';
import { toDraft } from '@/lib/schedule/week';
import WeekEditor from './WeekEditor';
export const metadata = { title: 'Horaires fixes — ITA ITO' };
export default async function HoursPage() {
const [session, rows] = await Promise.all([
auth(),
prisma.weeklySchedule.findMany({ orderBy: { dayOfWeek: 'asc' } }),
]);
const initial = toDraft(
rows.map((row) => ({
dayOfWeek: row.dayOfWeek,
isClosed: row.isClosed,
slots: (row.slots as Slot[] | null) ?? [],
})),
);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl">Horaires fixes</h1>
<p className="mt-2 max-w-prose text-[var(--ink-muted)]">
La semaine de référence. Les fermetures ponctuelles, les vacances et les jours fériés se
règlent ailleurs et priment sur ces horaires.
</p>
<WeekEditor initial={initial} canEdit={session?.user?.role === 'admin'} />
</main>
);
}
+18
View File
@@ -3,6 +3,12 @@ import Link from 'next/link';
import { auth, signOut } from '@/lib/auth';
/** Extended as each page lands, so nothing in the bar leads nowhere. */
const NAV = [
{ href: '/admin', label: 'Tableau de bord' },
{ href: '/admin/horaires', label: 'Horaires' },
];
/**
* The admin shell.
*
@@ -29,6 +35,18 @@ export default async function AdminLayout({ children }: { children: React.ReactN
/>
</Link>
<nav aria-label="Sections" className="flex flex-wrap items-center gap-x-5 gap-y-1">
{NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-[var(--ink-muted)] underline-offset-4 transition-colors hover:text-[var(--ink)] hover:underline"
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-3 text-sm">
{!isAdmin ? (
<span className="rounded-[var(--radius-sm)] bg-[var(--surface-muted)] px-2 py-1 text-xs text-[var(--ink-muted)]">