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
This commit is contained in:
15 files changed
+1377
-56
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
|
||||
import { posteShort, posteTokens } from '@/lib/design/postes';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getLabelBoard } from '@/server/planning/views';
|
||||
|
||||
export const metadata = { title: 'Planning · étiquettes · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function EtiquettesPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
|
||||
const board = await getLabelBoard(week, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · par poste"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (semaine: string, etablissement = board.location.id) =>
|
||||
`/planning/etiquettes?semaine=${semaine}&etablissement=${etablissement}`;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Planning · par poste · semaine ${week.isoWeek}`}
|
||||
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()}`}
|
||||
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 />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<Link
|
||||
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
|
||||
className="rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2"
|
||||
>
|
||||
Voir par salarié →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
|
||||
<div className="w-56 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
Poste
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{board.headings.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
|
||||
index > 4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{board.rows.map((row) => {
|
||||
const tokens = posteTokens(row.poste);
|
||||
return (
|
||||
<div
|
||||
key={row.labelId}
|
||||
className="flex border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<div className="flex w-56 flex-none items-center gap-2 border-r border-line-1 px-3 py-2">
|
||||
<span
|
||||
className="flex-none rounded-1 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
border: `1px solid ${tokens.edge}`,
|
||||
}}
|
||||
>
|
||||
{posteShort(row.poste)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-ink-1">
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="tnum block text-micro text-ink-3">
|
||||
{formatMinutes(row.minutes)} sur la semaine
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{board.headings.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{(row.days[index] ?? []).map((entry) => (
|
||||
<span
|
||||
key={entry.id}
|
||||
className={cx(
|
||||
'flex min-w-0 flex-col rounded-2 px-1.5 py-1 text-micro',
|
||||
entry.unassigned &&
|
||||
'border border-dashed border-line-3 text-ink-3',
|
||||
)}
|
||||
style={
|
||||
entry.unassigned
|
||||
? undefined
|
||||
: {
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
}
|
||||
}
|
||||
>
|
||||
<span className="tnum">{entry.time}</span>
|
||||
<span className="truncate opacity-90">
|
||||
{entry.who}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DayTimeline } from '@/components/planning/DayTimeline';
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { getDayBoard } from '@/server/planning/queries';
|
||||
@@ -57,6 +58,7 @@ export default async function JourPage({ searchParams }: PageProps) {
|
||||
>
|
||||
Jour suivant →
|
||||
</Link>
|
||||
<PrintButton label="Imprimer la journée" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { PrintButton } from '@/components/planning/PrintButton';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { monthOf, parseMonthParam } from '@/domain/planning/month';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { getMonthBoard } from '@/server/planning/views';
|
||||
|
||||
export const metadata = { title: 'Planning · mois · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ mois?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
/** Heures d'une journée, en compact : « 7,5 » plutôt que « 7 h 30 ». */
|
||||
function compact(minutes: number): string {
|
||||
if (minutes === 0) return '';
|
||||
const hours = minutes / 60;
|
||||
return Number.isInteger(hours)
|
||||
? String(hours)
|
||||
: hours.toFixed(1).replace('.', ',');
|
||||
}
|
||||
|
||||
export default async function MoisPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
|
||||
const board = await getMonthBoard(month, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · mois"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (mois: string, etablissement = board.location.id) =>
|
||||
`/planning/mois?mois=${mois}&etablissement=${etablissement}`;
|
||||
const total = board.rows.reduce((sum, row) => sum + row.totalMinutes, 0);
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title={`Planning · ${board.label}`}
|
||||
subtitle={`${board.location.name} · ${formatMinutes(total)} planifiées sur le mois`}
|
||||
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"
|
||||
>
|
||||
← Mois précédent
|
||||
</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"
|
||||
>
|
||||
Mois suivant →
|
||||
</Link>
|
||||
<PrintButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2" data-print="hide">
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(board.monthParam, 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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<caption className="sr-only">
|
||||
Heures planifiées par salarié et par jour, {board.label}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-line-2 bg-surface-2">
|
||||
<th
|
||||
scope="col"
|
||||
className="sticky left-0 z-10 min-w-44 bg-surface-2 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</th>
|
||||
{board.days.map((day) => (
|
||||
<th
|
||||
key={day.isoDate}
|
||||
scope="col"
|
||||
className={cx(
|
||||
'tnum w-8 px-1 py-2 text-center font-semibold',
|
||||
day.weekend ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{day.day}
|
||||
</th>
|
||||
))}
|
||||
<th
|
||||
scope="col"
|
||||
className="px-3 py-2 text-right text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Total
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{board.rows.map((row) => (
|
||||
<tr
|
||||
key={row.membershipId}
|
||||
className="border-b border-line-1 last:border-b-0"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
|
||||
>
|
||||
{row.name}
|
||||
<span className="block text-micro font-normal text-ink-3">
|
||||
{row.workedDays} jour{row.workedDays > 1 ? 's' : ''}{' '}
|
||||
travaillé{row.workedDays > 1 ? 's' : ''}
|
||||
</span>
|
||||
</th>
|
||||
{row.minutes.map((minutes, index) => {
|
||||
const day = board.days[index];
|
||||
return (
|
||||
<td
|
||||
key={day?.isoDate ?? index}
|
||||
className={cx(
|
||||
'tnum border-l border-line-1 px-1 py-1.5 text-center',
|
||||
day?.weekend && 'bg-surface-2',
|
||||
// Au-delà de 10 h, la journée mérite un regard : c'est
|
||||
// la borne légale au-dessus de laquelle une dérogation
|
||||
// devient nécessaire (le contrôle formel arrive avec le
|
||||
// moteur de règles).
|
||||
minutes > 600 && 'bg-warn-soft text-warn-soft-ink',
|
||||
)}
|
||||
title={
|
||||
minutes > 0
|
||||
? `${formatMinutes(minutes)} le ${day?.day ?? ''}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{compact(minutes)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="tnum border-l border-line-2 px-3 py-1.5 text-right font-semibold text-ink-1">
|
||||
{formatMinutes(row.totalMinutes)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{board.rows.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Aucun salarié rattaché à une équipe de cet établissement.
|
||||
</p>
|
||||
) : null}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -45,6 +46,7 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
|
||||
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}`;
|
||||
|
||||
@@ -67,11 +69,12 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
>
|
||||
Semaine suivante →
|
||||
</Link>
|
||||
<PrintButton label="Imprimer la semaine" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2" data-print="hide">
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
@@ -111,8 +114,10 @@ export default async function SemainePage({ searchParams }: PageProps) {
|
||||
dates={board.dates}
|
||||
labels={board.labels}
|
||||
weekParam={board.weekParam}
|
||||
previousParam={board.previousParam}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
canDuplicate={canDuplicate}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -365,3 +365,61 @@ body {
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Impression
|
||||
---------------------------------------------------------------------------
|
||||
Un planning affiché reste affiché ; un planning imprimé est le document que
|
||||
l'équipe lit dans la réserve. Deux exigences en découlent :
|
||||
|
||||
- la couleur ne doit pas être nécessaire (les codes de poste sont déjà
|
||||
imprimés dans chaque bloc), mais les aplats doivent sortir quand même,
|
||||
d'où `print-color-adjust: exact` ;
|
||||
- rien ne doit être coupé : la grille tient en paysage et les lignes ne se
|
||||
scindent pas entre deux pages.
|
||||
*/
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* L'impression part toujours du thème clair : un aplat sombre consomme
|
||||
l'encre sans rien apporter. */
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
* {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
/* Les zones à défilement horizontal doivent se déplier, sinon la fin de la
|
||||
semaine n'est tout simplement pas sur la feuille. */
|
||||
.overflow-x-auto {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.min-w-\[1040px\] {
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
[data-print='hide'],
|
||||
nav,
|
||||
aside {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
section,
|
||||
tr,
|
||||
.print-keep {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user