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 (
+
+ É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 →
+
+ >
+ }
+ />
+
+
+
+
+ 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,
+ });
+ });
+});