feat: add the closure periods page
Create a period with a start, an end and a label, see the twelve months ahead at a glance, delete one. The label is required and trimmed: it goes straight onto the shop window. Overlapping periods are refused, and the message names the one they collide with. Two rows covering the same day would both be "in effect" with no way to say which, and silently merging them would lose whichever label the owner meant. Periods that merely touch — one ending the 10th, the next starting the 11th — are fine. The year view exists because a list of date ranges is precise and hard to picture, while "have I left a gap in August?" is the question people actually ask. It renders on the server; it only changes when the data does. A vacation period stays a source of truth and is never expanded into exception rows, so a one-off exception placed inside one still wins and nothing is overwritten. The page says so rather than leaving it to be discovered. The first version of the calendar built the months but never applied the periods to them, so no closure would ever have shown. Caught before commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012cSY9pVhZmJUKNN7wf1Myd
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { countDays } from '@/lib/schedule/vacations';
|
||||
|
||||
import { createVacation, deleteVacation } from './actions';
|
||||
|
||||
export type VacationRow = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
labelFr: string;
|
||||
labelEn: string | null;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
export default function VacationManager({
|
||||
canEdit,
|
||||
periods,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
periods: VacationRow[];
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [labelFr, setLabelFr] = useState('');
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||
|
||||
function run(work: () => Promise<{ ok: true } | { ok: false; error: string }>, success: string) {
|
||||
setMessage(null);
|
||||
startTransition(async () => {
|
||||
const result = await work();
|
||||
if (result.ok) {
|
||||
setMessage({ kind: 'ok', text: success });
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setLabelFr('');
|
||||
} else {
|
||||
setMessage({ kind: 'error', text: result.error });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8 space-y-8">
|
||||
{message ? (
|
||||
<p
|
||||
role="status"
|
||||
className={`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}
|
||||
|
||||
{canEdit ? (
|
||||
<section className="rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)] p-5">
|
||||
<h2 className="text-lg">Nouvelle période de fermeture</h2>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="block text-[var(--ink-muted)]">Du</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(event) => {
|
||||
setStartDate(event.target.value);
|
||||
if (!endDate) {
|
||||
setEndDate(event.target.value);
|
||||
}
|
||||
}}
|
||||
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-[var(--ink-muted)]">Au (inclus)</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
className="mt-1 rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-56 flex-1 text-sm">
|
||||
<span className="block text-[var(--ink-muted)]">Libellé affiché sur l’écran</span>
|
||||
<input
|
||||
type="text"
|
||||
value={labelFr}
|
||||
maxLength={80}
|
||||
placeholder="Congés d’été"
|
||||
onChange={(event) => setLabelFr(event.target.value)}
|
||||
className="mt-1 w-full rounded-[var(--radius-sm)] border border-[var(--line-strong)] bg-[var(--surface)] px-2 py-2 text-base"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || !startDate || !endDate || labelFr.trim().length === 0}
|
||||
onClick={() =>
|
||||
run(
|
||||
() => createVacation({ startDate, endDate, labelFr }),
|
||||
'Période de fermeture enregistrée.',
|
||||
)
|
||||
}
|
||||
className="rounded-[var(--radius-md)] bg-[var(--accent)] px-4 py-2.5 font-medium text-[var(--on-accent)] transition-colors hover:bg-[var(--accent-hover)] disabled:opacity-40"
|
||||
>
|
||||
{pending ? 'Enregistrement…' : 'Ajouter'}
|
||||
</button>
|
||||
|
||||
{startDate && endDate && startDate <= endDate ? (
|
||||
<p className="text-sm text-[var(--ink-muted)]">
|
||||
{countDays({ startDate, endDate })} jour
|
||||
{countDays({ startDate, endDate }) > 1 ? 's' : ''} de fermeture.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg">Périodes enregistrées</h2>
|
||||
{periods.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-[var(--ink-muted)]">Aucune fermeture prévue.</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-[var(--line)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--line)] bg-[var(--surface)]">
|
||||
{periods.map((period) => (
|
||||
<li key={period.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||
<span className="font-medium">{period.labelFr}</span>
|
||||
<span className="text-sm text-[var(--ink-muted)]">
|
||||
du {period.startDate} au {period.endDate} ·{' '}
|
||||
{countDays({ startDate: period.startDate, endDate: period.endDate })} jours
|
||||
</span>
|
||||
{period.isCurrent ? (
|
||||
<span className="rounded-[var(--radius-sm)] bg-[var(--accent-tint)] px-2 py-0.5 text-xs text-[var(--accent-ink)]">
|
||||
en cours
|
||||
</span>
|
||||
) : null}
|
||||
{period.labelEn ? (
|
||||
<span className="text-sm text-[var(--ink-muted)]">« {period.labelEn} »</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--ink-muted)]">traduction à venir</span>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => run(() => deleteVacation(period.id), 'Période supprimée.')}
|
||||
className="ml-auto text-sm text-[var(--ink-muted)] underline-offset-2 hover:text-[var(--danger)] hover:underline disabled:opacity-40"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { addCivilDays, civilDayOfWeek } from '@/lib/schedule/civil';
|
||||
import { periodsOverlap, type Period } from '@/lib/schedule/vacations';
|
||||
|
||||
const MONTHS_FR = [
|
||||
'Janvier',
|
||||
'Février',
|
||||
'Mars',
|
||||
'Avril',
|
||||
'Mai',
|
||||
'Juin',
|
||||
'Juillet',
|
||||
'Août',
|
||||
'Septembre',
|
||||
'Octobre',
|
||||
'Novembre',
|
||||
'Décembre',
|
||||
];
|
||||
|
||||
type LabelledPeriod = Period & { labelFr: string };
|
||||
|
||||
type Day = { date: string; isWeekend: boolean; label: string | null };
|
||||
type Month = { key: string; label: string; days: Day[] };
|
||||
|
||||
/**
|
||||
* Twelve months from today, with closure days filled in.
|
||||
*
|
||||
* A list of date ranges is precise and hard to picture; a year at a glance is
|
||||
* how anyone actually checks "have I left a gap in August?". Rendered on the
|
||||
* server: it only changes when the data does.
|
||||
*/
|
||||
export default function YearCalendar({
|
||||
from,
|
||||
periods,
|
||||
}: {
|
||||
from: string;
|
||||
periods: LabelledPeriod[];
|
||||
}) {
|
||||
const months = buildMonths(from, periods);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[34rem] border-separate border-spacing-y-1 text-sm">
|
||||
<caption className="sr-only">
|
||||
Calendrier des douze prochains mois, jours de fermeture marqués
|
||||
</caption>
|
||||
<tbody>
|
||||
{months.map((month) => (
|
||||
<tr key={month.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-28 pr-3 text-left align-middle font-normal text-[var(--ink-muted)]"
|
||||
>
|
||||
{month.label}
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex gap-[2px]">
|
||||
{month.days.map((day) => (
|
||||
<span
|
||||
key={day.date}
|
||||
title={day.label ? `${day.date} — ${day.label}` : day.date}
|
||||
className={[
|
||||
'h-5 flex-1 rounded-[2px]',
|
||||
day.label
|
||||
? 'bg-[var(--accent)]'
|
||||
: day.isWeekend
|
||||
? 'bg-[var(--surface-muted)]'
|
||||
: 'bg-[var(--line)]',
|
||||
].join(' ')}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-[2px] bg-[var(--accent)]" /> fermeture
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-3 w-3 rounded-[2px] bg-[var(--surface-muted)]" /> week-end
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildMonths(from: string, periods: LabelledPeriod[]): Month[] {
|
||||
const months: Month[] = [];
|
||||
let cursor = `${from.slice(0, 7)}-01`;
|
||||
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const prefix = cursor.slice(0, 7);
|
||||
const [year, month] = prefix.split('-').map(Number);
|
||||
const days: Day[] = [];
|
||||
|
||||
let day = cursor;
|
||||
while (day.startsWith(prefix)) {
|
||||
const covering = periods.find((period) =>
|
||||
periodsOverlap({ startDate: day, endDate: day }, period),
|
||||
);
|
||||
days.push({
|
||||
date: day,
|
||||
isWeekend: [0, 6].includes(civilDayOfWeek(day)),
|
||||
label: covering?.labelFr ?? null,
|
||||
});
|
||||
day = addCivilDays(day, 1);
|
||||
}
|
||||
|
||||
months.push({ key: prefix, label: `${MONTHS_FR[(month ?? 1) - 1]} ${year}`, days });
|
||||
cursor = day;
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'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 { CivilDate } from '@/lib/schedule/civil';
|
||||
import { countDays, findOverlap, validatePeriod } from '@/lib/schedule/vacations';
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function createVacation(input: {
|
||||
startDate: CivilDate;
|
||||
endDate: CivilDate;
|
||||
labelFr: string;
|
||||
}): Promise<ActionResult> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const labelFr = input.labelFr.trim();
|
||||
const problems = validatePeriod({ ...input, labelFr });
|
||||
if (problems.length > 0) {
|
||||
return { ok: false, error: problems.join(' ') };
|
||||
}
|
||||
|
||||
// Two periods covering the same day would both be in effect with no way to
|
||||
// say which; refuse rather than merge silently.
|
||||
const existing = await prisma.vacationPeriod.findMany();
|
||||
const clash = findOverlap(
|
||||
{ startDate: input.startDate, endDate: input.endDate },
|
||||
existing.map((period) => ({
|
||||
id: period.id,
|
||||
labelFr: period.labelFr,
|
||||
startDate: asCivil(period.startDate),
|
||||
endDate: asCivil(period.endDate),
|
||||
})),
|
||||
);
|
||||
if (clash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cette période chevauche « ${clash.labelFr} » (${clash.startDate} au ${clash.endDate}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const created = await prisma.vacationPeriod.create({
|
||||
data: {
|
||||
startDate: asDate(input.startDate),
|
||||
endDate: asDate(input.endDate),
|
||||
labelFr,
|
||||
createdBy: gate.value.email,
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'vacation.create',
|
||||
entity: 'VacationPeriod',
|
||||
entityId: created.id,
|
||||
diff: diffOf(null, {
|
||||
libellé: labelFr,
|
||||
du: input.startDate,
|
||||
au: input.endDate,
|
||||
jours: countDays(input),
|
||||
}),
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteVacation(id: string): Promise<ActionResult> {
|
||||
const gate = await requireAdmin();
|
||||
if (!gate.ok) {
|
||||
return { ok: false, error: gate.error };
|
||||
}
|
||||
|
||||
const existing = await prisma.vacationPeriod.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return { ok: false, error: 'Cette période a déjà été supprimée.' };
|
||||
}
|
||||
|
||||
await prisma.vacationPeriod.delete({ where: { id } });
|
||||
|
||||
await recordAudit({
|
||||
userEmail: gate.value.email,
|
||||
action: 'vacation.delete',
|
||||
entity: 'VacationPeriod',
|
||||
entityId: id,
|
||||
diff: diffOf(
|
||||
{
|
||||
libellé: existing.labelFr,
|
||||
du: asCivil(existing.startDate),
|
||||
au: asCivil(existing.endDate),
|
||||
},
|
||||
null,
|
||||
),
|
||||
});
|
||||
|
||||
revalidateAll();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function asCivil(value: Date): CivilDate {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function asDate(value: CivilDate): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function revalidateAll(): void {
|
||||
revalidatePath('/admin');
|
||||
revalidatePath('/admin/vacances');
|
||||
revalidatePath('/admin/exceptions');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { toCivilDate } from '@/lib/schedule/civil';
|
||||
import { loadSettings } from '@/lib/schedule/context';
|
||||
import { periodsOverlap, upcomingPeriods } from '@/lib/schedule/vacations';
|
||||
|
||||
import VacationManager, { type VacationRow } from './VacationManager';
|
||||
import YearCalendar from './YearCalendar';
|
||||
|
||||
export const metadata = { title: 'Vacances — ITA ITO' };
|
||||
|
||||
export default async function VacationsPage() {
|
||||
const [session, settings] = await Promise.all([auth(), loadSettings()]);
|
||||
const today = toCivilDate(new Date(), settings.timezone);
|
||||
|
||||
const rows = await prisma.vacationPeriod.findMany({ orderBy: { startDate: 'asc' } });
|
||||
|
||||
const periods: VacationRow[] = rows.map((row) => {
|
||||
const startDate = row.startDate.toISOString().slice(0, 10);
|
||||
const endDate = row.endDate.toISOString().slice(0, 10);
|
||||
return {
|
||||
id: row.id,
|
||||
startDate,
|
||||
endDate,
|
||||
labelFr: row.labelFr,
|
||||
labelEn: row.labelEn,
|
||||
isCurrent: periodsOverlap({ startDate, endDate }, { startDate: today, endDate: today }),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-2xl">Vacances</h1>
|
||||
<p className="mt-2 max-w-prose text-[var(--ink-muted)]">
|
||||
Les fermetures longues. Une exception ponctuelle posée sur un de ces jours continue de
|
||||
primer : rien n’est écrasé.
|
||||
</p>
|
||||
|
||||
<VacationManager
|
||||
canEdit={session?.user?.role === 'admin'}
|
||||
periods={upcomingPeriods(periods, today)}
|
||||
/>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="text-lg">Vue sur douze mois</h2>
|
||||
<div className="mt-3">
|
||||
<YearCalendar from={today} periods={periods} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user