From e4be93a208c7160408a9835e88619cda20a6892c Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 12:30:02 +0200 Subject: [PATCH] =?UTF-8?q?Compl=C3=A9ter=20les=20vues=20de=20planning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux des cinq vues de §9 manquaient. La vue des présences répond à une question que les quatre autres posent mal : qui est là aujourd'hui. La grille montre des créneaux, pas des personnes ; il fallait la lire colonne par colonne et compter de tête. Le planning à afficher est une mise en page pour la feuille punaisée en salle de pause — toutes les équipes à la suite, sans panneau d'édition ni compteurs, un aplat par créneau avec l'horaire et le code du poste écrits. Sans la capacité de voir le non publié, la feuille ne la contourne pas : imprimer un brouillon revient à le publier sur un mur. La règle « absent ou présent » part dans le domaine plutôt que dans les écrans. Les deux vues en tirent la même conclusion, et un export futur en tirera la même : deux implémentations finiraient par se contredire sur le seul cas qui compte, celui du congé validé après la construction de la semaine. Les créneaux restent alors au planning, et l'absence l'emporte — afficher « présent » enverrait un responsable chercher en rayon quelqu'un qui est chez lui. Co-Authored-By: Claude Opus 5 --- src/app/(app)/planning/impression/page.tsx | 219 +++++++++++++++++ src/app/(app)/planning/presences/page.tsx | 259 +++++++++++++++++++++ src/components/shell/navigation.ts | 6 + src/domain/planning/presence.ts | 70 ++++++ tests/unit/planning-presence.test.ts | 111 +++++++++ 5 files changed, 665 insertions(+) create mode 100644 src/app/(app)/planning/impression/page.tsx create mode 100644 src/app/(app)/planning/presences/page.tsx create mode 100644 src/domain/planning/presence.ts create mode 100644 tests/unit/planning-presence.test.ts diff --git a/src/app/(app)/planning/impression/page.tsx b/src/app/(app)/planning/impression/page.tsx new file mode 100644 index 0000000..f44f815 --- /dev/null +++ b/src/app/(app)/planning/impression/page.tsx @@ -0,0 +1,219 @@ +import Link from 'next/link'; + +import { PrintButton } from '@/components/planning/PrintButton'; +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge } from '@/components/ui/Badge'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { can } from '@/domain/access/authorize'; +import { formatMinutes } from '@/domain/counters/week'; +import { displayName, type BoardRow } from '@/domain/planning/board'; +import { absenceOnDay } from '@/domain/planning/presence'; +import { isoWeekOf, parseWeekParam } from '@/domain/planning/week'; +import { isPosteCode, posteTokens } from '@/lib/design/postes'; +import { requireSession } from '@/server/context'; +import { getWeekBoard } from '@/server/planning/queries'; + +export const metadata = { title: 'Planning · impression · PlanFlow' }; + +/** + * Planning à afficher — PLAN.md §9, `/planning/impression`. + * + * Ce n'est pas la grille d'édition avec les boutons cachés. C'est une mise en + * page faite pour la feuille punaisée en salle de pause : toutes les équipes à + * la suite, pas de panneau d'édition, pas de compteurs — et un seul aplat par + * créneau, avec l'horaire et le code du poste écrits en toutes lettres. + * + * Rien n'est recalculé : le même `getWeekBoard` que les cinq vues. Une feuille + * imprimée qui contredit l'écran est pire qu'une absence de feuille, parce + * qu'elle survit à la correction. + * + * Le rendu PDF passe par la boîte d'impression du navigateur (voir + * `PrintButton`) : un second moteur de rendu serait un second endroit où la + * grille peut diverger. + */ + +interface PageProps { + searchParams: Promise<{ semaine?: string; etablissement?: string }>; +} + +function ShiftLine({ shift }: { shift: BoardRow['days'][number][number] }) { + const tokens = isPosteCode(shift.poste) ? posteTokens(shift.poste) : null; + + return ( +
  • + {shift.time} + {shift.poste.toUpperCase()} + {shift.breakMinutes > 0 ? ( + pause {shift.breakMinutes} min + ) : null} +
  • + ); +} + +export default async function ImpressionPage({ searchParams }: PageProps) { + const params = await searchParams; + const session = await requireSession(); + const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date()); + const board = await getWeekBoard(week, params.etablissement); + + if (!board) { + return ( + + + + ); + } + + // Sans la capacité de voir le non publié, la feuille ne doit pas la + // contourner : imprimer un brouillon revient à le publier sur un mur. + const seeUnpublished = can(session.actor, 'planning.view_unpublished'); + const sections = board.sections.filter( + (section) => + !section.hidden && (seeUnpublished || section.status === 'PUBLISHED'), + ); + + const hiddenDrafts = board.sections.filter( + (section) => !section.hidden && section.status !== 'PUBLISHED', + ).length; + + return ( + +
    + + + Retour à la grille + + + + } + /> +
    + +

    + {board.location.name} — semaine {week.isoWeek}, {board.label} +

    + + {hiddenDrafts > 0 && seeUnpublished ? ( +

    + {hiddenDrafts} équipe{hiddenDrafts > 1 ? 's' : ''} non publiée + {hiddenDrafts > 1 ? 's' : ''} figure + {hiddenDrafts > 1 ? 'nt' : ''} sur cette feuille. Un planning affiché + en salle vaut information des salariés : publiez avant d’imprimer. +

    + ) : null} + + {sections.length === 0 ? ( + + + + ) : null} + + {sections.map((section) => ( + + Publié + ) : ( + Brouillon + ) + } + /> + +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + {[ + ...section.rows, + ...(section.unassignedRow ? [section.unassignedRow] : []), + ].map((row) => ( + + + + {board.dates.map((date, index) => { + const absence = absenceOnDay(row, index); + const shifts = row.days[index] ?? []; + + return ( + + ); + })} + + ))} + +
    + Salarié + + {heading} +
    + {displayName(row)} + + {row.unassigned ? 'À pourvoir' : row.job} + + + {absence ? ( + + {absence.label} + + ) : shifts.length === 0 ? ( + — + ) : ( +
      + {shifts.map((shift) => ( + + ))} +
    + )} +
    +
    +
    + ))} + +

    + Édité depuis PlanFlow · {board.location.name} · semaine {week.isoWeek}. + Les horaires font foi tels qu’ils étaient à l’édition ; une modification + postérieure du planning n’apparaît pas sur cette feuille. +

    +
    + ); +} diff --git a/src/app/(app)/planning/presences/page.tsx b/src/app/(app)/planning/presences/page.tsx new file mode 100644 index 0000000..11d02db --- /dev/null +++ b/src/app/(app)/planning/presences/page.tsx @@ -0,0 +1,259 @@ +import Link from 'next/link'; + +import { PageBody, PageHeader } from '@/components/shell/PageHeader'; +import { Badge, type Tone } from '@/components/ui/Badge'; +import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; +import { formatMinutes } from '@/domain/counters/week'; +import { displayName } from '@/domain/planning/board'; +import { + absenceOnDay, + dayState, + headcountByDay, + type DayState, +} from '@/domain/planning/presence'; +import { isoWeekOf, parseWeekParam } from '@/domain/planning/week'; +import { cx } from '@/lib/cx'; +import { getWeekBoard } from '@/server/planning/queries'; + +export const metadata = { title: 'Planning · présences · PlanFlow' }; + +/** + * Vue des présences et absences — cinquième vue de §9. + * + * Elle répond à une question que les quatre autres posent mal : **qui est là + * aujourd'hui ?** La grille hebdomadaire montre des créneaux, pas des + * personnes ; il faut la lire colonne par colonne et compter de tête. + * + * Elle lit le **même modèle** que les autres vues, sans requête propre : une + * modification faite dans la grille est correcte ici à l'instant même. Une vue + * qui recalcule ses présences de son côté finit par contredire le planning + * qu'elle prétend résumer. + */ + +interface PageProps { + searchParams: Promise<{ semaine?: string; etablissement?: string }>; +} + +const STATE_TONE: Record = { + present: 'ok', + absent: 'warn', + off: 'neutral', +}; + +const STATE_LABEL: Record = { + present: 'Présent', + absent: 'Absent', + off: 'Repos', +}; + +/** + * État d'un salarié un jour donné. + * + * L'absence l'emporte sur le créneau : un salarié dont l'absence a été acceptée + * après la construction du planning garde des créneaux en base, et l'afficher + * « présent » enverrait un manager le chercher en rayon. + */ +export default async function PresencesPage({ searchParams }: PageProps) { + const params = await searchParams; + const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date()); + const board = await getWeekBoard(week, params.etablissement); + + if (!board) { + return ( + + + + ); + } + + const href = (semaine: string, etablissement = board.location.id) => + `/planning/presences?semaine=${semaine}&etablissement=${etablissement}`; + + // Les besoins non couverts n'ont personne à compter : ils appartiennent à la + // grille, pas à un état de présence. + const sections = board.sections + .filter((section) => !section.hidden) + .map((section) => ({ + ...section, + rows: section.rows.filter((row) => !row.unassigned), + })); + + const perDay = headcountByDay( + sections.flatMap((section) => section.rows), + board.dates.length, + ).map((tally, index) => ({ date: board.dates[index] as string, ...tally })); + + return ( + + + + ← Semaine précédente + + + Semaine suivante → + + + } + /> + + + +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + + + {perDay.map((day) => ( + + ))} + + + + {perDay.map((day) => ( + + ))} + + + + {perDay.map((day) => ( + + ))} + + +
    + Jour + + {heading} +
    Présents + {day.present} +
    Absents 0 ? 'text-warn-soft-ink' : 'text-ink-3', + )} + > + {day.absent} +
    Repos + {day.off} +
    +
    +
    + + {sections.length === 0 ? ( + + + + ) : null} + + {sections.map((section) => ( + + + {section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'} + + } + /> + + {section.rows.length === 0 ? ( + + ) : ( +
    + + + + + {board.headings.map((heading) => ( + + ))} + + + + {section.rows.map((row) => ( + + + + {board.dates.map((date, index) => { + const state = dayState(row, index); + const absence = absenceOnDay(row, index); + return ( + + ); + })} + + ))} + +
    + Salarié + + {heading} +
    + {row.membershipId ? ( + + {displayName(row)} + + ) : ( + displayName(row) + )} + + {row.job} + + + + {absence ? absence.label : STATE_LABEL[state]} + +
    +
    + )} +
    + ))} + +

    + Une absence acceptée l’emporte sur les créneaux restés au planning : un + salarié dont le congé a été validé après la construction de la semaine + apparaît absent, même si ses créneaux n’ont pas encore été retirés. + L’inverse enverrait un responsable le chercher en rayon. +

    +
    + ); +} diff --git a/src/components/shell/navigation.ts b/src/components/shell/navigation.ts index c8c5ace..d81b970 100644 --- a/src/components/shell/navigation.ts +++ b/src/components/shell/navigation.ts @@ -50,6 +50,12 @@ export const NAVIGATION: NavSection[] = [ { 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: 'presences', + label: 'Présences et absences', + href: '/planning/presences', + }, + { id: 'impression', label: 'Planning à afficher', href: '/planning/impression' }, { id: 'besoins', label: 'Besoins non couverts' }, { id: 'modeles', label: "Modèles d'ouverture" }, ], diff --git a/src/domain/planning/presence.ts b/src/domain/planning/presence.ts new file mode 100644 index 0000000..0f72539 --- /dev/null +++ b/src/domain/planning/presence.ts @@ -0,0 +1,70 @@ +import type { BoardAbsence, BoardRow } from '@/domain/planning/board'; + +/** + * Lecture « présent / absent / repos » d'une ligne de planning. + * + * Vit dans le domaine et non dans l'écran : la vue des présences, la feuille + * imprimée et, demain, un export en tirent la même conclusion. Deux + * implémentations de cette règle finiraient par se contredire sur le seul cas + * qui compte — celui du salarié dont le congé a été validé après coup. + */ + +export type DayState = 'present' | 'absent' | 'off'; + +/** + * Absence recouvrant la colonne demandée. + * + * Les absences d'une ligne sont déjà découpées sur la semaine affichée : + * `startDay` est un index de colonne, `span` un nombre de jours. Repartir des + * dates ici referait, moins bien, un travail déjà fait — et rouvrirait la + * question du fuseau au passage. + */ +export function absenceOnDay( + row: BoardRow, + dayIndex: number, +): BoardAbsence | undefined { + return row.absences.find( + (absence) => + dayIndex >= absence.startDay && dayIndex < absence.startDay + absence.span, + ); +} + +/** + * État d'un salarié un jour donné. + * + * **L'absence l'emporte sur le créneau.** Un congé accepté après la + * construction du planning laisse les créneaux en base : afficher « présent » + * enverrait un responsable chercher en rayon quelqu'un qui est chez lui. Le + * planning a tort, pas l'absence. + */ +export function dayState(row: BoardRow, dayIndex: number): DayState { + if (absenceOnDay(row, dayIndex)) return 'absent'; + return (row.days[dayIndex]?.length ?? 0) > 0 ? 'present' : 'off'; +} + +export interface DayHeadcount { + present: number; + absent: number; + off: number; +} + +/** + * Effectif par jour sur un ensemble de lignes. + * + * Les lignes de besoins non couverts sont écartées : elles ne portent personne, + * et les compter en « repos » gonflerait un effectif qui n'existe pas. + */ +export function headcountByDay( + rows: BoardRow[], + dayCount: number, +): DayHeadcount[] { + const counted = rows.filter((row) => !row.unassigned); + + return Array.from({ length: dayCount }, (_, index) => { + const tally: DayHeadcount = { present: 0, absent: 0, off: 0 }; + for (const row of counted) { + tally[dayState(row, index)] += 1; + } + return tally; + }); +} diff --git a/tests/unit/planning-presence.test.ts b/tests/unit/planning-presence.test.ts new file mode 100644 index 0000000..7502572 --- /dev/null +++ b/tests/unit/planning-presence.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; + +import type { BoardRow } from '@/domain/planning/board'; +import { + absenceOnDay, + dayState, + headcountByDay, +} from '@/domain/planning/presence'; + +function row(overrides: Partial = {}): BoardRow { + return { + membershipId: 'm1', + firstName: 'Camille', + lastName: 'Ferrand', + job: 'Vendeuse', + forfaitJours: false, + counters: { + contractMinutes: 0, + plannedMinutes: 0, + absenceMinutes: 0, + sundaysWorked: 0, + restDays: 0, + }, + days: [[], [], [], [], [], [], []], + absences: [], + unassigned: false, + ...overrides, + }; +} + +const shift: BoardRow['days'][number][number] = { + id: 's1', + poste: 'cai', + time: '09:00–17:00', + minutes: 480, + breakMinutes: 30, + state: 'published', + note: null, +}; + +describe('dayState', () => { + it('dit « repos » sur une case vide', () => { + expect(dayState(row(), 0)).toBe('off'); + }); + + it('dit « présent » quand la case porte un créneau', () => { + const days: BoardRow['days'] = [[shift], [], [], [], [], [], []]; + expect(dayState(row({ days }), 0)).toBe('present'); + }); + + it('fait primer l’absence sur le créneau resté au planning', () => { + // Le cas qui compte : le congé est validé après la construction de la + // semaine, les créneaux n'ont pas été retirés. Afficher « présent » + // enverrait un responsable chercher quelqu'un qui est chez lui. + const days: BoardRow['days'] = [[shift], [], [], [], [], [], []]; + const absences = [ + { label: 'Congé payé', colorKey: 'neutral', startDay: 0, span: 2 }, + ]; + expect(dayState(row({ days, absences }), 0)).toBe('absent'); + }); +}); + +describe('absenceOnDay', () => { + const absences = [ + { label: 'Congé payé', colorKey: 'neutral', startDay: 2, span: 3 }, + ]; + + it('couvre le premier et le dernier jour de la plage', () => { + // La borne haute est le dernier jour d'absence, pas le jour de reprise : + // `startDay + span` est donc exclu. + expect(absenceOnDay(row({ absences }), 2)?.label).toBe('Congé payé'); + expect(absenceOnDay(row({ absences }), 4)?.label).toBe('Congé payé'); + }); + + it('ne déborde ni avant ni après', () => { + expect(absenceOnDay(row({ absences }), 1)).toBeUndefined(); + expect(absenceOnDay(row({ absences }), 5)).toBeUndefined(); + }); +}); + +describe('headcountByDay', () => { + it('compte présents, absents et repos sur chaque colonne', () => { + const present = row({ + membershipId: 'a', + days: [[shift], [shift], [], [], [], [], []], + }); + const absent = row({ + membershipId: 'b', + absences: [ + { label: 'Maladie', colorKey: 'neutral', startDay: 0, span: 1 }, + ], + }); + + const tally = headcountByDay([present, absent], 7); + + expect(tally[0]).toEqual({ present: 1, absent: 1, off: 0 }); + expect(tally[1]).toEqual({ present: 1, absent: 0, off: 1 }); + expect(tally[6]).toEqual({ present: 0, absent: 0, off: 2 }); + }); + + it('écarte la ligne des besoins non couverts', () => { + // Elle ne porte personne : la compter en repos gonflerait un effectif qui + // n'existe pas. + const unassigned = row({ membershipId: null, unassigned: true }); + expect(headcountByDay([unassigned], 7)[0]).toEqual({ + present: 0, + absent: 0, + off: 0, + }); + }); +});