From d9f55f866fb9084d88d2899aba35be9c133da208 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:20:36 +0000 Subject: [PATCH] =?UTF-8?q?WP-04=20(suite)=20:=20vues=20par=20poste=20et?= =?UTF-8?q?=20par=20mois,=20duplication,=20=C3=A9dition,=20impression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- src/app/(app)/planning/etiquettes/page.tsx | 176 +++++++++ src/app/(app)/planning/jour/page.tsx | 2 + src/app/(app)/planning/mois/page.tsx | 177 ++++++++++ src/app/(app)/planning/semaine/page.tsx | 7 +- src/app/globals.css | 58 +++ src/components/planning/PrintButton.tsx | 19 + src/components/planning/TeamSection.tsx | 197 ++++++++--- src/components/planning/WeekGrid.tsx | 36 +- src/components/shell/AppShell.tsx | 2 +- src/components/shell/navigation.ts | 2 + src/domain/planning/month.ts | 80 +++++ src/server/planning/actions.ts | 142 ++++++++ src/server/planning/views.ts | 393 +++++++++++++++++++++ tests/e2e/planning.spec.ts | 53 ++- tests/unit/planning-month.test.ts | 89 +++++ 15 files changed, 1377 insertions(+), 56 deletions(-) create mode 100644 src/app/(app)/planning/etiquettes/page.tsx create mode 100644 src/app/(app)/planning/mois/page.tsx create mode 100644 src/components/planning/PrintButton.tsx create mode 100644 src/domain/planning/month.ts create mode 100644 src/server/planning/views.ts create mode 100644 tests/unit/planning-month.test.ts diff --git a/src/app/(app)/planning/etiquettes/page.tsx b/src/app/(app)/planning/etiquettes/page.tsx new file mode 100644 index 0000000..a2eb41d --- /dev/null +++ b/src/app/(app)/planning/etiquettes/page.tsx @@ -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 ( + + + + ); + } + + const href = (semaine: string, etablissement = board.location.id) => + `/planning/etiquettes?semaine=${semaine}&etablissement=${etablissement}`; + + return ( + + + + ← Semaine précédente + + + Semaine suivante → + + + + } + /> + +
+ {board.locations.map((location) => ( + + {location.name} + + ))} + + Voir par salarié → + +
+ +
+
+
+
+ Poste +
+
+ {board.headings.map((day, index) => ( +
4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1', + )} + > + {day} +
+ ))} +
+
+ + {board.rows.map((row) => { + const tokens = posteTokens(row.poste); + return ( +
+
+ + {posteShort(row.poste)} + + + + {row.name} + + + {formatMinutes(row.minutes)} sur la semaine + + +
+ +
+ {board.headings.map((day, index) => ( +
4 && 'bg-surface-2', + )} + > + {(row.days[index] ?? []).map((entry) => ( + + {entry.time} + + {entry.who} + + + ))} +
+ ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(app)/planning/jour/page.tsx b/src/app/(app)/planning/jour/page.tsx index 28a1f48..7fbda92 100644 --- a/src/app/(app)/planning/jour/page.tsx +++ b/src/app/(app)/planning/jour/page.tsx @@ -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 → + } /> diff --git a/src/app/(app)/planning/mois/page.tsx b/src/app/(app)/planning/mois/page.tsx new file mode 100644 index 0000000..d6ebfd8 --- /dev/null +++ b/src/app/(app)/planning/mois/page.tsx @@ -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 ( + + + + ); + } + + 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 ( + + + + ← Mois précédent + + + Mois suivant → + + + + } + /> + +
+ {board.locations.map((location) => ( + + {location.name} + + ))} +
+ +
+ + + + + + {board.days.map((day) => ( + + ))} + + + + + {board.rows.map((row) => ( + + + {row.minutes.map((minutes, index) => { + const day = board.days[index]; + return ( + + ); + })} + + + ))} + +
+ Heures planifiées par salarié et par jour, {board.label} +
+ Salarié + + {day.day} + + Total +
+ {row.name} + + {row.workedDays} jour{row.workedDays > 1 ? 's' : ''}{' '} + travaillé{row.workedDays > 1 ? 's' : ''} + + 600 && 'bg-warn-soft text-warn-soft-ink', + )} + title={ + minutes > 0 + ? `${formatMinutes(minutes)} le ${day?.day ?? ''}` + : undefined + } + > + {compact(minutes)} + + {formatMinutes(row.totalMinutes)} +
+
+ + {board.rows.length === 0 ? ( +

+ Aucun salarié rattaché à une équipe de cet établissement. +

+ ) : null} +
+ ); +} diff --git a/src/app/(app)/planning/semaine/page.tsx b/src/app/(app)/planning/semaine/page.tsx index e3128fb..f0652aa 100644 --- a/src/app/(app)/planning/semaine/page.tsx +++ b/src/app/(app)/planning/semaine/page.tsx @@ -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 → + } /> -
+
{board.locations.map((location) => ( ))} diff --git a/src/app/globals.css b/src/app/globals.css index 21b3b22..0bf23b1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/components/planning/PrintButton.tsx b/src/components/planning/PrintButton.tsx new file mode 100644 index 0000000..29a12dc --- /dev/null +++ b/src/components/planning/PrintButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx index c0c2571..d0d407b 100644 --- a/src/components/planning/TeamSection.tsx +++ b/src/components/planning/TeamSection.tsx @@ -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(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({
- {canPublish ? ( - - ) : null} +
+ {canDuplicate && isEmpty ? ( + + ) : null} + {canPublish ? ( + + ) : null} +
- 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 @@ -117,8 +151,26 @@ export function TeamSection({ ), - shiftAction: (shiftId: string) => ( - + shiftAction: (shift, row, dayIndex) => ( +
+ + +
), } : {})} @@ -126,13 +178,14 @@ export function TeamSection({ {target ? ( 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 ( +
+ + + + {state.error ? ( + + {state.error} + + ) : null} + +
+ ); +} + function DeleteShift({ shiftId }: { shiftId: string }) { const [state, formAction, pending] = useActionState(deleteShiftAction, empty); return ( -
+ {/* 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 ( - - + {editing ? ( + + ) : ( + <> + + + + )} @@ -247,7 +356,7 @@ function ShiftComposer({ - {labels.map((label) => ( - - ))} - - + {editing ? null : ( + + + + )}