'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(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.teamName}

Le planning de cette équipe n’est pas encore publié.

); } return (

{section.teamName}

{published ? 'Publiée' : 'Brouillon'} {formatMinutes(plannedMinutes)} planifiées
{canDuplicate && isEmpty ? ( ) : null} {canPublish ? ( ) : null}
( ), shiftAction: (shift, row, dayIndex) => (
), } : {})} /> {target ? ( setTarget(null)} /> ) : null}
); } 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 ( {/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */} {/* 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 ? ( ) : null} {state.error ? ( {state.error} ) : 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. */} ); } /** * 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 (
{state.error ? ( {state.error} ) : null}
); } function DeleteShift({ shiftId }: { shiftId: string }) { const [state, formAction, pending] = useActionState(deleteShiftAction, empty); return (
{/* 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. */}
); } /** * 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 (
{editing ? ( ) : ( <> )} {dayLabel} {/* 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. */} {editing ? null : ( )}
{state.error ? (

{state.error}

) : null} ); } /** * 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 (
Répéter sur
{dates.map((date, index) => { const locked = date === selected; return ( ); })}
); } function Field({ label, htmlFor, children, }: { label: string; htmlFor?: string; children: React.ReactNode; }) { return (
{children}
); }