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, + }); + }); +});