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:
Claude committed 2026-08-08 07:20:36 +00:00
1 parent 023f236881
commit d9f55f866f
15 files changed
+1377 -56

No files matched your search

+19
View File
@@ -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>
);
}
+154 -43
View File
@@ -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
+29 -7
View File
@@ -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)}
+1 -1
View File
@@ -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]"
+2
View File
@@ -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" },
],