WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
|
||||
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 {
|
||||
createShiftAction,
|
||||
deleteShiftAction,
|
||||
publishWeekAction,
|
||||
unpublishWeekAction,
|
||||
type PlanningActionState,
|
||||
} from '@/server/planning/actions';
|
||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||
|
||||
export interface TeamSectionProps {
|
||||
section: BoardSection;
|
||||
days: string[];
|
||||
dates: string[];
|
||||
labels: BoardLabel[];
|
||||
weekParam: string;
|
||||
/** Faux quand la capacité de modification manque : la grille reste lisible. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
}
|
||||
|
||||
const empty: PlanningActionState = {};
|
||||
|
||||
/**
|
||||
* Une équipe, une grille, un état de publication.
|
||||
*
|
||||
* La publication est **par équipe** : l'audit montre un bouton « Dépublier »
|
||||
* sur chaque section, pas un pour l'établissement. Le regrouper ferait
|
||||
* republier la caisse quand on corrige la réserve.
|
||||
*/
|
||||
export function TeamSection({
|
||||
section,
|
||||
days,
|
||||
dates,
|
||||
labels,
|
||||
weekParam,
|
||||
canEdit,
|
||||
canPublish,
|
||||
}: TeamSectionProps) {
|
||||
const [target, setTarget] = useState<{
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const published = section.status === 'PUBLISHED';
|
||||
const plannedMinutes = section.rows.reduce(
|
||||
(total, row) => total + row.counters.plannedMinutes,
|
||||
0,
|
||||
);
|
||||
|
||||
if (section.hidden) {
|
||||
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
|
||||
// « pas encore publié » est honnête et n'apprend rien de son contenu.
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Le planning de cette équipe n’est pas encore publié.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<Badge tone={published ? 'ok' : 'warn'}>
|
||||
{published ? 'Publiée' : 'Brouillon'}
|
||||
</Badge>
|
||||
<span className="text-xs text-ink-3">
|
||||
{formatMinutes(plannedMinutes)} planifiées
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<WeekGrid
|
||||
days={days}
|
||||
dates={dates}
|
||||
rows={section.rows}
|
||||
unassignedRow={section.unassignedRow}
|
||||
{...(canEdit
|
||||
? {
|
||||
cellAction: (row: BoardRow, dayIndex: number) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTarget({ 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"
|
||||
>
|
||||
+ Créneau
|
||||
<span className="sr-only">
|
||||
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
shiftAction: (shiftId: string) => (
|
||||
<DeleteShift shiftId={shiftId} />
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{target ? (
|
||||
<ShiftComposer
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
labels={labels}
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
membershipId={target.membershipId}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishControl({
|
||||
teamId,
|
||||
weekParam,
|
||||
version,
|
||||
published,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
version: number;
|
||||
published: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
published ? unpublishWeekAction : publishWeekAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
|
||||
<input type="hidden" name="expectedVersion" value={version} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={published ? 'secondary' : 'primary'}
|
||||
disabled={pending}
|
||||
>
|
||||
{published ? 'Dépublier' : 'Publier'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="absolute -top-1 -right-1">
|
||||
<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é
|
||||
transitoire ne protège de rien tout en rendant la cible instable. */}
|
||||
<button
|
||||
type="submit"
|
||||
aria-busy={pending}
|
||||
title={state.error ?? 'Supprimer le créneau'}
|
||||
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-danger hover:text-danger focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
<span className="sr-only">Supprimer le créneau</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftComposer({
|
||||
teamId,
|
||||
weekParam,
|
||||
labels,
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
membershipId,
|
||||
onClose,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
labels: BoardLabel[];
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
membershipId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(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.
|
||||
useEffect(() => {
|
||||
if (state.ok) onClose();
|
||||
}, [state.ok, onClose]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
<input type="hidden" name="localDate" value={localDate} />
|
||||
|
||||
<Field label="Jour">
|
||||
<span className="flex h-8 items-center px-1 text-sm text-ink-2">
|
||||
{dayLabel}
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
<Field label="Salarié" htmlFor="composer-membership">
|
||||
<select
|
||||
id="composer-membership"
|
||||
name="membershipId"
|
||||
defaultValue={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>
|
||||
{rows.map((row) => (
|
||||
<option key={row.membershipId} value={row.membershipId ?? ''}>
|
||||
{displayName(row)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Début" htmlFor="composer-start">
|
||||
<input
|
||||
id="composer-start"
|
||||
name="start"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Fin" htmlFor="composer-end">
|
||||
<input
|
||||
id="composer-end"
|
||||
name="end"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="17:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pause (min)" htmlFor="composer-break">
|
||||
<input
|
||||
id="composer-break"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={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>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Ajouter
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user