Le panneau créait un créneau, pour un jour. Or un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne varie : ressaisir cinq fois « 9 h–17 h, pause 30 » était le geste le plus répété de la semaine, et le plus exposé à la faute de frappe. Les sept jours sont désormais cochables. Le jour ouvert est coché et verrouillé — on ne crée pas ailleurs qu'ici depuis la case d'un jour donné — et voyage dans un champ caché, puisqu'une case désactivée n'est pas envoyée. La création est **tout ou rien**. Un jour refusé, pour période close ou chevauchement, annule les autres : une répétition à demi appliquée laisserait un planning que personne n'a voulu, et qu'il faudrait défaire à la main pour le refaire. Le contrôle de période porte donc sur tous les jours demandés avant la première écriture. Trois champs du modèle étaient inatteignables depuis l'écran : le nombre de repas, la note du créneau, et l'étiquette en modification. Cette dernière était un vrai défaut — une étiquette posée de travers se corrigeait en supprimant le créneau pour le refaire, ce qui perdait sa validation et son historique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { useActionState, useEffect, useState } from 'react';
|
||
|
||
import { AlertPanel } from '@/components/planning/AlertPanel';
|
||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||
import { Badge } from '@/components/ui/Badge';
|
||
import { Button } from '@/components/ui/Button';
|
||
import { PersistentForm } from '@/components/ui/PersistentForm';
|
||
import { cx } from '@/lib/cx';
|
||
import { formatMinutes } from '@/domain/counters/week';
|
||
import {
|
||
displayName,
|
||
type BoardRow,
|
||
type BoardShift,
|
||
} from '@/domain/planning/board';
|
||
import {
|
||
createShiftAction,
|
||
deleteShiftAction,
|
||
duplicateWeekAction,
|
||
setWeekPublicationAction,
|
||
updateShiftAction,
|
||
type PlanningActionState,
|
||
} from '@/server/planning/actions';
|
||
import {
|
||
PUBLICATION_INTENT_FIELD,
|
||
PUBLISH_INTENT,
|
||
UNPUBLISH_INTENT,
|
||
} from '@/domain/planning/publication';
|
||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||
|
||
export interface TeamSectionProps {
|
||
section: BoardSection;
|
||
days: string[];
|
||
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.
|
||
*
|
||
* 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,
|
||
previousParam,
|
||
canEdit,
|
||
canPublish,
|
||
canDuplicate,
|
||
}: TeamSectionProps) {
|
||
const [target, setTarget] = useState<Target | null>(null);
|
||
|
||
const names = new Map(
|
||
section.rows
|
||
.filter((row) => row.membershipId)
|
||
.map((row) => [row.membershipId as string, displayName(row)]),
|
||
);
|
||
const flagged = new Set(section.flaggedShiftIds);
|
||
const pendingWarnings = section.alerts.filter(
|
||
(alert) => alert.severity === 'WARNING' && !alert.acknowledged,
|
||
).length;
|
||
|
||
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
|
||
// « 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>
|
||
|
||
<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}
|
||
pendingWarnings={pendingWarnings}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
|
||
<WeekGrid
|
||
days={days}
|
||
dates={dates}
|
||
rows={section.rows}
|
||
unassignedRow={section.unassignedRow}
|
||
flaggedShiftIds={flagged}
|
||
{...(canEdit
|
||
? {
|
||
cellAction: (row: BoardRow, dayIndex: number) => (
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
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 print:hidden"
|
||
>
|
||
+ Créneau
|
||
<span className="sr-only">
|
||
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
|
||
</span>
|
||
</button>
|
||
),
|
||
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>
|
||
),
|
||
}
|
||
: {})}
|
||
/>
|
||
|
||
<AlertPanel alerts={section.alerts} names={names} />
|
||
|
||
{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] ?? ''}
|
||
dates={dates}
|
||
days={days}
|
||
target={target}
|
||
onClose={() => setTarget(null)}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function PublishControl({
|
||
teamId,
|
||
weekParam,
|
||
version,
|
||
published,
|
||
pendingWarnings,
|
||
}: {
|
||
teamId: string;
|
||
weekParam: string;
|
||
version: number;
|
||
published: boolean;
|
||
pendingWarnings: number;
|
||
}) {
|
||
const [state, formAction, pending] = useActionState(
|
||
setWeekPublicationAction,
|
||
empty,
|
||
);
|
||
|
||
return (
|
||
<PersistentForm
|
||
action={formAction}
|
||
className="flex flex-wrap 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} />
|
||
|
||
{/* Le motif n'apparaît que s'il y a quelque chose à assumer : demander
|
||
une justification quand tout est conforme apprendrait à la remplir
|
||
machinalement. */}
|
||
{!published && pendingWarnings > 0 ? (
|
||
<label className="flex items-center gap-1.5 text-xs text-ink-2">
|
||
<span className="sr-only">Motif de publication malgré les alertes</span>
|
||
<input
|
||
name="acknowledgement"
|
||
type="text"
|
||
maxLength={500}
|
||
placeholder={`Motif — ${pendingWarnings} alerte${pendingWarnings > 1 ? 's' : ''}`}
|
||
className="h-8 w-56 rounded-2 border border-warn bg-surface px-2 text-sm text-ink-1"
|
||
/>
|
||
</label>
|
||
) : null}
|
||
|
||
{state.error ? (
|
||
<span role="alert" className="text-xs text-danger">
|
||
{state.error}
|
||
</span>
|
||
) : null}
|
||
{/* L'intention voyage sur le bouton, et non dans un champ caché : la
|
||
réinitialisation que React applique après chaque action remettrait un
|
||
champ caché à sa valeur d'origine, jamais un bouton. */}
|
||
<Button
|
||
type="submit"
|
||
name={PUBLICATION_INTENT_FIELD}
|
||
value={published ? UNPUBLISH_INTENT : PUBLISH_INTENT}
|
||
size="sm"
|
||
variant={published ? 'secondary' : 'primary'}
|
||
disabled={pending}
|
||
>
|
||
{published ? 'Dépublier' : 'Publier'}
|
||
</Button>
|
||
</PersistentForm>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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}>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
labels,
|
||
rows,
|
||
localDate,
|
||
dayLabel,
|
||
dates,
|
||
days,
|
||
target,
|
||
onClose,
|
||
}: {
|
||
teamId: string;
|
||
weekParam: string;
|
||
labels: BoardLabel[];
|
||
rows: BoardRow[];
|
||
localDate: string;
|
||
dayLabel: string;
|
||
dates: string[];
|
||
days: string[];
|
||
target: Target;
|
||
onClose: () => void;
|
||
}) {
|
||
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.
|
||
useEffect(() => {
|
||
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 print:hidden"
|
||
>
|
||
{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">
|
||
<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={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>
|
||
{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={start}
|
||
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={end}
|
||
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={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="Repas" htmlFor="composer-meals">
|
||
<input
|
||
id="composer-meals"
|
||
name="mealCount"
|
||
type="number"
|
||
min={0}
|
||
max={5}
|
||
defaultValue={editing ? target.shift.mealCount : 0}
|
||
className="h-8 w-16 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||
/>
|
||
</Field>
|
||
|
||
{/* L'étiquette est modifiable à la correction aussi : posée de travers,
|
||
elle se corrigeait en supprimant le créneau pour le refaire — ce qui
|
||
perdait sa validation et son historique. */}
|
||
<Field label="Poste" htmlFor="composer-label">
|
||
<select
|
||
id="composer-label"
|
||
name="labelId"
|
||
defaultValue={editing ? (target.shift.labelId ?? '') : ''}
|
||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||
>
|
||
<option value="">Aucune étiquette</option>
|
||
{labels.map((label) => (
|
||
<option key={label.id} value={label.id}>
|
||
{label.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</Field>
|
||
|
||
<Field label="Notes" htmlFor="composer-note">
|
||
<input
|
||
id="composer-note"
|
||
name="note"
|
||
type="text"
|
||
maxLength={500}
|
||
defaultValue={editing ? (target.shift.note ?? '') : ''}
|
||
placeholder="Consigne du jour"
|
||
className="h-8 w-56 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||
/>
|
||
</Field>
|
||
|
||
{editing ? null : (
|
||
<RepeatDays dates={dates} days={days} selected={localDate} />
|
||
)}
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||
{editing ? 'Enregistrer' : '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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Répétition du créneau sur plusieurs jours.
|
||
*
|
||
* Le jour ouvert est coché et **verrouillé** : on ne crée pas « ailleurs qu'ici
|
||
* » depuis la case d'un jour donné, et le décocher laisserait un formulaire qui
|
||
* ne produit rien tout en paraissant valide.
|
||
*
|
||
* La création est tout ou rien côté serveur : un jour refusé — période close,
|
||
* chevauchement — annule les autres. Une répétition à demi appliquée laisserait
|
||
* un planning que personne n'a voulu.
|
||
*/
|
||
function RepeatDays({
|
||
dates,
|
||
days,
|
||
selected,
|
||
}: {
|
||
dates: string[];
|
||
days: string[];
|
||
selected: string;
|
||
}) {
|
||
return (
|
||
<fieldset className="flex flex-col gap-1">
|
||
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||
Répéter sur
|
||
</legend>
|
||
<div className="flex flex-wrap gap-1">
|
||
{dates.map((date, index) => {
|
||
const locked = date === selected;
|
||
return (
|
||
<label
|
||
key={date}
|
||
className={cx(
|
||
'flex cursor-pointer items-center gap-1 rounded-2 border px-2 py-1 text-micro',
|
||
locked
|
||
? 'border-accent bg-accent-soft text-accent-soft-ink'
|
||
: 'border-line-2 bg-surface text-ink-2 hover:bg-surface-2',
|
||
)}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
name="localDates"
|
||
value={date}
|
||
defaultChecked={locked}
|
||
disabled={locked}
|
||
className="size-3"
|
||
/>
|
||
{/* Un champ désactivé n'est pas envoyé : le jour ouvert voyage
|
||
donc dans un champ caché, sans quoi cocher zéro case
|
||
produirait une création vide. */}
|
||
{locked ? (
|
||
<input type="hidden" name="localDates" value={date} />
|
||
) : null}
|
||
{(days[index] ?? date).slice(0, 3)}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|