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,19 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
/**
|
||||
* Impression du planning.
|
||||
*
|
||||
* Pas de génération PDF côté serveur : le navigateur sait déjà paginer, et sa
|
||||
* boîte d'impression permet d'enregistrer en PDF. Un moteur de rendu de plus
|
||||
* serait un second endroit où la grille peut diverger de ce qui est à l'écran.
|
||||
* La mise en page d'impression vit dans `globals.css`.
|
||||
*/
|
||||
export function PrintButton({ label = 'Imprimer' }: { label?: string }) {
|
||||
return (
|
||||
<Button type="button" onClick={() => window.print()}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,18 @@ import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName, type BoardRow } from '@/domain/planning/board';
|
||||
import {
|
||||
displayName,
|
||||
type BoardRow,
|
||||
type BoardShift,
|
||||
} from '@/domain/planning/board';
|
||||
import {
|
||||
createShiftAction,
|
||||
deleteShiftAction,
|
||||
duplicateWeekAction,
|
||||
publishWeekAction,
|
||||
unpublishWeekAction,
|
||||
updateShiftAction,
|
||||
type PlanningActionState,
|
||||
} from '@/server/planning/actions';
|
||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||
@@ -22,13 +28,26 @@ export interface TeamSectionProps {
|
||||
dates: string[];
|
||||
labels: BoardLabel[];
|
||||
weekParam: string;
|
||||
/** Semaine précédente : proposition par défaut de la duplication. */
|
||||
previousParam: string;
|
||||
/** Faux quand la capacité de modification manque : la grille reste lisible. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
canDuplicate: boolean;
|
||||
}
|
||||
|
||||
const empty: PlanningActionState = {};
|
||||
|
||||
/** Cible du panneau d'édition : création dans une case, ou créneau existant. */
|
||||
type Target =
|
||||
| { kind: 'create'; membershipId: string | null; dayIndex: number }
|
||||
| {
|
||||
kind: 'edit';
|
||||
shift: BoardShift;
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Une équipe, une grille, un état de publication.
|
||||
*
|
||||
@@ -42,19 +61,21 @@ export function TeamSection({
|
||||
dates,
|
||||
labels,
|
||||
weekParam,
|
||||
previousParam,
|
||||
canEdit,
|
||||
canPublish,
|
||||
canDuplicate,
|
||||
}: TeamSectionProps) {
|
||||
const [target, setTarget] = useState<{
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
} | null>(null);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
|
||||
const published = section.status === 'PUBLISHED';
|
||||
const plannedMinutes = section.rows.reduce(
|
||||
(total, row) => total + row.counters.plannedMinutes,
|
||||
0,
|
||||
);
|
||||
const isEmpty =
|
||||
section.rows.every((row) => row.days.flat().length === 0) &&
|
||||
!section.unassignedRow;
|
||||
|
||||
if (section.hidden) {
|
||||
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
|
||||
@@ -86,14 +107,23 @@ export function TeamSection({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 print:hidden">
|
||||
{canDuplicate && isEmpty ? (
|
||||
<DuplicateControl
|
||||
teamId={section.teamId}
|
||||
source={previousParam}
|
||||
target={weekParam}
|
||||
/>
|
||||
) : null}
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<WeekGrid
|
||||
@@ -107,9 +137,13 @@ export function TeamSection({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTarget({ membershipId: row.membershipId, dayIndex })
|
||||
setTarget({
|
||||
kind: 'create',
|
||||
membershipId: row.membershipId,
|
||||
dayIndex,
|
||||
})
|
||||
}
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100 print:hidden"
|
||||
>
|
||||
+ Créneau
|
||||
<span className="sr-only">
|
||||
@@ -117,8 +151,26 @@ export function TeamSection({
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
shiftAction: (shiftId: string) => (
|
||||
<DeleteShift shiftId={shiftId} />
|
||||
shiftAction: (shift, row, dayIndex) => (
|
||||
<div className="absolute -top-1 -right-1 flex gap-0.5 print:hidden">
|
||||
<button
|
||||
type="button"
|
||||
title="Modifier le créneau"
|
||||
onClick={() =>
|
||||
setTarget({
|
||||
kind: 'edit',
|
||||
shift,
|
||||
membershipId: row.membershipId,
|
||||
dayIndex,
|
||||
})
|
||||
}
|
||||
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
<span aria-hidden>✎</span>
|
||||
<span className="sr-only">Modifier le créneau</span>
|
||||
</button>
|
||||
<DeleteShift shiftId={shift.id} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
@@ -126,13 +178,14 @@ export function TeamSection({
|
||||
|
||||
{target ? (
|
||||
<ShiftComposer
|
||||
key={target.kind === 'edit' ? target.shift.id : 'create'}
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
labels={labels}
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
membershipId={target.membershipId}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -179,11 +232,46 @@ function PublishControl({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proposé seulement sur une semaine vide : dupliquer par-dessus un travail
|
||||
* commencé le détruirait, et l'action refuse de toute façon côté serveur.
|
||||
*/
|
||||
function DuplicateControl({
|
||||
teamId,
|
||||
source,
|
||||
target,
|
||||
}: {
|
||||
teamId: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
duplicateWeekAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="source" value={source} />
|
||||
<input type="hidden" name="target" value={target} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
Copier la semaine précédente
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="absolute -top-1 -right-1">
|
||||
<form action={formAction}>
|
||||
<input type="hidden" name="shiftId" value={shiftId} />
|
||||
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
|
||||
disparaître le créneau, donc le bouton, et un état désactivé
|
||||
@@ -201,6 +289,13 @@ function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panneau d'édition d'un créneau, en création comme en modification.
|
||||
*
|
||||
* Un seul formulaire pour les deux : 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.
|
||||
*/
|
||||
function ShiftComposer({
|
||||
teamId,
|
||||
weekParam,
|
||||
@@ -208,7 +303,7 @@ function ShiftComposer({
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
membershipId,
|
||||
target,
|
||||
onClose,
|
||||
}: {
|
||||
teamId: string;
|
||||
@@ -217,10 +312,14 @@ function ShiftComposer({
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
membershipId: string | null;
|
||||
target: Target;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(createShiftAction, empty);
|
||||
const editing = target.kind === 'edit';
|
||||
const [state, formAction, pending] = useActionState(
|
||||
editing ? updateShiftAction : createShiftAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
|
||||
// reposter le même créneau par inadvertance.
|
||||
@@ -228,13 +327,23 @@ function ShiftComposer({
|
||||
if (state.ok) onClose();
|
||||
}, [state.ok, onClose]);
|
||||
|
||||
const [start, end] = editing
|
||||
? target.shift.time.split('–')
|
||||
: ['09:00', '17:00'];
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3 print:hidden"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
{editing ? (
|
||||
<input type="hidden" name="shiftId" value={target.shift.id} />
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
</>
|
||||
)}
|
||||
<input type="hidden" name="localDate" value={localDate} />
|
||||
|
||||
<Field label="Jour">
|
||||
@@ -247,7 +356,7 @@ function ShiftComposer({
|
||||
<select
|
||||
id="composer-membership"
|
||||
name="membershipId"
|
||||
defaultValue={membershipId ?? ''}
|
||||
defaultValue={target.membershipId ?? ''}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
<option value="">Besoin non couvert</option>
|
||||
@@ -265,7 +374,7 @@ function ShiftComposer({
|
||||
name="start"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
defaultValue={start}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
@@ -276,7 +385,7 @@ function ShiftComposer({
|
||||
name="end"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="17:00"
|
||||
defaultValue={end}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
@@ -289,28 +398,30 @@ function ShiftComposer({
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={0}
|
||||
defaultValue={editing ? target.shift.breakMinutes : 0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{editing ? null : (
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Ajouter
|
||||
{editing ? 'Enregistrer' : 'Ajouter'}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onClose}>
|
||||
Annuler
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
displayName,
|
||||
initialsOf,
|
||||
type BoardRow,
|
||||
type BoardShift,
|
||||
} from '@/domain/planning/board';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
@@ -16,7 +17,11 @@ export interface WeekGridProps {
|
||||
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
/** Rendu attaché à un créneau : suppression, édition. */
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
shiftAction?: (
|
||||
shift: BoardShift,
|
||||
row: BoardRow,
|
||||
dayIndex: number,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,7 +42,10 @@ export function WeekGrid({
|
||||
}: WeekGridProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
{/* Rôles ARIA explicites : la grille est faite de div pour la mise en
|
||||
page, mais elle se lit comme un tableau — un lecteur d'écran doit
|
||||
pouvoir annoncer « ligne Camille Ferrand, colonne mercredi ». */}
|
||||
<div role="grid" aria-label="Planning de la semaine" className="min-w-[1040px]">
|
||||
<GridHeader days={days} />
|
||||
{unassignedRow ? (
|
||||
<Row
|
||||
@@ -70,13 +78,20 @@ export function WeekGrid({
|
||||
|
||||
function GridHeader({ days }: { days: readonly string[] }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
|
||||
<div className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
|
||||
<div
|
||||
role="row"
|
||||
className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2"
|
||||
>
|
||||
<div
|
||||
role="columnheader"
|
||||
className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
|
||||
>
|
||||
Salarié
|
||||
</div>
|
||||
<div className="grid flex-1 grid-cols-7">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
role="columnheader"
|
||||
key={day}
|
||||
className={cx(
|
||||
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
|
||||
@@ -102,19 +117,25 @@ function Row({
|
||||
days: readonly string[];
|
||||
dates: readonly string[];
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
shiftAction?: (
|
||||
shift: BoardShift,
|
||||
row: BoardRow,
|
||||
dayIndex: number,
|
||||
) => React.ReactNode;
|
||||
}) {
|
||||
const counters = computeWeekCounters(row.counters);
|
||||
const name = row.unassigned ? 'Non assigné' : displayName(row);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
data-membership={row.membershipId ?? 'unassigned'}
|
||||
className={cx(
|
||||
'flex border-b border-line-1 last:border-b-0',
|
||||
row.unassigned && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<div className="w-64 flex-none border-r border-line-1 px-3 py-2">
|
||||
<div role="rowheader" className="w-64 flex-none border-r border-line-1 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.unassigned ? (
|
||||
<span
|
||||
@@ -158,6 +179,7 @@ function Row({
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
<div
|
||||
role="gridcell"
|
||||
key={dates[index] ?? day}
|
||||
className={cx(
|
||||
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
@@ -174,7 +196,7 @@ function Row({
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shiftAction?.(shift.id)}
|
||||
{shiftAction?.(shift, row, index)}
|
||||
</div>
|
||||
))}
|
||||
{cellAction?.(row, index)}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AppShell({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
||||
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
|
||||
|
||||
@@ -40,6 +40,8 @@ export const NAVIGATION: NavSection[] = [
|
||||
items: [
|
||||
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
|
||||
{ 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: 'besoins', label: 'Besoins non couverts', badge: '5' },
|
||||
{ id: 'modeles', label: "Modèles d'ouverture" },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user