Files
planflow/src/app/(app)/planning/semaine/page.tsx
T
Claude d9f55f866f WP-04 (suite) : vues par poste et par mois, duplication, édition, impression
É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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-08 07:20:36 +00:00

166 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · semaine · PlanFlow' };
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
export default async function SemainePage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Semaine courante par défaut : ouvrir le planning sur une semaine arbitraire
// obligerait à naviguer avant de voir quoi que ce soit d'utile.
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning"
subtitle="Aucun établissement n'est encore créé."
/>
<p className="text-sm text-ink-2">
Créez un établissement dans{' '}
<Link href="/reglages/etablissements" className="underline">
Réglages · Établissements
</Link>{' '}
pour commencer à planifier.
</p>
</PageBody>
);
}
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}`;
return (
<PageBody>
<PageHeader
title={`Planning · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()} · ${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>
<PrintButton label="Imprimer la semaine" />
</>
}
/>
<div className="flex flex-wrap items-center gap-2" data-print="hide">
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.weekParam, location.id)}
aria-current={
location.id === board.location.id ? 'page' : undefined
}
className={
location.id === board.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
{board.totals.unassigned > 0 ? (
<Badge tone="warn">
{board.totals.unassigned} besoin
{board.totals.unassigned > 1 ? 's' : ''} non couvert
{board.totals.unassigned > 1 ? 's' : ''}
</Badge>
) : null}
</div>
{board.sections.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Cet établissement n’a pas encore d’équipe.
</p>
) : null}
{board.sections.map((section) => (
<TeamSection
key={section.teamId}
section={section}
days={board.headings}
dates={board.dates}
labels={board.labels}
weekParam={board.weekParam}
previousParam={board.previousParam}
canEdit={canEdit}
canPublish={canPublish}
canDuplicate={canDuplicate}
/>
))}
<PosteLegend />
</PageBody>
);
}
/**
* Légende des postes.
*
* Elle n'est pas décorative : la couleur seule ne dit pas quel poste est
* lequel, et le code à trois lettres imprimé dans chaque bloc n'a de sens que
* si sa correspondance est visible sur la même page.
*/
function PosteLegend() {
return (
<section className="rounded-3 border border-line-1 bg-surface p-4">
<h2 className="mb-3 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
Postes
</h2>
<ul className="flex flex-wrap gap-2">
{POSTE_CODES.map((code) => {
const tokens = posteTokens(code);
return (
<li
key={code}
className="flex items-center gap-1.5 rounded-2 px-2 py-1 text-micro"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
>
<span className="font-semibold tracking-wide">
{code.toUpperCase()}
</span>
<span>{POSTE_LABELS[code]}</span>
</li>
);
})}
</ul>
</section>
);
}