Compléter les vues de planning

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 12:30:02 +02:00
1 parent 98b289784a
commit e4be93a208
5 files changed
+665

No files matched your search

+219
View File
@@ -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 (
<li
className="print-keep rounded-2 border px-1.5 py-1 text-micro leading-tight"
style={
tokens
? {
background: tokens.bg,
color: tokens.fg,
borderColor: tokens.edge,
}
: undefined
}
>
<span className="block font-semibold tnum">{shift.time}</span>
<span className="block">{shift.poste.toUpperCase()}</span>
{shift.breakMinutes > 0 ? (
<span className="block tnum">pause {shift.breakMinutes} min</span>
) : null}
</li>
);
}
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 (
<PageBody>
<PageHeader
title="Impression"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
// 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 (
<PageBody>
<div data-print="hide">
<PageHeader
title={`Planning à afficher · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Link
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Retour à la grille
</Link>
<PrintButton label="Imprimer la semaine" />
</>
}
/>
</div>
<p className="text-sm text-ink-2">
{board.location.name} — semaine {week.isoWeek}, {board.label}
</p>
{hiddenDrafts > 0 && seeUnpublished ? (
<p
data-print="hide"
role="status"
className="rounded-2 border border-warn bg-warn-soft px-3 py-2 text-xs text-warn-soft-ink"
>
{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.
</p>
) : null}
{sections.length === 0 ? (
<Card>
<EmptyState
title="Rien à imprimer"
description="Aucune équipe publiée sur cette semaine."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId} className="print-keep">
<CardHeader
title={section.teamName}
badge={
section.status === 'PUBLISHED' ? (
<Badge tone="ok">Publié</Badge>
) : (
<Badge tone="warn">Brouillon</Badge>
)
}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[1040px] text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="w-48 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-2 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{[
...section.rows,
...(section.unassignedRow ? [section.unassignedRow] : []),
].map((row) => (
<tr key={row.membershipId ?? 'non-assignes'} className="print-keep">
<th className="px-3 py-2 text-left align-top font-medium">
{displayName(row)}
<span className="block text-micro font-normal text-ink-3">
{row.unassigned ? 'À pourvoir' : row.job}
</span>
</th>
{board.dates.map((date, index) => {
const absence = absenceOnDay(row, index);
const shifts = row.days[index] ?? [];
return (
<td key={date} className="px-2 py-2 align-top">
{absence ? (
<span className="block rounded-2 border border-line-2 bg-surface-2 px-1.5 py-1 text-micro">
{absence.label}
</span>
) : shifts.length === 0 ? (
<span className="text-micro text-ink-3">—</span>
) : (
<ul className="flex flex-col gap-1">
{shifts.map((shift) => (
<ShiftLine key={shift.id} shift={shift} />
))}
</ul>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</Card>
))}
<p className="text-micro text-ink-3">
É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.
</p>
</PageBody>
);
}
+259
View File
@@ -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<DayState, Tone> = {
present: 'ok',
absent: 'warn',
off: 'neutral',
};
const STATE_LABEL: Record<DayState, string> = {
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 (
<PageBody>
<PageHeader
title="Présences et absences"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
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 (
<PageBody>
<PageHeader
title={`Présences · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
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>
</>
}
/>
<Card>
<CardHeader title="Effectif présent par jour" />
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Jour
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
<tr>
<th className="px-4 py-2 text-left font-medium">Présents</th>
{perDay.map((day) => (
<td key={day.date} className="px-3 py-2 text-center tnum">
{day.present}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Absents</th>
{perDay.map((day) => (
<td
key={day.date}
className={cx(
'px-3 py-2 text-center tnum',
day.absent > 0 ? 'text-warn-soft-ink' : 'text-ink-3',
)}
>
{day.absent}
</td>
))}
</tr>
<tr>
<th className="px-4 py-2 text-left font-medium">Repos</th>
{perDay.map((day) => (
<td
key={day.date}
className="px-3 py-2 text-center tnum text-ink-3"
>
{day.off}
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
{sections.length === 0 ? (
<Card>
<EmptyState
title="Aucune équipe"
description="Créez une équipe et rattachez-y des salariés pour suivre les présences."
/>
</Card>
) : null}
{sections.map((section) => (
<Card key={section.teamId}>
<CardHeader
title={section.teamName}
badge={
<Badge
tone={section.status === 'PUBLISHED' ? 'ok' : 'warn'}
>
{section.status === 'PUBLISHED' ? 'Publié' : 'Non publié'}
</Badge>
}
/>
{section.rows.length === 0 ? (
<EmptyState title="Aucun salarié rattaché" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line-1">
<th className="px-4 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Salarié
</th>
{board.headings.map((heading) => (
<th
key={heading}
className="px-3 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-line-1">
{section.rows.map((row) => (
<tr key={row.membershipId ?? displayName(row)}>
<th className="px-4 py-2 text-left font-medium">
{row.membershipId ? (
<Link
href={`/equipe/${row.membershipId}`}
className="hover:underline"
>
{displayName(row)}
</Link>
) : (
displayName(row)
)}
<span className="block text-micro font-normal text-ink-3">
{row.job}
</span>
</th>
{board.dates.map((date, index) => {
const state = dayState(row, index);
const absence = absenceOnDay(row, index);
return (
<td key={date} className="px-3 py-2 text-center">
<Badge tone={STATE_TONE[state]}>
{absence ? absence.label : STATE_LABEL[state]}
</Badge>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
))}
<p className="max-w-[70ch] text-xs leading-[var(--lh-prose)] text-ink-3">
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.
</p>
</PageBody>
);
}
+6
View File
@@ -50,6 +50,12 @@ export const NAVIGATION: NavSection[] = [
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' }, { id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' }, { id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' }, { 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: 'besoins', label: 'Besoins non couverts' },
{ id: 'modeles', label: "Modèles d'ouverture" }, { id: 'modeles', label: "Modèles d'ouverture" },
], ],
+70
View File
@@ -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;
});
}
+111
View File
@@ -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> = {}): 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,
});
});
});