WP-04 (suite) : vues par poste et par mois, duplication, édition, impression
Édition
- Le panneau de créneau sert aussi à la modification : déplacer un créneau,
c'est changer son jour, son salarié ou ses heures — exactement les champs de
la création. Deux écrans distincts finiraient par diverger.
- Duplication d'une semaine vers une autre, refusée si la destination contient
déjà des créneaux. Le report se fait **par heure locale**, pas par décalage de
sept jours : entre mars et avril, sept jours d'écart ne redonnent pas la même
heure.
Vues
- `/planning/etiquettes` : la semaine par poste. C'est la question du
responsable d'ouverture — « la caisse est-elle tenue samedi ? » — illisible
sur une grille dont les lignes sont des personnes. Les postes sans créneau
restent affichés : un poste vide toute la semaine est une information.
- `/planning/mois` : les heures par salarié et par jour, avec les journées de
plus de 10 h signalées. Vue de contrôle, pas d'édition.
- Les deux lisent `Shift` directement. Un test e2e pose un créneau et le
retrouve identique dans les quatre vues — c'est ce qui empêche l'une d'elles
de dériver vers son propre calcul.
Impression
- Feuille d'impression A4 paysage : les zones à défilement se déplient, les
lignes ne se coupent pas entre deux pages, et les aplats de poste sortent
(`print-color-adjust: exact`) sans être nécessaires à la lecture, puisque le
code du poste est imprimé dans chaque bloc.
- Pas de moteur PDF côté serveur : le navigateur sait paginer et enregistrer en
PDF. Un second moteur de rendu serait un second endroit où la grille peut
diverger de ce qui est à l'écran.
Accessibilité
- La grille porte enfin ses rôles ARIA (`grid`, `row`, `columnheader`,
`rowheader`, `gridcell`). Elle est faite de div pour la mise en page, mais se
lit comme un tableau : un lecteur d'écran doit pouvoir annoncer « ligne
Camille Ferrand, colonne mercredi ».
Cette structure a d'ailleurs révélé un test faux : `locator('div')` remontait au
conteneur de la grille, et le créneau du test atterrissait sur le premier
salarié de l'équipe au lieu de celui visé — une erreur invisible à l'écran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
15 files changed
+1377
-56
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
|
||||
import { posteShort, posteTokens } from '@/lib/design/postes';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getLabelBoard } from '@/server/planning/views';
|
||||
|
||||
export const metadata = { title: 'Planning · étiquettes · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function EtiquettesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
|
||||
const board = await getLabelBoard(week, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · par poste"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (semaine: string, etablissement = board.location.id) =>
|
||||
`/planning/etiquettes?semaine=${semaine}&etablissement=${etablissement}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Planning · par poste · semaine ${week.isoWeek}`}
|
||||
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()}`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Semaine précédente
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Semaine suivante →
|
||||
</Link>
|
||||
<PrintButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" data-print="hide">
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(board.weekParam, location.id)}
|
||||
aria-current={
|
||||
location.id === board.location.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === board.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
<Link
|
||||
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
|
||||
className="rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2"
|
||||
>
|
||||
Voir par salarié →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
|
||||
<div className="w-56 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Poste
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{board.headings.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
|
||||
index > 4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{board.rows.map((row) => {
|
||||
const tokens = posteTokens(row.poste);
|
||||
return (
|
||||
<div
|
||||
key={row.labelId}
|
||||
className="flex border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<div className="flex w-56 flex-none items-center gap-2 border-r border-line-1 px-3 py-2">
|
||||
<span
|
||||
className="flex-none rounded-1 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
border: `1px solid ${tokens.edge}`,
|
||||
}}
|
||||
>
|
||||
{posteShort(row.poste)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-ink-1">
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="tnum block text-micro text-ink-3">
|
||||
{formatMinutes(row.minutes)} sur la semaine
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{board.headings.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{(row.days[index] ?? []).map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
className={cx(
|
||||
'flex min-w-0 flex-col rounded-2 px-1.5 py-1 text-micro',
|
||||
entry.unassigned &&
|
||||
'border border-dashed border-line-3 text-ink-3',
|
||||
)}
|
||||
style={
|
||||
entry.unassigned
|
||||
? undefined
|
||||
: {
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span className="tnum">{entry.time}</span>
|
||||
<span className="truncate opacity-90">
|
||||
{entry.who}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DayTimeline } from '@/components/planning/DayTimeline';
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getDayBoard } from '@/server/planning/queries';
|
||||
@@ -57,6 +58,7 @@ export default async function JourPage({ searchParams }: PageProps) {
|
||||
>
|
||||
Jour suivant →
|
||||
</Link>
|
||||
<PrintButton label="Imprimer la journée" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getMonthBoard } from '@/server/planning/views';
|
||||
|
||||
export const metadata = { title: 'Planning · mois · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
/** Heures d'une journée, en compact : « 7,5 » plutôt que « 7 h 30 ». */
|
||||
function compact(minutes: number): string {
|
||||
if (minutes === 0) return '';
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours)
|
||||
? String(hours)
|
||||
: hours.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
export default async function MoisPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const board = await getMonthBoard(month, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · mois"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (mois: string, etablissement = board.location.id) =>
|
||||
`/planning/mois?mois=${mois}&etablissement=${etablissement}`;
|
||||
const total = board.rows.reduce((sum, row) => sum + row.totalMinutes, 0);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Planning · ${board.label}`}
|
||||
subtitle={`${board.location.name} · ${formatMinutes(total)} planifiées sur le mois`}
|
||||
actions={
|
||||
<>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Mois précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
<PrintButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" data-print="hide">
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(board.monthParam, location.id)}
|
||||
aria-current={
|
||||
location.id === board.location.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === board.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Heures planifiées par salarié et par jour, {board.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2">
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-10 min-w-44 bg-surface-2 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</th>
|
||||
{board.days.map((day) => (
|
||||
<th
|
||||
key={day.isoDate}
|
||||
scope="col"
|
||||
className={cx(
|
||||
'tnum w-8 px-1 py-2 text-center font-semibold',
|
||||
day.weekend ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{day.day}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-2 text-right text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.rows.map((row) => (
|
||||
<tr
|
||||
key={row.membershipId}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
|
||||
>
|
||||
{row.name}
|
||||
<span className="block text-micro font-normal text-ink-3">
|
||||
{row.workedDays} jour{row.workedDays > 1 ? 's' : ''}{' '}
|
||||
travaillé{row.workedDays > 1 ? 's' : ''}
|
||||
</span>
|
||||
</th>
|
||||
{row.minutes.map((minutes, index) => {
|
||||
const day = board.days[index];
|
||||
return (
|
||||
<td
|
||||
key={day?.isoDate ?? index}
|
||||
className={cx(
|
||||
'tnum border-l border-line-1 px-1 py-1.5 text-center',
|
||||
day?.weekend && 'bg-surface-2',
|
||||
// Au-delà de 10 h, la journée mérite un regard : c'est
|
||||
// la borne légale au-dessus de laquelle une dérogation
|
||||
// devient nécessaire (le contrôle formel arrive avec le
|
||||
// moteur de règles).
|
||||
minutes > 600 && 'bg-warn-soft text-warn-soft-ink',
|
||||
)}
|
||||
title={
|
||||
minutes > 0
|
||||
? `${formatMinutes(minutes)} le ${day?.day ?? ''}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{compact(minutes)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="tnum border-l border-line-2 px-3 py-1.5 text-right font-semibold text-ink-1">
|
||||
{formatMinutes(row.totalMinutes)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{board.rows.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Aucun salarié rattaché à une équipe de cet établissement.
|
||||
</p>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { TeamSection } from '@/components/planning/TeamSection';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -45,6 +46,7 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
|
||||
const canEdit = can(session.actor, 'planning.create');
|
||||
const canPublish = can(session.actor, 'planning.publish');
|
||||
const canDuplicate = can(session.actor, 'planning.duplicate');
|
||||
const href = (semaine: string, etablissement = board.location.id) =>
|
||||
`/planning/semaine?semaine=${semaine}&etablissement=${etablissement}`;
|
||||
|
||||
@@ -67,11 +69,12 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
>
|
||||
Semaine suivante →
|
||||
</Link>
|
||||
<PrintButton label="Imprimer la semaine" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2" data-print="hide">
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
@@ -111,8 +114,10 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
dates={board.dates}
|
||||
labels={board.labels}
|
||||
weekParam={board.weekParam}
|
||||
previousParam={board.previousParam}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
canDuplicate={canDuplicate}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -365,3 +365,61 @@ body {
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Impression
|
||||
---------------------------------------------------------------------------
|
||||
Un planning affiché reste affiché ; un planning imprimé est le document que
|
||||
l'équipe lit dans la réserve. Deux exigences en découlent :
|
||||
|
||||
- la couleur ne doit pas être nécessaire (les codes de poste sont déjà
|
||||
imprimés dans chaque bloc), mais les aplats doivent sortir quand même,
|
||||
d'où `print-color-adjust: exact` ;
|
||||
- rien ne doit être coupé : la grille tient en paysage et les lignes ne se
|
||||
scindent pas entre deux pages.
|
||||
*/
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* L'impression part toujours du thème clair : un aplat sombre consomme
|
||||
l'encre sans rien apporter. */
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
* {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Les zones à défilement horizontal doivent se déplier, sinon la fin de la
|
||||
semaine n'est tout simplement pas sur la feuille. */
|
||||
.overflow-x-auto {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.min-w-\[1040px\] {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
[data-print='hide'],
|
||||
nav,
|
||||
aside {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
section,
|
||||
tr,
|
||||
.print-keep {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* Impression du planning.
|
||||
*
|
||||
* Pas de génération PDF côté serveur : le navigateur sait déjà paginer, et sa
|
||||
* boîte d'impression permet d'enregistrer en PDF. Un moteur de rendu de plus
|
||||
* serait un second endroit où la grille peut diverger de ce qui est à l'écran.
|
||||
* La mise en page d'impression vit dans `globals.css`.
|
||||
*/
|
||||
export function PrintButton({ label = 'Imprimer' }: { label?: string }) {
|
||||
return (
|
||||
<Button type="button" onClick={() => window.print()}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,18 @@ import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName, type BoardRow } from '@/domain/planning/board';
|
||||
import {
|
||||
displayName,
|
||||
type BoardRow,
|
||||
type BoardShift,
|
||||
} from '@/domain/planning/board';
|
||||
import {
|
||||
createShiftAction,
|
||||
deleteShiftAction,
|
||||
duplicateWeekAction,
|
||||
publishWeekAction,
|
||||
unpublishWeekAction,
|
||||
updateShiftAction,
|
||||
type PlanningActionState,
|
||||
} from '@/server/planning/actions';
|
||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||
@@ -22,13 +28,26 @@ export interface TeamSectionProps {
|
||||
dates: string[];
|
||||
labels: BoardLabel[];
|
||||
weekParam: string;
|
||||
/** Semaine précédente : proposition par défaut de la duplication. */
|
||||
previousParam: string;
|
||||
/** Faux quand la capacité de modification manque : la grille reste lisible. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
canDuplicate: boolean;
|
||||
}
|
||||
|
||||
const empty: PlanningActionState = {};
|
||||
|
||||
/** Cible du panneau d'édition : création dans une case, ou créneau existant. */
|
||||
type Target =
|
||||
| { kind: 'create'; membershipId: string | null; dayIndex: number }
|
||||
| {
|
||||
kind: 'edit';
|
||||
shift: BoardShift;
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Une équipe, une grille, un état de publication.
|
||||
*
|
||||
@@ -42,19 +61,21 @@ export function TeamSection({
|
||||
dates,
|
||||
labels,
|
||||
weekParam,
|
||||
previousParam,
|
||||
canEdit,
|
||||
canPublish,
|
||||
canDuplicate,
|
||||
}: TeamSectionProps) {
|
||||
const [target, setTarget] = useState<{
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
} | null>(null);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
|
||||
const published = section.status === 'PUBLISHED';
|
||||
const plannedMinutes = section.rows.reduce(
|
||||
(total, row) => total + row.counters.plannedMinutes,
|
||||
0,
|
||||
);
|
||||
const isEmpty =
|
||||
section.rows.every((row) => row.days.flat().length === 0) &&
|
||||
!section.unassignedRow;
|
||||
|
||||
if (section.hidden) {
|
||||
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
|
||||
@@ -86,14 +107,23 @@ export function TeamSection({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 print:hidden">
|
||||
{canDuplicate && isEmpty ? (
|
||||
<DuplicateControl
|
||||
teamId={section.teamId}
|
||||
source={previousParam}
|
||||
target={weekParam}
|
||||
/>
|
||||
) : null}
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<WeekGrid
|
||||
@@ -107,9 +137,13 @@ export function TeamSection({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTarget({ membershipId: row.membershipId, dayIndex })
|
||||
setTarget({
|
||||
kind: 'create',
|
||||
membershipId: row.membershipId,
|
||||
dayIndex,
|
||||
})
|
||||
}
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100 print:hidden"
|
||||
>
|
||||
+ Créneau
|
||||
<span className="sr-only">
|
||||
@@ -117,8 +151,26 @@ export function TeamSection({
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
shiftAction: (shiftId: string) => (
|
||||
<DeleteShift shiftId={shiftId} />
|
||||
shiftAction: (shift, row, dayIndex) => (
|
||||
<div className="absolute -top-1 -right-1 flex gap-0.5 print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
title="Modifier le créneau"
|
||||
onClick={() =>
|
||||
setTarget({
|
||||
kind: 'edit',
|
||||
shift,
|
||||
membershipId: row.membershipId,
|
||||
dayIndex,
|
||||
})
|
||||
}
|
||||
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
<span aria-hidden>✎</span>
|
||||
<span className="sr-only">Modifier le créneau</span>
|
||||
</button>
|
||||
<DeleteShift shiftId={shift.id} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
@@ -126,13 +178,14 @@ export function TeamSection({
|
||||
|
||||
{target ? (
|
||||
<ShiftComposer
|
||||
key={target.kind === 'edit' ? target.shift.id : 'create'}
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
labels={labels}
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
membershipId={target.membershipId}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -179,11 +232,46 @@ function PublishControl({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposé seulement sur une semaine vide : dupliquer par-dessus un travail
|
||||
* commencé le détruirait, et l'action refuse de toute façon côté serveur.
|
||||
*/
|
||||
function DuplicateControl({
|
||||
teamId,
|
||||
source,
|
||||
target,
|
||||
}: {
|
||||
teamId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
duplicateWeekAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="source" value={source} />
|
||||
<input type="hidden" name="target" value={target} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
Copier la semaine précédente
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="absolute -top-1 -right-1">
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="shiftId" value={shiftId} />
|
||||
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
|
||||
disparaître le créneau, donc le bouton, et un état désactivé
|
||||
@@ -201,6 +289,13 @@ function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau d'édition d'un créneau, en création comme en modification.
|
||||
*
|
||||
* Un seul formulaire pour les deux : déplacer un créneau, c'est changer son
|
||||
* jour, son salarié ou ses heures — exactement les champs de la création. Deux
|
||||
* écrans distincts finiraient par diverger.
|
||||
*/
|
||||
function ShiftComposer({
|
||||
teamId,
|
||||
weekParam,
|
||||
@@ -208,7 +303,7 @@ function ShiftComposer({
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
membershipId,
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
teamId: string;
|
||||
@@ -217,10 +312,14 @@ function ShiftComposer({
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
membershipId: string | null;
|
||||
target: Target;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(createShiftAction, empty);
|
||||
const editing = target.kind === 'edit';
|
||||
const [state, formAction, pending] = useActionState(
|
||||
editing ? updateShiftAction : createShiftAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
|
||||
// reposter le même créneau par inadvertance.
|
||||
@@ -228,13 +327,23 @@ function ShiftComposer({
|
||||
if (state.ok) onClose();
|
||||
}, [state.ok, onClose]);
|
||||
|
||||
const [start, end] = editing
|
||||
? target.shift.time.split('–')
|
||||
: ['09:00', '17:00'];
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3 print:hidden"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
{editing ? (
|
||||
<input type="hidden" name="shiftId" value={target.shift.id} />
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="localDate" value={localDate} />
|
||||
|
||||
<Field label="Jour">
|
||||
@@ -247,7 +356,7 @@ function ShiftComposer({
|
||||
<select
|
||||
id="composer-membership"
|
||||
name="membershipId"
|
||||
defaultValue={membershipId ?? ''}
|
||||
defaultValue={target.membershipId ?? ''}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
<option value="">Besoin non couvert</option>
|
||||
@@ -265,7 +374,7 @@ function ShiftComposer({
|
||||
name="start"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
defaultValue={start}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
@@ -276,7 +385,7 @@ function ShiftComposer({
|
||||
name="end"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="17:00"
|
||||
defaultValue={end}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
@@ -289,28 +398,30 @@ function ShiftComposer({
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={0}
|
||||
defaultValue={editing ? target.shift.breakMinutes : 0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{editing ? null : (
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Ajouter
|
||||
{editing ? 'Enregistrer' : 'Ajouter'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onClose}>
|
||||
Annuler
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
displayName,
|
||||
initialsOf,
|
||||
type BoardRow,
|
||||
type BoardShift,
|
||||
} from '@/domain/planning/board';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
@@ -16,7 +17,11 @@ export interface WeekGridProps {
|
||||
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
/** Rendu attaché à un créneau : suppression, édition. */
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
shiftAction?: (
|
||||
shift: BoardShift,
|
||||
row: BoardRow,
|
||||
dayIndex: number,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +42,10 @@ export function WeekGrid({
|
||||
}: WeekGridProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
{/* Rôles ARIA explicites : la grille est faite de div pour la mise en
|
||||
page, mais elle se lit comme un tableau — un lecteur d'écran doit
|
||||
pouvoir annoncer « ligne Camille Ferrand, colonne mercredi ». */}
|
||||
<div role="grid" aria-label="Planning de la semaine" className="min-w-[1040px]">
|
||||
<GridHeader days={days} />
|
||||
{unassignedRow ? (
|
||||
<Row
|
||||
@@ -70,13 +78,20 @@ export function WeekGrid({
|
||||
|
||||
function GridHeader({ days }: { days: readonly string[] }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
|
||||
<div className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<div
|
||||
role="row"
|
||||
className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2"
|
||||
>
|
||||
<div
|
||||
role="columnheader"
|
||||
className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
role="columnheader"
|
||||
key={day}
|
||||
className={cx(
|
||||
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
|
||||
@@ -102,19 +117,25 @@ function Row({
|
||||
days: readonly string[];
|
||||
dates: readonly string[];
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
shiftAction?: (
|
||||
shift: BoardShift,
|
||||
row: BoardRow,
|
||||
dayIndex: number,
|
||||
) => React.ReactNode;
|
||||
}) {
|
||||
const counters = computeWeekCounters(row.counters);
|
||||
const name = row.unassigned ? 'Non assigné' : displayName(row);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
data-membership={row.membershipId ?? 'unassigned'}
|
||||
className={cx(
|
||||
'flex border-b border-line-1 last:border-b-0',
|
||||
row.unassigned && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<div className="w-64 flex-none border-r border-line-1 px-3 py-2">
|
||||
<div role="rowheader" className="w-64 flex-none border-r border-line-1 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.unassigned ? (
|
||||
<span
|
||||
@@ -158,6 +179,7 @@ function Row({
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
<div
|
||||
role="gridcell"
|
||||
key={dates[index] ?? day}
|
||||
className={cx(
|
||||
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
@@ -174,7 +196,7 @@ function Row({
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shiftAction?.(shift.id)}
|
||||
{shiftAction?.(shift, row, index)}
|
||||
</div>
|
||||
))}
|
||||
{cellAction?.(row, index)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
||||
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
|
||||
|
||||
@@ -40,6 +40,8 @@ export const NAVIGATION: NavSection[] = [
|
||||
items: [
|
||||
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
|
||||
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
|
||||
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
|
||||
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
|
||||
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
|
||||
{ id: 'modeles', label: "Modèles d'ouverture" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Repérage des mois de planning.
|
||||
*
|
||||
* Le mois est la maille de la paie ; la semaine est celle du travail. Les deux
|
||||
* ne s'alignent jamais, et c'est précisément pourquoi la vue mois existe :
|
||||
* elle montre ce que la vue semaine ne peut pas montrer, à savoir un mois qui
|
||||
* commence un jeudi.
|
||||
*/
|
||||
|
||||
export interface Month {
|
||||
year: number;
|
||||
/** 1 = janvier. */
|
||||
month: number;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'janvier',
|
||||
'février',
|
||||
'mars',
|
||||
'avril',
|
||||
'mai',
|
||||
'juin',
|
||||
'juillet',
|
||||
'août',
|
||||
'septembre',
|
||||
'octobre',
|
||||
'novembre',
|
||||
'décembre',
|
||||
];
|
||||
|
||||
export function monthOf(date: Date): Month {
|
||||
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
|
||||
}
|
||||
|
||||
export function daysInMonth({ year, month }: Month): number {
|
||||
// Le jour 0 du mois suivant est le dernier du mois courant : la règle
|
||||
// bissextile est celle du calendrier, pas une table à maintenir.
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
/** Les dates civiles du mois, en `YYYY-MM-DD`. */
|
||||
export function monthDates(month: Month): string[] {
|
||||
return Array.from({ length: daysInMonth(month) }, (_, index) => {
|
||||
const day = String(index + 1).padStart(2, '0');
|
||||
return `${month.year}-${String(month.month).padStart(2, '0')}-${day}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function previousMonth({ year, month }: Month): Month {
|
||||
return month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
|
||||
}
|
||||
|
||||
export function nextMonth({ year, month }: Month): Month {
|
||||
return month === 12 ? { year: year + 1, month: 1 } : { year, month: month + 1 };
|
||||
}
|
||||
|
||||
/** Numéro du jour de la semaine, 1 = lundi … 7 = dimanche. */
|
||||
export function isoDayOfWeek(isoDate: string): number {
|
||||
const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay();
|
||||
return day === 0 ? 7 : day;
|
||||
}
|
||||
|
||||
export function monthLabel({ year, month }: Month): string {
|
||||
return `${MONTH_NAMES[month - 1] ?? ''} ${year}`;
|
||||
}
|
||||
|
||||
export function formatMonthParam({ year, month }: Month): string {
|
||||
return `${year}-${String(month).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Analyse « 2026-08 » ; renvoie `null` si la forme ou les bornes sont fausses. */
|
||||
export function parseMonthParam(value: string | undefined): Month | null {
|
||||
if (!value) return null;
|
||||
const match = /^(\d{4})-(\d{2})$/.exec(value);
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
if (month < 1 || month > 12) return null;
|
||||
return { year, month };
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { shiftMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
parseWeekParam,
|
||||
weekBounds,
|
||||
weekDates,
|
||||
zonedClock,
|
||||
zonedDate,
|
||||
zonedInstant,
|
||||
} from '@/domain/planning/week';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
@@ -359,6 +362,145 @@ export async function unpublishWeekAction(
|
||||
return setPublication(formData, false);
|
||||
}
|
||||
|
||||
const duplicateInput = z.object({
|
||||
teamId: z.string().min(1),
|
||||
/** Semaine à copier. */
|
||||
source: z.string().min(1),
|
||||
/** Semaine de destination. */
|
||||
target: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Duplique une semaine vers une autre.
|
||||
*
|
||||
* C'est le geste le plus fréquent du métier : les plannings de commerce se
|
||||
* répètent d'une semaine à l'autre, à quelques ajustements près. La copie
|
||||
* reporte les créneaux **jour à jour** — le lundi source devient le lundi
|
||||
* cible — et recalcule les instants dans le fuseau de l'établissement, sans
|
||||
* quoi une copie franchissant un changement d'heure décalerait tout d'une
|
||||
* heure.
|
||||
*
|
||||
* La destination doit être **vide** : écraser silencieusement le travail de
|
||||
* quelqu'un d'autre est pire que refuser.
|
||||
*/
|
||||
export async function duplicateWeekAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
const parsed = duplicateInput.safeParse({
|
||||
teamId: formData.get('teamId'),
|
||||
source: formData.get('source'),
|
||||
target: formData.get('target'),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const source = parseWeekParam(parsed.data.source);
|
||||
const target = parseWeekParam(parsed.data.target);
|
||||
if (!source || !target) return { error: 'Semaine invalide.' };
|
||||
if (source.isoYear === target.isoYear && source.isoWeek === target.isoWeek) {
|
||||
return { error: 'La semaine source et la semaine cible sont identiques.' };
|
||||
}
|
||||
|
||||
let copied = 0;
|
||||
|
||||
try {
|
||||
await mutate('planning.duplicate', async (db, actor) => {
|
||||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||||
|
||||
const from = await db.weeklySchedule.findUnique({
|
||||
where: {
|
||||
teamId_isoYear_isoWeek: {
|
||||
teamId: team.id,
|
||||
isoYear: source.isoYear,
|
||||
isoWeek: source.isoWeek,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!from) {
|
||||
throw new ValidationError("La semaine à copier n'existe pas.");
|
||||
}
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: { weeklyScheduleId: from.id },
|
||||
});
|
||||
if (shifts.length === 0) {
|
||||
throw new ValidationError('La semaine à copier ne contient aucun créneau.');
|
||||
}
|
||||
|
||||
const to = await ensureSchedule(
|
||||
db,
|
||||
team.id,
|
||||
location.id,
|
||||
target.isoYear,
|
||||
target.isoWeek,
|
||||
);
|
||||
|
||||
const existing = await db.shift.count({
|
||||
where: { weeklyScheduleId: to.id },
|
||||
});
|
||||
if (existing > 0) {
|
||||
throw new ValidationError(
|
||||
'La semaine de destination contient déjà des créneaux. Videz-la avant de dupliquer.',
|
||||
);
|
||||
}
|
||||
|
||||
const sourceDates = weekDates(source);
|
||||
const targetDates = weekDates(target);
|
||||
const dayOf = new Map(sourceDates.map((date, index) => [date, index]));
|
||||
|
||||
for (const shift of shifts) {
|
||||
const column = dayOf.get(zonedDate(shift.startAt, location.timezone));
|
||||
if (column === undefined) continue;
|
||||
|
||||
const targetDate = targetDates[column] as string;
|
||||
// Report par heure locale, pas par décalage de sept jours : entre
|
||||
// mars et avril, sept jours d'écart ne redonnent pas la même heure.
|
||||
const startClock = zonedClock(shift.startAt, location.timezone);
|
||||
const endClock = zonedClock(shift.endAt, location.timezone);
|
||||
|
||||
const startAt = zonedInstant(targetDate, startClock, location.timezone);
|
||||
let endAt = zonedInstant(targetDate, endClock, location.timezone);
|
||||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||||
|
||||
await db.shift.create({
|
||||
data: {
|
||||
weeklyScheduleId: to.id,
|
||||
membershipId: shift.membershipId,
|
||||
localDate: new Date(`${targetDate}T00:00:00Z`),
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
labelId: shift.labelId,
|
||||
note: shift.note,
|
||||
} as never,
|
||||
});
|
||||
copied += 1;
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'planning.week.duplicate',
|
||||
entityType: 'WeeklySchedule',
|
||||
entityId: to.id,
|
||||
after: {
|
||||
teamId: team.id,
|
||||
from: `${source.isoYear}-W${source.isoWeek}`,
|
||||
to: `${target.isoYear}-W${target.isoWeek}`,
|
||||
shifts: copied,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, 'Vous ne pouvez pas dupliquer cette semaine.');
|
||||
}
|
||||
|
||||
revalidatePath('/planning/semaine');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie ou dépublie la semaine d'une **équipe**.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { shiftMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
formatMonthParam,
|
||||
monthDates,
|
||||
monthLabel,
|
||||
nextMonth,
|
||||
previousMonth,
|
||||
isoDayOfWeek,
|
||||
type Month,
|
||||
} from '@/domain/planning/month';
|
||||
import {
|
||||
dayHeadings,
|
||||
formatWeekParam,
|
||||
nextIsoWeek,
|
||||
previousIsoWeek,
|
||||
weekBounds,
|
||||
weekDates,
|
||||
weekLabel,
|
||||
zonedClock,
|
||||
zonedDate,
|
||||
zonedMidnight,
|
||||
type IsoWeek,
|
||||
} from '@/domain/planning/week';
|
||||
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
|
||||
import { query } from '@/server/context';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Les vues secondaires du planning — étiquettes et mois.
|
||||
*
|
||||
* Elles lisent **le même modèle** que la grille hebdomadaire : `Shift` reste
|
||||
* la seule source. Une vue qui recopierait les créneaux dans sa propre table
|
||||
* finirait par les afficher autrement, et personne ne saurait laquelle croire.
|
||||
*/
|
||||
|
||||
const FALLBACK_POSTE: PosteCode = 'vte';
|
||||
|
||||
function posteOf(paletteKey: string | null | undefined): PosteCode {
|
||||
if (!paletteKey) return FALLBACK_POSTE;
|
||||
return (POSTE_CODES as readonly string[]).includes(paletteKey)
|
||||
? (paletteKey as PosteCode)
|
||||
: FALLBACK_POSTE;
|
||||
}
|
||||
|
||||
interface LocationRef {
|
||||
id: string;
|
||||
name: string;
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
/** Établissements visibles, avec celui qui est affiché. */
|
||||
async function resolveLocation(
|
||||
db: ScopedClient,
|
||||
locationId: string | undefined,
|
||||
): Promise<{ location: LocationRef | undefined; locations: LocationRef[] }> {
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return {
|
||||
locations,
|
||||
location:
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0],
|
||||
};
|
||||
}
|
||||
|
||||
async function visibleScheduleIds(
|
||||
db: ScopedClient,
|
||||
locationId: string,
|
||||
seesDrafts: boolean,
|
||||
week?: IsoWeek,
|
||||
): Promise<string[]> {
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const schedules = await db.weeklySchedule.findMany({
|
||||
where: {
|
||||
teamId: { in: teams.map((team) => team.id) },
|
||||
...(week ? { isoYear: week.isoYear, isoWeek: week.isoWeek } : {}),
|
||||
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return schedules.map((schedule) => schedule.id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vue étiquettes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface LabelBoardEntry {
|
||||
id: string;
|
||||
time: string;
|
||||
who: string;
|
||||
unassigned: boolean;
|
||||
}
|
||||
|
||||
export interface LabelBoardRow {
|
||||
labelId: string;
|
||||
name: string;
|
||||
poste: PosteCode;
|
||||
/** Sept cases, lundi → dimanche. */
|
||||
days: LabelBoardEntry[][];
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface LabelBoard {
|
||||
location: LocationRef;
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
label: string;
|
||||
headings: string[];
|
||||
weekParam: string;
|
||||
previousParam: string;
|
||||
nextParam: string;
|
||||
rows: LabelBoardRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* La semaine vue par poste plutôt que par salarié.
|
||||
*
|
||||
* C'est la question du responsable d'ouverture : « la caisse est-elle tenue
|
||||
* samedi ? » — impossible à lire d'un coup d'œil sur une grille dont les
|
||||
* lignes sont des personnes.
|
||||
*/
|
||||
export async function getLabelBoard(
|
||||
week: IsoWeek,
|
||||
locationId?: string,
|
||||
): Promise<LabelBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
const { location, locations } = await resolveLocation(db, locationId);
|
||||
if (!location) return null;
|
||||
|
||||
const dates = weekDates(week);
|
||||
const { from, to } = weekBounds(week, location.timezone);
|
||||
const scheduleIds = await visibleScheduleIds(
|
||||
db,
|
||||
location.id,
|
||||
seesDrafts,
|
||||
week,
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: scheduleIds },
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
});
|
||||
|
||||
const labels = await db.label.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
|
||||
const memberIds = [
|
||||
...new Set(
|
||||
shifts
|
||||
.map((shift) => shift.membershipId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const members = await db.membership.findMany({
|
||||
where: { id: { in: memberIds } },
|
||||
include: { profile: { select: { firstName: true, lastName: true } } },
|
||||
});
|
||||
const nameById = new Map(
|
||||
members.map((member) => [
|
||||
member.id,
|
||||
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
|
||||
]),
|
||||
);
|
||||
|
||||
const columnOf = new Map(dates.map((date, index) => [date, index]));
|
||||
const rowByLabel = new Map<string, LabelBoardRow>();
|
||||
|
||||
const rowFor = (labelId: string | null): LabelBoardRow => {
|
||||
const key = labelId ?? 'sans-etiquette';
|
||||
const existing = rowByLabel.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const label = labels.find((candidate) => candidate.id === labelId);
|
||||
const created: LabelBoardRow = {
|
||||
labelId: key,
|
||||
name: label?.name ?? 'Sans étiquette',
|
||||
poste: posteOf(label?.paletteKey),
|
||||
days: Array.from({ length: 7 }, () => [] as LabelBoardEntry[]),
|
||||
minutes: 0,
|
||||
};
|
||||
rowByLabel.set(key, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const shift of shifts) {
|
||||
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
|
||||
if (column === undefined) continue;
|
||||
|
||||
const row = rowFor(shift.labelId);
|
||||
row.days[column]?.push({
|
||||
id: shift.id,
|
||||
time: `${zonedClock(shift.startAt, location.timezone)}–${zonedClock(shift.endAt, location.timezone)}`,
|
||||
who: shift.membershipId
|
||||
? (nameById.get(shift.membershipId) ?? 'Salarié')
|
||||
: 'Non couvert',
|
||||
unassigned: !shift.membershipId,
|
||||
});
|
||||
row.minutes += shiftMinutes(
|
||||
shift.startAt,
|
||||
shift.endAt,
|
||||
shift.breakMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
// Les postes non planifiés restent affichés : un poste vide toute la
|
||||
// semaine est une information, pas une ligne à masquer.
|
||||
for (const label of labels) rowFor(label.id);
|
||||
|
||||
const order = new Map(labels.map((label, index) => [label.id, index]));
|
||||
const rows = [...rowByLabel.values()].sort(
|
||||
(a, b) =>
|
||||
(order.get(a.labelId) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(order.get(b.labelId) ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
label: weekLabel(week),
|
||||
headings: dayHeadings(week),
|
||||
weekParam: formatWeekParam(week),
|
||||
previousParam: formatWeekParam(previousIsoWeek(week)),
|
||||
nextParam: formatWeekParam(nextIsoWeek(week)),
|
||||
rows,
|
||||
} satisfies LabelBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vue mois
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MonthDay {
|
||||
isoDate: string;
|
||||
day: number;
|
||||
weekend: boolean;
|
||||
}
|
||||
|
||||
export interface MonthRow {
|
||||
membershipId: string;
|
||||
name: string;
|
||||
/** Minutes travaillées par jour, dans l'ordre de `days`. */
|
||||
minutes: number[];
|
||||
totalMinutes: number;
|
||||
workedDays: number;
|
||||
}
|
||||
|
||||
export interface MonthBoard {
|
||||
location: LocationRef;
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
label: string;
|
||||
monthParam: string;
|
||||
previousParam: string;
|
||||
nextParam: string;
|
||||
days: MonthDay[];
|
||||
rows: MonthRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le mois par salarié.
|
||||
*
|
||||
* Vue de contrôle plutôt que d'édition : elle sert à repérer une série de
|
||||
* journées trop longues ou un salarié sans repos, ce que sept colonnes ne
|
||||
* laissent pas voir.
|
||||
*/
|
||||
export async function getMonthBoard(
|
||||
month: Month,
|
||||
locationId?: string,
|
||||
): Promise<MonthBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
const { location, locations } = await resolveLocation(db, locationId);
|
||||
if (!location) return null;
|
||||
|
||||
const dates = monthDates(month);
|
||||
const first = dates[0] as string;
|
||||
const last = dates[dates.length - 1] as string;
|
||||
const from = zonedMidnight(first, location.timezone);
|
||||
const to = zonedMidnight(
|
||||
new Date(new Date(`${last}T00:00:00Z`).getTime() + 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
location.timezone,
|
||||
);
|
||||
|
||||
const scheduleIds = await visibleScheduleIds(
|
||||
db,
|
||||
location.id,
|
||||
seesDrafts,
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: scheduleIds },
|
||||
membershipId: { not: null },
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
select: {
|
||||
membershipId: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const assignments = await db.teamMember.findMany({
|
||||
where: { teamId: { in: teams.map((team) => team.id) } },
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const columnOf = new Map(dates.map((date, index) => [date, index]));
|
||||
const rowById = new Map<string, MonthRow>();
|
||||
|
||||
for (const assignment of assignments) {
|
||||
if (rowById.has(assignment.membershipId)) continue;
|
||||
const profile = assignment.membership.profile;
|
||||
rowById.set(assignment.membershipId, {
|
||||
membershipId: assignment.membershipId,
|
||||
name: `${profile?.firstName ?? ''} ${profile?.lastName ?? assignment.membership.employeeNumber}`.trim(),
|
||||
minutes: dates.map(() => 0),
|
||||
totalMinutes: 0,
|
||||
workedDays: 0,
|
||||
});
|
||||
}
|
||||
|
||||
for (const shift of shifts) {
|
||||
if (!shift.membershipId) continue;
|
||||
const row = rowById.get(shift.membershipId);
|
||||
if (!row) continue;
|
||||
|
||||
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
|
||||
if (column === undefined) continue;
|
||||
|
||||
const minutes = shiftMinutes(
|
||||
shift.startAt,
|
||||
shift.endAt,
|
||||
shift.breakMinutes,
|
||||
);
|
||||
const before = row.minutes[column] ?? 0;
|
||||
if (before === 0 && minutes > 0) row.workedDays += 1;
|
||||
row.minutes[column] = before + minutes;
|
||||
row.totalMinutes += minutes;
|
||||
}
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
label: monthLabel(month),
|
||||
monthParam: formatMonthParam(month),
|
||||
previousParam: formatMonthParam(previousMonth(month)),
|
||||
nextParam: formatMonthParam(nextMonth(month)),
|
||||
days: dates.map((isoDate) => ({
|
||||
isoDate,
|
||||
day: Number(isoDate.slice(8, 10)),
|
||||
weekend: isoDayOfWeek(isoDate) >= 6,
|
||||
})),
|
||||
rows: [...rowById.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name, 'fr'),
|
||||
),
|
||||
} satisfies MonthBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,12 @@ import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
* ou déjà rempli.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chaque test possède **sa** semaine : la suite tourne en parallèle, et deux
|
||||
* tests qui videraient puis rempliraient la même grille se marcheraient dessus.
|
||||
*/
|
||||
const WEEK = '2027-W20';
|
||||
const CROSS_VIEW_WEEK = '2027-W22';
|
||||
|
||||
function venteSection(page: Page): Locator {
|
||||
return page
|
||||
@@ -21,8 +26,8 @@ function venteSection(page: Page): Locator {
|
||||
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
|
||||
}
|
||||
|
||||
async function resetWeek(page: Page): Promise<void> {
|
||||
await page.goto(`/planning/semaine?semaine=${WEEK}`);
|
||||
async function resetWeek(page: Page, week = WEEK): Promise<void> {
|
||||
await page.goto(`/planning/semaine?semaine=${week}`);
|
||||
const section = venteSection(page);
|
||||
await expect(section).toBeVisible();
|
||||
|
||||
@@ -49,9 +54,12 @@ async function addShift(
|
||||
section: Locator,
|
||||
times: { start: string; end: string; pause?: string },
|
||||
): Promise<Locator> {
|
||||
// Viser la ligne par son rôle ARIA : `locator('div')` remonterait au
|
||||
// conteneur de la grille, et le premier bouton trouvé serait celui d'un
|
||||
// autre salarié — une erreur qui ne se voit pas à l'écran.
|
||||
const row = section
|
||||
.locator('div')
|
||||
.filter({ hasText: /Camille Ferrand/ })
|
||||
.getByRole('row')
|
||||
.filter({ hasText: 'Camille Ferrand' })
|
||||
.first();
|
||||
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
|
||||
|
||||
@@ -114,3 +122,40 @@ test('la navigation de semaine change la grille', async ({ page }) => {
|
||||
page.getByRole('heading', { name: 'Planning · semaine 20' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Les vues lisent le même modèle.
|
||||
*
|
||||
* C'est le critère d'acceptation de WP-04 : un créneau posé dans la grille
|
||||
* hebdomadaire doit apparaître **identique** dans les vues jour, poste et
|
||||
* mois. Chacune interroge `Shift` directement ; ce test est ce qui empêche
|
||||
* l'une d'elles de dériver vers son propre calcul.
|
||||
*/
|
||||
test('un créneau posé se retrouve dans les quatre vues', async ({ page }) => {
|
||||
await resetWeek(page, CROSS_VIEW_WEEK);
|
||||
const section = venteSection(page);
|
||||
|
||||
await addShift(page, section, { start: '08:30', end: '16:30', pause: '45' });
|
||||
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
|
||||
|
||||
// Lundi de la semaine 22 de 2027.
|
||||
const monday = '2027-05-31';
|
||||
|
||||
await page.goto(`/planning/jour?jour=${monday}`);
|
||||
// Chercher le nom dans la chronologie, pas dans l'en-tête de l'application,
|
||||
// qui affiche déjà « Camille Ferrand, Propriétaire ».
|
||||
await expect(page.getByTitle(/^ENC · 08:30–16:30$|08:30–16:30/).first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('main').getByText('Camille Ferrand').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(`/planning/etiquettes?semaine=${CROSS_VIEW_WEEK}`);
|
||||
await expect(page.getByText('08:30–16:30').first()).toBeVisible();
|
||||
|
||||
await page.goto('/planning/mois?mois=2027-05');
|
||||
const row = page.getByRole('row').filter({ hasText: 'Camille Ferrand' });
|
||||
// 8 h d'amplitude moins 45 min de pause : 7,3 h dans la case du jour. Les
|
||||
// deux vues doivent dire la même durée. `first()` parce que l'autre test de
|
||||
// ce fichier planifie le même salarié ailleurs dans le mois.
|
||||
await expect(row.getByText('7,3').first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
daysInMonth,
|
||||
formatMonthParam,
|
||||
isoDayOfWeek,
|
||||
monthDates,
|
||||
monthLabel,
|
||||
monthOf,
|
||||
nextMonth,
|
||||
parseMonthParam,
|
||||
previousMonth,
|
||||
} from '@/domain/planning/month';
|
||||
|
||||
describe('daysInMonth', () => {
|
||||
it('suit le calendrier, y compris les années bissextiles', () => {
|
||||
expect(daysInMonth({ year: 2026, month: 2 })).toBe(28);
|
||||
expect(daysInMonth({ year: 2028, month: 2 })).toBe(29);
|
||||
expect(daysInMonth({ year: 2026, month: 4 })).toBe(30);
|
||||
expect(daysInMonth({ year: 2026, month: 12 })).toBe(31);
|
||||
});
|
||||
|
||||
it('traite 2100 comme non bissextile', () => {
|
||||
// Divisible par 4 mais pas par 400 : le piège classique d'une table écrite
|
||||
// à la main.
|
||||
expect(daysInMonth({ year: 2100, month: 2 })).toBe(28);
|
||||
expect(daysInMonth({ year: 2000, month: 2 })).toBe(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthDates', () => {
|
||||
it('énumère le mois entier', () => {
|
||||
const dates = monthDates({ year: 2026, month: 2 });
|
||||
expect(dates).toHaveLength(28);
|
||||
expect(dates[0]).toBe('2026-02-01');
|
||||
expect(dates[27]).toBe('2026-02-28');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigation', () => {
|
||||
it('franchit l’année dans les deux sens', () => {
|
||||
expect(previousMonth({ year: 2026, month: 1 })).toEqual({
|
||||
year: 2025,
|
||||
month: 12,
|
||||
});
|
||||
expect(nextMonth({ year: 2026, month: 12 })).toEqual({
|
||||
year: 2027,
|
||||
month: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isoDayOfWeek', () => {
|
||||
it('numérote lundi 1 et dimanche 7', () => {
|
||||
// Le samedi et le dimanche portent un fond distinct dans la vue mois :
|
||||
// une erreur de numérotation les décalerait d'un jour toute l'année.
|
||||
expect(isoDayOfWeek('2026-08-10')).toBe(1);
|
||||
expect(isoDayOfWeek('2026-08-15')).toBe(6);
|
||||
expect(isoDayOfWeek('2026-08-16')).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paramètre d’URL', () => {
|
||||
it('fait un aller-retour', () => {
|
||||
const month = { year: 2026, month: 8 };
|
||||
expect(formatMonthParam(month)).toBe('2026-08');
|
||||
expect(parseMonthParam('2026-08')).toEqual(month);
|
||||
});
|
||||
|
||||
it('rejette ce qui n’est pas un mois', () => {
|
||||
expect(parseMonthParam('2026-13')).toBeNull();
|
||||
expect(parseMonthParam('2026-00')).toBeNull();
|
||||
expect(parseMonthParam('août')).toBeNull();
|
||||
expect(parseMonthParam(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('libellés', () => {
|
||||
it('nomme le mois en français', () => {
|
||||
expect(monthLabel({ year: 2026, month: 8 })).toBe('août 2026');
|
||||
});
|
||||
|
||||
it('déduit le mois d’une date', () => {
|
||||
expect(monthOf(new Date('2026-08-12T00:00:00Z'))).toEqual({
|
||||
year: 2026,
|
||||
month: 8,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user