É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
166 lines
5.6 KiB
TypeScript
166 lines
5.6 KiB
TypeScript
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>
|
||
);
|
||
}
|