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:
Claude committed 2026-08-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

+82 -25
View File
@@ -1,48 +1,105 @@
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
import Link from 'next/link';
import { DayTimeline } from '@/components/planning/DayTimeline';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine';
import { getDayBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
export const metadata = { title: 'Planning · jour · PlanFlow' };
/** Mercredi = index 2 dans la semaine de démonstration. */
const DAY_INDEX = 2;
interface PageProps {
searchParams: Promise<{ jour?: string; etablissement?: string }>;
}
export default function JourPage() {
const lanes: DayLane[] = WEEK_ROWS.map((row) => ({
employee: row.employee,
shifts: row.days[DAY_INDEX] ?? [],
})).filter((lane) => lane.shifts.length > 0);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
if (unassigned.length > 0) {
lanes.push({
employee: UNASSIGNED_ROW.employee,
shifts: unassigned,
unassigned: true,
});
export default async function JourPage({ searchParams }: PageProps) {
const params = await searchParams;
const isoDate =
params.jour && ISO_DATE.test(params.jour)
? params.jour
: new Date().toISOString().slice(0, 10);
const board = await getDayBoard(isoDate, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning · jour"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (jour: string, etablissement = board.location.id) =>
`/planning/jour?jour=${jour}&etablissement=${etablissement}`;
const present = board.lanes.filter((lane) => !lane.unassigned).length;
const open = board.lanes.find((lane) => lane.unassigned)?.shifts.length ?? 0;
return (
<PageBody>
<PageHeader
title="Planning · mercredi 12 août"
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
title={`Planning · ${board.label}`}
subtitle={`${board.location.name} · amplitude ${String(board.fromHour).padStart(2, '0')}:00 – ${String(board.toHour % 24).padStart(2, '0')}:00`}
actions={
<>
<Button>Imprimer la journée</Button>
<Button variant="primary">Ajouter un créneau</Button>
<Link
href={href(board.previousDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Jour précédent
</Link>
<Link
href={href(board.nextDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Jour suivant →
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{lanes.length} salariés présents</Badge>
<Badge tone="warn">Creux de couverture à 13 h</Badge>
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.isoDate, 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>
))}
<Badge tone="accent">
{present} salarié{present > 1 ? 's' : ''} présent
{present > 1 ? 's' : ''}
</Badge>
{open > 0 ? (
<Badge tone="warn">
{open} besoin{open > 1 ? 's' : ''} non couvert{open > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
{board.lanes.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Personne n’est planifié ce jour-là.
</p>
) : (
<DayTimeline
lanes={board.lanes}
fromHour={board.fromHour}
toHour={board.toHour}
/>
)}
</PageBody>
);
}
+100 -18
View File
@@ -1,38 +1,120 @@
import { WeekGrid } from '@/components/planning/WeekGrid';
import Link from 'next/link';
import { TeamSection } from '@/components/planning/TeamSection';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
export const metadata = { title: 'Planning · semaine · PlanFlow' };
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
export default async function SemainePage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Semaine courante par défaut : ouvrir le planning sur une semaine arbitraire
// obligerait à naviguer avant de voir quoi que ce soit d'utile.
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning"
subtitle="Aucun établissement n'est encore créé."
/>
<p className="text-sm text-ink-2">
Créez un établissement dans{' '}
<Link href="/reglages/etablissements" className="underline">
Réglages · Établissements
</Link>{' '}
pour commencer à planifier.
</p>
</PageBody>
);
}
const canEdit = can(session.actor, 'planning.create');
const canPublish = can(session.actor, 'planning.publish');
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/semaine?semaine=${semaine}&etablissement=${etablissement}`;
export default function SemainePage() {
return (
<PageBody>
<PageHeader
title="Planning · semaine 33"
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
title={`Planning · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Button>Dupliquer S‑32</Button>
<Button>Imprimer</Button>
<Button variant="primary">Publier la semaine</Button>
<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>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="warn">2 alertes de convention</Badge>
<Badge tone="info">Samedi non publié</Badge>
<Badge tone="neutral">5 besoins non couverts</Badge>
{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>
))}
{board.totals.unassigned > 0 ? (
<Badge tone="warn">
{board.totals.unassigned} besoin
{board.totals.unassigned > 1 ? 's' : ''} non couvert
{board.totals.unassigned > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<WeekGrid
days={WEEK_DAYS}
rows={WEEK_ROWS}
unassignedRow={UNASSIGNED_ROW}
/>
{board.sections.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Cet établissement n’a pas encore d’équipe.
</p>
) : null}
{board.sections.map((section) => (
<TeamSection
key={section.teamId}
section={section}
days={board.headings}
dates={board.dates}
labels={board.labels}
weekParam={board.weekParam}
canEdit={canEdit}
canPublish={canPublish}
/>
))}
<PosteLegend />
</PageBody>
+20 -18
View File
@@ -1,13 +1,15 @@
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
import type { DayLane } from '@/server/planning/queries';
export interface DayLane {
employee: DemoEmployee;
shifts: DemoShift[];
unassigned?: boolean;
/** « 09:00 » depuis des minutes après minuit ; 25 h devient 01:00. */
function clock(minutes: number): string {
const hours = Math.floor(minutes / 60) % 24;
return `${String(hours).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
}
export type { DayLane };
export interface DayTimelineProps {
lanes: DayLane[];
/** Amplitude affichée, en heures locales. */
@@ -32,7 +34,9 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
return lanes.filter(
(lane) =>
!lane.unassigned &&
lane.shifts.some((shift) => shift.start < end && shift.end > start),
lane.shifts.some(
(shift) => shift.startMinutes < end && shift.endMinutes > start,
),
).length;
});
const peak = Math.max(1, ...coverage);
@@ -95,7 +99,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
{lanes.map((lane) => (
<div
key={lane.employee.id}
key={lane.id}
className={cx(
'flex border-b border-line-1 last:border-b-0',
lane.unassigned && 'bg-surface-2',
@@ -111,7 +115,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
: 'bg-surface-3 text-ink-2',
)}
>
{lane.unassigned ? '?' : initials(lane.employee)}
{lane.initials}
</span>
<span className="min-w-0">
<span
@@ -120,12 +124,10 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{lane.unassigned
? 'Non assigné'
: `${lane.employee.firstName} ${lane.employee.lastName}`}
{lane.name}
</span>
<span className="block truncate text-micro text-ink-3">
{lane.employee.job}
{lane.job}
</span>
</span>
</div>
@@ -140,29 +142,29 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
/>
))}
{lane.shifts.map((shift, index) => {
{lane.shifts.map((shift) => {
const tokens = posteTokens(shift.poste);
const ghost = shift.state === 'unassigned';
return (
<div
key={index}
key={shift.id}
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
style={{
left: offset(shift.start),
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
left: offset(shift.startMinutes),
width: `calc(${offset(shift.endMinutes)} - ${offset(shift.startMinutes)})`,
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border: ghost
? `1px dashed ${tokens.edge}`
: '1px solid transparent',
}}
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
title={`${posteShort(shift.poste)} · ${clock(shift.startMinutes)}–${clock(shift.endMinutes)}`}
>
<span className="font-semibold tracking-wide">
{posteShort(shift.poste)}
</span>
<span className="tnum truncate opacity-90">
{clock(shift.start)}–{clock(shift.end)}
{clock(shift.startMinutes)}–{clock(shift.endMinutes)}
</span>
</div>
);
+349
View File
@@ -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>
);
}
+75 -47
View File
@@ -1,19 +1,22 @@
import { AbsenceBar } from '@/components/planning/AbsenceBar';
import { CounterStrip } from '@/components/planning/CounterStrip';
import { ShiftChip } from '@/components/planning/ShiftChip';
import { computeWeekCounters } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
import {
fullName,
initials,
timeRange,
type DemoWeekRow,
} from '@/lib/demo/types';
displayName,
initialsOf,
type BoardRow,
} from '@/domain/planning/board';
import { cx } from '@/lib/cx';
export interface WeekGridProps {
days: readonly string[];
rows: DemoWeekRow[];
unassignedRow?: DemoWeekRow;
dates: readonly string[];
rows: BoardRow[];
unassignedRow?: BoardRow | null;
/** 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;
}
/**
@@ -24,14 +27,41 @@ export interface WeekGridProps {
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
* planifier par inadvertance.
*/
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
export function WeekGrid({
days,
dates,
rows,
unassignedRow,
cellAction,
shiftAction,
}: WeekGridProps) {
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[1040px]">
<GridHeader days={days} />
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
{unassignedRow ? (
<Row
row={unassignedRow}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
) : null}
{rows.length === 0 && !unassignedRow ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun salarié rattaché à cette équipe.
</p>
) : null}
{rows.map((row) => (
<Row key={row.employee.id} row={row} days={days} />
<Row
key={row.membershipId ?? 'unassigned'}
row={row}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
))}
</div>
</div>
@@ -61,9 +91,21 @@ function GridHeader({ days }: { days: readonly string[] }) {
);
}
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
function Row({
row,
days,
dates,
cellAction,
shiftAction,
}: {
row: BoardRow;
days: readonly string[];
dates: readonly string[];
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
shiftAction?: (shiftId: string) => React.ReactNode;
}) {
const counters = computeWeekCounters(row.counters);
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
const name = row.unassigned ? 'Non assigné' : displayName(row);
return (
<div
@@ -86,7 +128,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
aria-hidden
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(row.employee)}
{initialsOf(row)}
</span>
)}
<span className="min-w-0">
@@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
{name}
</span>
<span className="block truncate text-micro text-ink-3">
{row.employee.job}
{row.job}
</span>
</span>
</div>
<CounterStrip
counters={counters}
forfaitJours={row.employee.forfaitJours ?? false}
unassigned={row.unassigned ?? false}
forfaitJours={row.forfaitJours}
unassigned={row.unassigned}
className="mt-1"
/>
</div>
<div className="relative grid flex-1 grid-cols-7">
{row.absence ? (
<AbsenceBar
kind={row.absence.kind}
label={row.absence.label}
duration={`${row.absence.span} j`}
startDay={row.absence.startDay}
span={row.absence.span}
className="top-1.5"
/>
) : null}
{days.map((day, index) => {
const shifts = row.days[index] ?? [];
return (
<div
key={day}
key={dates[index] ?? day}
className={cx(
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
'group/cell 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.absence &&
index >= row.absence.startDay &&
index < row.absence.startDay + row.absence.span &&
'pt-7',
)}
>
{shifts.map((shift, position) => (
<ShiftChip
key={`${day}-${position}`}
poste={shift.poste}
time={timeRange(shift)}
state={shift.state ?? 'published'}
{...(shift.delta ? { delta: shift.delta } : {})}
{...(shift.alert ? { alert: shift.alert } : {})}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shifts.map((shift) => (
<div key={shift.id} className="relative">
<ShiftChip
poste={shift.poste}
time={shift.time}
state={shift.state}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shiftAction?.(shift.id)}
</div>
))}
{cellAction?.(row, index)}
</div>
);
})}
+204
View File
@@ -0,0 +1,204 @@
/**
* Mise en forme de la grille hebdomadaire.
*
* Volontairement séparé de l'accès aux données : ranger sept jours de créneaux
* en colonnes est la partie où l'on se trompe (fuseau, semaine à cheval,
* créneau de nuit), et elle se teste sans base.
*/
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import { shiftMinutes } from '@/domain/counters/week';
import { zonedClock, zonedDate } from '@/domain/planning/week';
import type { PosteCode } from '@/lib/design/postes';
export interface BoardShiftInput {
id: string;
membershipId: string | null;
startAt: Date;
endAt: Date;
breakMinutes: number;
poste: PosteCode;
isValidated: boolean;
note: string | null;
}
export interface BoardShift {
id: string;
poste: PosteCode;
/** « 09:00–17:00 », en heure locale de l'établissement. */
time: string;
minutes: number;
breakMinutes: number;
state: ShiftState;
note: string | null;
}
export interface BoardRow {
/** `null` sur la ligne des besoins non couverts. */
membershipId: string | null;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
counters: WeekCounters;
/** Sept cases, lundi → dimanche. */
days: BoardShift[][];
unassigned: boolean;
}
export interface RowPerson {
membershipId: string;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
contractMinutes: number;
}
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
* Le jour de rattachement se déduit de `startAt` **vu depuis le fuseau de
* l'établissement**, pas de la colonne `localDate` : celle-ci est un cache de
* regroupement, et un créneau déplacé sans la mettre à jour se rangerait au
* mauvais endroit. Ici, les deux ne peuvent pas diverger.
*/
export function buildRows(
people: RowPerson[],
shifts: BoardShiftInput[],
weekDates: string[],
timeZone: string,
isPublished: boolean,
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
const byMember = new Map<string, BoardShift[][]>();
const minutesByMember = new Map<string, number>();
const sundaysByMember = new Map<string, Set<number>>();
const unassignedDays = emptyDays();
let hasUnassigned = false;
for (const shift of shifts) {
const column = columnOf.get(zonedDate(shift.startAt, timeZone));
// Un créneau hors des sept jours ne peut pas s'afficher : le taire vaut
// mieux que de l'écraser sur lundi, où il fausserait le compteur.
if (column === undefined) continue;
const minutes = shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
const view: BoardShift = {
id: shift.id,
poste: shift.poste,
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
minutes,
breakMinutes: shift.breakMinutes,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
note: shift.note,
};
if (!shift.membershipId) {
unassignedDays[column]?.push(view);
hasUnassigned = true;
continue;
}
let days = byMember.get(shift.membershipId);
if (!days) {
days = emptyDays();
byMember.set(shift.membershipId, days);
}
days[column]?.push(view);
minutesByMember.set(
shift.membershipId,
(minutesByMember.get(shift.membershipId) ?? 0) + minutes,
);
if (column === 6) {
const sundays =
sundaysByMember.get(shift.membershipId) ?? new Set<number>();
sundays.add(column);
sundaysByMember.set(shift.membershipId, sundays);
}
}
for (const days of byMember.values()) {
for (const column of days) column.sort(byStartTime);
}
for (const column of unassignedDays) column.sort(byStartTime);
const rows = people.map((person) => {
const days = byMember.get(person.membershipId) ?? emptyDays();
const worked = days.filter((column) => column.length > 0).length;
return {
membershipId: person.membershipId,
firstName: person.firstName,
lastName: person.lastName,
job: person.job,
forfaitJours: person.forfaitJours,
unassigned: false,
days,
counters: {
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
// que la formule d'écart ne change pas quand elles se brancheront.
absenceMinutes: 0,
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
},
} satisfies BoardRow;
});
const unassignedRow: BoardRow | null = hasUnassigned
? {
membershipId: null,
firstName: 'Non',
lastName: 'assigné',
job: 'Besoins à couvrir',
forfaitJours: false,
unassigned: true,
days: unassignedDays,
counters: {
contractMinutes: 0,
plannedMinutes: unassignedDays
.flat()
.reduce((total, shift) => total + shift.minutes, 0),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
}
: null;
return { rows, unassignedRow };
}
function byStartTime(a: BoardShift, b: BoardShift): number {
return a.time.localeCompare(b.time);
}
/**
* État visuel d'un créneau.
*
* L'ordre des tests compte : un besoin non couvert reste affiché en fantôme
* même sur une semaine publiée, sinon il se confondrait avec un créneau réel.
*/
export function shiftState(
isValidated: boolean,
isPublished: boolean,
membershipId: string | null,
): ShiftState {
if (!membershipId) return 'unassigned';
if (isValidated) return 'validated';
return isPublished ? 'published' : 'draft';
}
export function initialsOf(row: BoardRow): string {
return `${row.firstName.charAt(0)}${row.lastName.charAt(0)}`.toUpperCase();
}
export function displayName(row: BoardRow): string {
return `${row.firstName} ${row.lastName}`;
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Repérage des semaines de planning.
*
* Une semaine est désignée par son couple **année ISO + numéro ISO**, jamais
* par une date de début. La raison est pratique : fin décembre, le lundi 29
* décembre 2025 appartient à la semaine 1 de 2026. Stocker « 2025-12-29 »
* comme clé ferait apparaître deux semaines 1 selon le point d'entrée, et la
* contrainte d'unicité `(teamId, isoYear, isoWeek)` ne protégerait plus rien.
*
* Toutes les fonctions de ce module sont pures. Les instants sont calculés en
* UTC pour la clé, et les *jours* de la grille sont produits dans le fuseau de
* l'établissement — les deux ne coïncident pas (PLAN.md §3.3).
*/
export interface IsoWeek {
isoYear: number;
isoWeek: number;
}
const DAY_MS = 86_400_000;
/** Minuit UTC du jour donné, sans dépendre du fuseau de la machine. */
function utcDay(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Numéro de semaine ISO 8601 d'une date **civile**.
*
* L'algorithme est celui de la norme : on se déplace au jeudi de la semaine,
* dont l'année donne l'année ISO, puis on compte les semaines depuis le
* 4 janvier. Passer par le jeudi est ce qui rend le résultat correct au
* changement d'année, où la semaine chevauche deux millésimes.
*/
export function isoWeekOf(date: Date): IsoWeek {
const target = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
// getUTCDay : 0 = dimanche. En ISO, lundi = 1 et dimanche = 7.
const dayNumber = target.getUTCDay() === 0 ? 7 : target.getUTCDay();
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const isoYear = target.getUTCFullYear();
const firstThursday = utcDay(isoYear, 1, 4);
const firstDayNumber =
firstThursday.getUTCDay() === 0 ? 7 : firstThursday.getUTCDay();
firstThursday.setUTCDate(firstThursday.getUTCDate() + 4 - firstDayNumber);
const isoWeek =
1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * DAY_MS));
return { isoYear, isoWeek };
}
/** Lundi (date civile, minuit UTC) de la semaine ISO donnée. */
export function mondayOfIsoWeek({ isoYear, isoWeek }: IsoWeek): Date {
const jan4 = utcDay(isoYear, 1, 4);
const dayNumber = jan4.getUTCDay() === 0 ? 7 : jan4.getUTCDay();
const week1Monday = new Date(jan4.getTime() - (dayNumber - 1) * DAY_MS);
return new Date(week1Monday.getTime() + (isoWeek - 1) * 7 * DAY_MS);
}
/** Nombre de semaines ISO de l'année : 52, ou 53 les années longues. */
export function weeksInIsoYear(isoYear: number): number {
return isoWeekOf(utcDay(isoYear, 12, 28)).isoWeek;
}
/** Semaine précédente, en franchissant correctement les années à 53 semaines. */
export function previousIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek > 1) return { isoYear, isoWeek: isoWeek - 1 };
const year = isoYear - 1;
return { isoYear: year, isoWeek: weeksInIsoYear(year) };
}
export function nextIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek < weeksInIsoYear(isoYear)) {
return { isoYear, isoWeek: isoWeek + 1 };
}
return { isoYear: isoYear + 1, isoWeek: 1 };
}
/** Les sept dates civiles de la semaine, lundi → dimanche, en `YYYY-MM-DD`. */
export function weekDates(week: IsoWeek): string[] {
const monday = mondayOfIsoWeek(week);
return Array.from({ length: 7 }, (_, index) =>
new Date(monday.getTime() + index * DAY_MS).toISOString().slice(0, 10),
);
}
/**
* Bornes absolues de la semaine dans un fuseau donné.
*
* Sert à charger les créneaux : `startAt >= from && startAt < to`. Les bornes
* sont des **instants**, pas des dates civiles — la semaine du passage à
* l'heure d'hiver dure 169 heures, celle du passage à l'heure d'été 167.
*/
export function weekBounds(
week: IsoWeek,
timeZone: string,
): { from: Date; to: Date } {
const dates = weekDates(week);
const first = dates[0];
const monday = zonedMidnight(first ?? '1970-01-01', timeZone);
const nextMonday = zonedMidnight(
new Date(mondayOfIsoWeek(week).getTime() + 7 * DAY_MS)
.toISOString()
.slice(0, 10),
timeZone,
);
return { from: monday, to: nextMonday };
}
/**
* Instant correspondant à minuit local d'une date civile dans un fuseau.
*
* Il n'existe pas d'API native pour cela : on tâtonne à partir de l'UTC, en
* mesurant le décalage réel de ce fuseau **à cet instant-là**, puis on corrige.
* Deux passes suffisent, y compris le jour d'un changement d'heure.
*/
export function zonedMidnight(isoDate: string, timeZone: string): Date {
const [year, month, day] = isoDate.split('-').map(Number);
const naive = Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1);
let instant = naive;
for (let pass = 0; pass < 2; pass += 1) {
instant = naive - offsetAt(new Date(instant), timeZone);
}
return new Date(instant);
}
/** Décalage du fuseau à un instant donné, en millisecondes. */
export function offsetAt(instant: Date, timeZone: string): number {
// `en-US` avec `hourCycle: 'h23'` évite le « 24:00 » que produit `h24` à
// minuit, qui décalerait le calcul d'une journée entière.
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(instant);
const get = (type: string) =>
Number(parts.find((part) => part.type === type)?.value ?? '0');
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* Instant correspondant à une heure locale « 09:00 » d'une date civile.
*
* Passe par minuit local puis corrige : le jour d'un changement d'heure, le
* décalage à 09 h n'est pas celui de minuit, et ajouter neuf heures à minuit
* donnerait 08 h ou 10 h locales.
*/
export function zonedInstant(
isoDate: string,
clock: string,
timeZone: string,
): Date {
const [hours, minutes] = clock.split(':').map(Number);
const midnight = zonedMidnight(isoDate, timeZone);
const naive = new Date(
midnight.getTime() + ((hours ?? 0) * 60 + (minutes ?? 0)) * 60_000,
);
const reached = offsetAt(naive, timeZone);
const atMidnight = offsetAt(midnight, timeZone);
return reached === atMidnight
? naive
: new Date(naive.getTime() - (reached - atMidnight));
}
/** Date civile (`YYYY-MM-DD`) d'un instant, vue depuis un fuseau. */
export function zonedDate(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(instant);
}
/** Heure locale « 09:00 » d'un instant, vue depuis un fuseau. */
export function zonedClock(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('fr-FR', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(instant);
}
const DAY_LABELS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
/** « Lun. 10 » — en-tête de colonne de la grille. */
export function dayHeadings(week: IsoWeek): string[] {
return weekDates(week).map((isoDate, index) => {
const label = DAY_LABELS[index] ?? '';
const day = Number(isoDate.slice(8, 10));
return `${label.charAt(0).toUpperCase()}${label.slice(1)} ${day}`;
});
}
const MONTHS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
/** « Semaine 33 · 10 – 16 août 2026 ». */
export function weekLabel(week: IsoWeek): string {
const dates = weekDates(week);
const first = dates[0] ?? '';
const last = dates[6] ?? '';
const startDay = Number(first.slice(8, 10));
const endDay = Number(last.slice(8, 10));
const startMonth = MONTHS[Number(first.slice(5, 7)) - 1] ?? '';
const endMonth = MONTHS[Number(last.slice(5, 7)) - 1] ?? '';
const endYear = last.slice(0, 4);
const span =
startMonth === endMonth
? `${startDay} – ${endDay} ${endMonth} ${endYear}`
: `${startDay} ${startMonth} – ${endDay} ${endMonth} ${endYear}`;
return `Semaine ${week.isoWeek} · ${span}`;
}
/** Analyse « 2026-W33 » ; renvoie `null` si la forme ou les bornes sont fausses. */
export function parseWeekParam(value: string | undefined): IsoWeek | null {
if (!value) return null;
const match = /^(\d{4})-W(\d{1,2})$/.exec(value);
if (!match) return null;
const isoYear = Number(match[1]);
const isoWeek = Number(match[2]);
if (isoWeek < 1 || isoWeek > weeksInIsoYear(isoYear)) return null;
return { isoYear, isoWeek };
}
export function formatWeekParam({ isoYear, isoWeek }: IsoWeek): string {
return `${isoYear}-W${String(isoWeek).padStart(2, '0')}`;
}
-130
View File
@@ -1,130 +0,0 @@
import type { DemoEmployee } from '@/lib/demo/types';
/** Effectif fictif de l'établissement Nantes Atlantis. */
export const EMPLOYEES: DemoEmployee[] = [
{
id: 'camille-ferrand',
firstName: 'Camille',
lastName: 'Ferrand',
job: 'Responsable univers maison',
poste: 'enc',
contract: 'CDI 39 h',
since: '4 mars 2021',
status: 'actif',
},
{
id: 'jonas-meyer',
firstName: 'Jonas',
lastName: 'Meyer',
job: 'Adjoint magasin',
poste: 'enc',
contract: 'CDI · forfait jours',
since: '12 sept. 2019',
status: 'actif',
forfaitJours: true,
},
{
id: 'remi-chartier',
firstName: 'Rémi',
lastName: 'Chartier',
job: 'Vendeur',
poste: 'vte',
contract: 'CDI 35 h',
since: '2 juin 2022',
status: 'actif',
},
{
id: 'ines-bakhti',
firstName: 'Inès',
lastName: 'Bakhti',
job: 'Hôtesse de caisse',
poste: 'cai',
contract: 'CDI 35 h',
since: '18 janv. 2023',
status: 'actif',
},
{
id: 'nour-zaidi',
firstName: 'Nour',
lastName: 'Zaidi',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '5 oct. 2020',
status: 'actif',
},
{
id: 'alix-perron',
firstName: 'Alix',
lastName: 'Perron',
job: 'Employé réserve',
poste: 'res',
contract: 'CDI 35 h',
since: '9 mai 2024',
status: 'actif',
},
{
id: 'elsa-tavares',
firstName: 'Elsa',
lastName: 'Tavares',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDD 24 h',
since: '3 févr. 2026',
status: 'essai',
},
{
id: 'malik-ouedraogo',
firstName: 'Malik',
lastName: 'Ouedraogo',
job: 'Employé logistique',
poste: 'liv',
contract: 'CDI 35 h',
since: '21 nov. 2018',
status: 'actif',
},
{
id: 'chloe-vasseur',
firstName: 'Chloé',
lastName: 'Vasseur',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDI 20 h',
since: '14 juil. 2025',
status: 'actif',
},
{
id: 'yanis-bouchard',
firstName: 'Yanis',
lastName: 'Bouchard',
job: 'Vendeur univers maison',
poste: 'vte',
contract: 'CDI 35 h',
since: '17 août 2026',
status: 'essai',
},
{
id: 'sarah-lemoine',
firstName: 'Sarah',
lastName: 'Lemoine',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '7 sept. 2026',
status: 'essai',
},
{
id: 'hugo-delaunay',
firstName: 'Hugo',
lastName: 'Delaunay',
job: 'Employé retrait web',
poste: 'clc',
contract: 'CDI 28 h',
since: '2 avr. 2023',
status: 'sortie',
},
];
export function findEmployee(id: string): DemoEmployee | undefined {
return EMPLOYEES.find((employee) => employee.id === id);
}
-96
View File
@@ -1,96 +0,0 @@
import type { Tone } from '@/components/ui/Badge';
import type { PosteCode } from '@/lib/design/postes';
export interface FicheShift {
day: string;
poste: PosteCode;
time: string;
worked: string;
state: string;
tone: Tone;
}
export const FICHE_SHIFTS: FicheShift[] = [
{
day: 'Lun. 10 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mar. 11 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'for',
time: '09:00–12:30',
worked: '3 h 30',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'enc',
time: '13:30–17:00',
worked: '3 h 30',
state: 'Heures réelles manquantes',
tone: 'warn',
},
{
day: 'Jeu. 13 août',
poste: 'enc',
time: '11:00–19:00',
worked: '8 h 00',
state: 'Publié',
tone: 'neutral',
},
];
export interface FicheDocument {
label: string;
date: string;
state: string;
tone: Tone;
}
export const FICHE_DOCUMENTS: FicheDocument[] = [
{
label: 'Contrat de travail — avenant temps partiel',
date: '2 janv. 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Entretien professionnel 2026',
date: '18 mai 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Attestation mutuelle',
date: '—',
state: 'En attente',
tone: 'warn',
},
];
export interface FicheCounter {
label: string;
value: string;
tone?: Tone;
}
export const FICHE_COUNTERS: FicheCounter[] = [
{ label: 'Contrat', value: '39 h' },
{ label: 'Planifié S33', value: '39 h 00' },
{ label: 'Écart', value: '+0 h 00' },
{ label: 'CP acquis', value: '14,5 j' },
{ label: 'Récupération', value: '4 h 30', tone: 'warn' },
];
-268
View File
@@ -1,268 +0,0 @@
import { findEmployee } from '@/lib/demo/equipe';
import type { DemoEmployee, DemoShift, DemoWeekRow } from '@/lib/demo/types';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
function pick(id: string): DemoEmployee {
const employee = findEmployee(id);
if (!employee) throw new Error(`Salarié de démonstration inconnu : ${id}`);
return employee;
}
const NONE: DemoShift[] = [];
export const WEEK_DAYS = [
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
] as const;
export const WEEK_LABEL = 'Semaine 33 · 10 – 16 août 2026';
export const WEEK_ROWS: DemoWeekRow[] = [
{
employee: pick('camille-ferrand'),
counters: {
contractMinutes: h(39),
plannedMinutes: h(39),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[
{ poste: 'for', start: h(9), end: h(12, 30), state: 'validated' },
{ poste: 'enc', start: h(13, 30), end: h(17), state: 'validated' },
],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
NONE,
NONE,
],
},
{
employee: pick('jonas-meyer'),
counters: {
contractMinutes: 0,
plannedMinutes: h(38, 30),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[
{ poste: 'inv', start: h(6, 30), end: h(11), state: 'validated' },
{ poste: 'enc', start: h(11, 30), end: h(15), state: 'validated' },
],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
NONE,
[{ poste: 'enc', start: h(9), end: h(19), state: 'draft' }],
NONE,
],
},
{
employee: pick('remi-chartier'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
},
days: [
[
{ poste: 'vte', start: h(9, 30), end: h(13), state: 'validated' },
{ poste: 'cai', start: h(14), end: h(18, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(12),
end: h(20),
state: 'alert',
alert: 'Repos < 11 h',
},
],
[{ poste: 'vte', start: h(10), end: h(14), state: 'validated' }],
NONE,
[
{ poste: 'vte', start: h(9), end: h(13), state: 'validated' },
{ poste: 'cab', start: h(14), end: h(18), state: 'validated' },
],
[{ poste: 'cai', start: h(9, 30), end: h(19, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('ines-bakhti'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(35),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
NONE,
[{ poste: 'cai', start: h(11, 30), end: h(19, 30), state: 'validated' }],
[
{ poste: 'cai', start: h(9), end: h(14), state: 'validated' },
{ poste: 'clc', start: h(14, 30), end: h(17, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(9, 30),
end: h(19, 30),
state: 'alert',
alert: 'Amplitude > 10 h',
},
],
NONE,
],
},
{
employee: pick('nour-zaidi'),
counters: {
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
},
absence: { kind: 'cp', label: 'Congés payés', startDay: 0, span: 5 },
days: [
NONE,
NONE,
NONE,
NONE,
NONE,
[{ poste: 'vte', start: h(13), end: h(19), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(17), state: 'unpublished' }],
],
},
{
employee: pick('alix-perron'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
},
days: [
[
{ poste: 'liv', start: h(6), end: h(11), state: 'validated' },
{ poste: 'res', start: h(11, 30), end: h(14, 45), state: 'validated' },
],
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'liv', start: h(6), end: h(11), state: 'validated' }],
NONE,
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'rss', start: h(7), end: h(13), state: 'draft' }],
NONE,
],
},
{
employee: pick('elsa-tavares'),
counters: {
contractMinutes: h(24),
plannedMinutes: h(25, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 3,
},
days: [
NONE,
NONE,
[{ poste: 'vte', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'cab', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'vte', start: h(14), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(13, 30), end: h(19, 30), state: 'draft' }],
[{ poste: 'vte', start: h(10), end: h(14), state: 'unpublished' }],
],
},
{
employee: pick('malik-ouedraogo'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(31),
absenceMinutes: h(14),
sundaysWorked: 0,
restDays: 2,
},
absence: { kind: 'maladie', label: 'Arrêt maladie', startDay: 3, span: 2 },
days: [
[
{ poste: 'liv', start: h(5, 30), end: h(10, 30), state: 'validated' },
{ poste: 'res', start: h(11), end: h(14), state: 'validated' },
],
[{ poste: 'rss', start: h(6), end: h(13, 30), state: 'validated' }],
[{ poste: 'inv', start: h(6), end: h(13), state: 'validated' }],
NONE,
NONE,
[{ poste: 'liv', start: h(6), end: h(12, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('chloe-vasseur'),
counters: {
contractMinutes: h(20),
plannedMinutes: h(21, 15),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 4,
},
days: [
NONE,
NONE,
[{ poste: 'for', start: h(9), end: h(13), state: 'validated' }],
NONE,
[{ poste: 'vte', start: h(15), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(11), end: h(18), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(15), state: 'unpublished' }],
],
},
];
/** Ligne des besoins sans titulaire, affichée en tête de grille. */
export const UNASSIGNED_ROW: DemoWeekRow = {
employee: {
id: 'non-assigne',
firstName: '',
lastName: 'Non assigné',
job: 'Besoin non couvert',
poste: 'cai',
contract: '—',
since: '—',
status: 'actif',
},
unassigned: true,
counters: {
contractMinutes: 0,
plannedMinutes: h(14),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
days: [
NONE,
[{ poste: 'net', start: h(20), end: h(22), state: 'unassigned' }],
NONE,
[{ poste: 'clc', start: h(16), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(17), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(14), end: h(19), state: 'unassigned' }],
[{ poste: 'clc', start: h(13), end: h(17), state: 'unassigned' }],
],
};
-63
View File
@@ -1,63 +0,0 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import type { PosteCode } from '@/lib/design/postes';
export interface DemoShift {
poste: PosteCode;
/** Début et fin en minutes depuis minuit, jour local. */
start: number;
end: number;
breakMinutes?: number;
state?: ShiftState;
delta?: string;
alert?: string;
}
export interface DemoAbsence {
kind: AbsenceKind;
label: string;
/** 0 = lundi. */
startDay: number;
span: number;
}
export interface DemoEmployee {
id: string;
firstName: string;
lastName: string;
job: string;
poste: PosteCode;
contract: string;
since: string;
status: 'actif' | 'essai' | 'sortie';
forfaitJours?: boolean;
}
export interface DemoWeekRow {
employee: DemoEmployee;
counters: WeekCounters;
/** Sept entrées, lundi → dimanche. */
days: DemoShift[][];
absence?: DemoAbsence;
unassigned?: boolean;
}
export function fullName(employee: DemoEmployee): string {
return `${employee.firstName} ${employee.lastName}`;
}
export function initials(employee: DemoEmployee): string {
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
}
/** « 09:00 » depuis des minutes après minuit. */
export function clock(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function timeRange(shift: DemoShift): string {
return `${clock(shift.start)}–${clock(shift.end)}`;
}
+499
View File
@@ -0,0 +1,499 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
import { shiftMinutes } from '@/domain/counters/week';
import {
parseWeekParam,
weekBounds,
zonedInstant,
} from '@/domain/planning/week';
import { recordAudit } from '@/server/audit';
import { mutate } from '@/server/context';
import type { ScopedClient } from '@/server/tenant';
/**
* Écritures du planning.
*
* Deux règles gouvernent ce fichier :
*
* 1. **Les instants font foi.** Le formulaire envoie une date civile et deux
* heures locales ; ils sont convertis en instants dans le fuseau de
* l'établissement avant toute écriture. `localDate` n'est qu'un cache de
* regroupement, jamais une source de vérité.
* 2. **Publier est une décision, la modifier ensuite en est une autre.**
* Toucher à une semaine publiée exige une capacité distincte : un salarié a
* organisé sa semaine sur ce qu'il a lu.
*/
export interface PlanningActionState {
error?: string;
ok?: boolean;
}
class ValidationError extends Error {}
const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
const shiftInput = z.object({
teamId: z.string().min(1),
week: z.string().min(1),
membershipId: z.string().optional(),
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
start: z.string().regex(HOUR, 'Heure de début invalide'),
end: z.string().regex(HOUR, 'Heure de fin invalide'),
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
labelId: z.string().optional(),
note: z.string().trim().max(500).optional(),
});
async function loadTeamContext(db: ScopedClient, teamId: string) {
const team = await db.team.findUnique({
where: { id: teamId },
select: { id: true, name: true, locationId: true },
});
if (!team) throw new AuthorizationError('planning.create');
const location = await db.location.findUnique({
where: { id: team.locationId },
select: { id: true, timezone: true },
});
if (!location) throw new AuthorizationError('planning.create');
return { team, location };
}
/**
* Crée ou remplace la semaine de l'équipe.
*
* `upsert` plutôt que `create` : la semaine naît au premier créneau posé, et
* deux managers qui commencent la même semaine en même temps ne doivent pas
* produire deux enregistrements — la contrainte d'unicité s'en charge.
*/
async function ensureSchedule(
db: ScopedClient,
teamId: string,
locationId: string,
isoYear: number,
isoWeek: number,
) {
return db.weeklySchedule.upsert({
where: { teamId_isoYear_isoWeek: { teamId, isoYear, isoWeek } },
update: {},
create: { teamId, locationId, isoYear, isoWeek } as never,
});
}
export async function createShiftAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
const parsed = shiftInput.safeParse({
teamId: formData.get('teamId'),
week: formData.get('week'),
membershipId: formData.get('membershipId') || undefined,
localDate: formData.get('localDate'),
start: formData.get('start'),
end: formData.get('end'),
breakMinutes: formData.get('breakMinutes') || 0,
labelId: formData.get('labelId') || undefined,
note: formData.get('note') || undefined,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const week = parseWeekParam(parsed.data.week);
if (!week) return { error: 'Semaine invalide.' };
try {
await mutate('planning.create', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const startAt = zonedInstant(
parsed.data.localDate,
parsed.data.start,
location.timezone,
);
let endAt = zonedInstant(
parsed.data.localDate,
parsed.data.end,
location.timezone,
);
// Fin avant début = créneau de nuit : il finit le lendemain. Le refuser
// interdirait de planifier un inventaire 22 h–02 h.
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes);
if (worked <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
}
const { from, to } = weekBounds(week, location.timezone);
if (startAt < from || startAt >= to) {
throw new ValidationError(
"Ce créneau ne commence pas dans la semaine affichée.",
);
}
const schedule = await ensureSchedule(
db,
team.id,
location.id,
week.isoYear,
week.isoWeek,
);
if (schedule.status === 'PUBLISHED') {
assertMayEditPublished(actor);
}
if (parsed.data.membershipId) {
await assertNoOverlap(db, parsed.data.membershipId, startAt, endAt, null);
}
const created = await db.shift.create({
data: {
weeklyScheduleId: schedule.id,
membershipId: parsed.data.membershipId ?? null,
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: parsed.data.breakMinutes,
labelId: parsed.data.labelId ?? null,
note: parsed.data.note ?? null,
} as never,
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.create',
entityType: 'Shift',
entityId: created.id,
after: {
teamId: team.id,
membershipId: created.membershipId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
minutes: worked,
},
});
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas créer de créneau ici.');
}
revalidatePath('/planning/semaine');
return { ok: true };
}
const moveInput = z.object({
shiftId: z.string().min(1),
membershipId: z.string().optional(),
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
start: z.string().regex(HOUR, 'Heure de début invalide'),
end: z.string().regex(HOUR, 'Heure de fin invalide'),
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
});
export async function updateShiftAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
const parsed = moveInput.safeParse({
shiftId: formData.get('shiftId'),
membershipId: formData.get('membershipId') || undefined,
localDate: formData.get('localDate'),
start: formData.get('start'),
end: formData.get('end'),
breakMinutes: formData.get('breakMinutes') || 0,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
try {
await mutate('planning.edit', async (db, actor) => {
const shift = await db.shift.findUnique({
where: { id: parsed.data.shiftId },
include: { schedule: true },
});
if (!shift) throw new AuthorizationError('planning.edit');
const location = await db.location.findUnique({
where: { id: shift.schedule.locationId },
select: { timezone: true },
});
if (!location) throw new AuthorizationError('planning.edit');
if (shift.schedule.status === 'PUBLISHED') {
assertMayEditPublished(actor);
}
const startAt = zonedInstant(
parsed.data.localDate,
parsed.data.start,
location.timezone,
);
let endAt = zonedInstant(
parsed.data.localDate,
parsed.data.end,
location.timezone,
);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) {
throw new ValidationError(
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
);
}
const membershipId = parsed.data.membershipId ?? null;
if (membershipId) {
await assertNoOverlap(db, membershipId, startAt, endAt, shift.id);
}
await db.shift.update({
where: { id: shift.id },
data: {
membershipId,
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: parsed.data.breakMinutes,
version: { increment: 1 },
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.update',
entityType: 'Shift',
entityId: shift.id,
before: {
membershipId: shift.membershipId,
startAt: shift.startAt.toISOString(),
endAt: shift.endAt.toISOString(),
},
after: {
membershipId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
},
});
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas modifier ce créneau.');
}
revalidatePath('/planning/semaine');
return { ok: true };
}
export async function deleteShiftAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
const shiftId = String(formData.get('shiftId') ?? '');
if (!shiftId) return { error: 'Créneau introuvable.' };
try {
await mutate('planning.delete', async (db, actor) => {
const shift = await db.shift.findUnique({
where: { id: shiftId },
include: { schedule: true },
});
if (!shift) throw new AuthorizationError('planning.delete');
if (shift.schedule.status === 'PUBLISHED') {
assertMayEditPublished(actor);
}
// La trace est écrite **avant** la suppression : après, l'identifiant ne
// désigne plus rien, et l'état supprimé serait perdu.
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.shift.delete',
entityType: 'Shift',
entityId: shift.id,
before: {
membershipId: shift.membershipId,
startAt: shift.startAt.toISOString(),
endAt: shift.endAt.toISOString(),
},
});
await db.shift.delete({ where: { id: shift.id } });
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas supprimer ce créneau.');
}
revalidatePath('/planning/semaine');
return { ok: true };
}
const publishInput = z.object({
teamId: z.string().min(1),
week: z.string().min(1),
expectedVersion: z.coerce.number().int().min(0),
});
export async function publishWeekAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
return setPublication(formData, true);
}
export async function unpublishWeekAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
return setPublication(formData, false);
}
/**
* Publie ou dépublie la semaine d'une **équipe**.
*
* Le verrou optimiste porte sur `version` : deux managers sur la même grille
* est le cas normal, pas l'exception. Sans lui, le second écraserait la
* décision du premier sans que personne ne le voie (PLAN.md §3.4).
*/
async function setPublication(
formData: FormData,
publish: boolean,
): Promise<PlanningActionState> {
const parsed = publishInput.safeParse({
teamId: formData.get('teamId'),
week: formData.get('week'),
expectedVersion: formData.get('expectedVersion') ?? 0,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const week = parseWeekParam(parsed.data.week);
if (!week) return { error: 'Semaine invalide.' };
try {
await mutate(publish ? 'planning.publish' : 'planning.unpublish', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const schedule = await ensureSchedule(
db,
team.id,
location.id,
week.isoYear,
week.isoWeek,
);
if (publish) {
const count = await db.shift.count({
where: { weeklyScheduleId: schedule.id },
});
if (count === 0) {
throw new ValidationError(
'Cette semaine ne contient aucun créneau : rien à publier.',
);
}
}
const updated = await db.weeklySchedule.updateMany({
where: { id: schedule.id, version: parsed.data.expectedVersion },
data: {
status: publish ? 'PUBLISHED' : 'DRAFT',
publishedAt: publish ? new Date() : null,
publishedBy: publish ? actor.membershipId : null,
version: { increment: 1 },
},
});
if (updated.count === 0) {
throw new ValidationError(
'Cette semaine a été modifiée entre-temps. Rechargez la page avant de recommencer.',
);
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: publish ? 'planning.publish' : 'planning.unpublish',
entityType: 'WeeklySchedule',
entityId: schedule.id,
before: { status: schedule.status, version: schedule.version },
after: {
status: publish ? 'PUBLISHED' : 'DRAFT',
teamId: team.id,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
},
});
});
} catch (error) {
return toState(
error,
publish
? 'Vous ne pouvez pas publier cette semaine.'
: 'Vous ne pouvez pas dépublier cette semaine.',
);
}
revalidatePath('/planning/semaine');
return { ok: true };
}
/**
* Deux créneaux qui se chevauchent pour le même salarié sont impossibles dans
* la réalité, et compteraient deux fois en heures. Le contrôle est en base et
* en transaction — un contrôle côté formulaire ne survit pas à deux managers
* qui planifient en même temps.
*/
async function assertNoOverlap(
db: ScopedClient,
membershipId: string,
startAt: Date,
endAt: Date,
ignoreShiftId: string | null,
): Promise<void> {
const clash = await db.shift.findFirst({
where: {
membershipId,
startAt: { lt: endAt },
endAt: { gt: startAt },
...(ignoreShiftId ? { id: { not: ignoreShiftId } } : {}),
},
select: { id: true },
});
if (clash) {
throw new ValidationError(
'Ce salarié a déjà un créneau qui recouvre cette plage.',
);
}
}
/**
* Une semaine publiée n'est pas gelée, mais la toucher est une seconde
* décision : le salarié a organisé sa semaine sur ce qu'il a lu. D'où une
* capacité distincte de `planning.edit`, que le rôle manager n'a pas par
* défaut.
*/
function assertMayEditPublished(actor: Actor): void {
if (!can(actor, 'planning.edit_published')) {
throw new ValidationError(
'Cette semaine est publiée. Dépubliez-la, ou demandez le droit de modifier une semaine publiée.',
);
}
}
function toState(error: unknown, denied: string): PlanningActionState {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
+526
View File
@@ -0,0 +1,526 @@
import {
buildRows,
shiftState,
type BoardRow,
type BoardShiftInput,
} from '@/domain/planning/board';
import type { ShiftState } from '@/components/planning/ShiftChip';
import {
dayHeadings,
formatWeekParam,
nextIsoWeek,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
zonedClock,
zonedMidnight,
type IsoWeek,
} from '@/domain/planning/week';
import { can } from '@/domain/access/authorize';
import { query } from '@/server/context';
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
import type { ScopedClient } from '@/server/tenant';
/**
* Lecture de la grille hebdomadaire.
*
* Une section par équipe : c'est la maille de publication constatée dans
* l'audit — chaque section porte son propre bouton « Dépublier », pas
* l'établissement entier.
*/
export interface BoardSection {
teamId: string;
teamName: string;
/** Vrai quand la semaine existe mais reste masquée faute de capacité. */
hidden: boolean;
scheduleId: string | null;
status: 'DRAFT' | 'VALIDATED' | 'PUBLISHED';
version: number;
publishedAt: Date | null;
rows: BoardRow[];
unassignedRow: BoardRow | null;
}
export interface BoardLabel {
id: string;
code: string;
name: string;
poste: PosteCode;
}
export interface WeekBoard {
location: { id: string; name: string; timezone: string };
locations: Array<{ id: string; name: string }>;
week: IsoWeek;
label: string;
headings: string[];
dates: string[];
previousParam: string;
nextParam: string;
weekParam: string;
sections: BoardSection[];
labels: BoardLabel[];
totals: { plannedMinutes: number; shifts: number; unassigned: number };
}
/** Poste par défaut quand le créneau ne porte pas d'étiquette. */
const FALLBACK_POSTE: PosteCode = 'vte';
function posteOf(paletteKey: string | null | undefined): PosteCode {
if (!paletteKey) return FALLBACK_POSTE;
return (POSTE_CODES as readonly string[]).includes(paletteKey)
? (paletteKey as PosteCode)
: FALLBACK_POSTE;
}
export async function listLocations(): Promise<
Array<{ id: string; name: string; timezone: string }>
> {
return query('planning.view', async (db) => {
return db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
});
}
export async function getWeekBoard(
week: IsoWeek,
locationId?: string,
): Promise<WeekBoard | null> {
return query(
'planning.view',
async (db, actor) => {
// Un salarié ne voit que ce qui est publié : un brouillon est une
// intention de manager, pas une information sur laquelle organiser sa
// semaine. La restriction est **serveur** — masquer côté écran laisserait
// les créneaux dans la charge utile.
const seesDrafts = can(actor, 'planning.view_unpublished');
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
const location =
locations.find((candidate) => candidate.id === locationId) ??
locations[0];
if (!location) return null;
const dates = weekDates(week);
const { from, to } = weekBounds(week, location.timezone);
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
orderBy: { position: 'asc' },
select: { id: true, name: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
isoYear: week.isoYear,
isoWeek: week.isoWeek,
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
});
const scheduleByTeam = new Map(
schedules.map((schedule) => [schedule.teamId, schedule]),
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
// Bornes en instants : un créneau de nuit du dimanche appartient à
// cette semaine par son début, quel que soit son jour de fin.
startAt: { gte: from, lt: to },
},
select: {
id: true,
weeklyScheduleId: true,
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
isValidated: true,
note: true,
labelId: true,
},
});
const labels = await db.label.findMany({
where: { archivedAt: null },
orderBy: { position: 'asc' },
select: { id: true, code: true, name: true, paletteKey: true },
});
const paletteById = new Map(
labels.map((label) => [label.id, label.paletteKey]),
);
const people = await loadPeople(db, teams.map((team) => team.id));
const sections = teams.map((team) => {
const schedule = scheduleByTeam.get(team.id) ?? null;
const isPublished = schedule?.status === 'PUBLISHED';
const hidden = !seesDrafts && !schedule;
const teamShifts: BoardShiftInput[] = shifts
.filter((shift) => shift.weeklyScheduleId === schedule?.id)
.map((shift) => ({
id: shift.id,
membershipId: shift.membershipId,
startAt: shift.startAt,
endAt: shift.endAt,
breakMinutes: shift.breakMinutes,
poste: posteOf(paletteById.get(shift.labelId ?? '')),
isValidated: shift.isValidated,
note: shift.note,
}));
const { rows, unassignedRow } = buildRows(
people.get(team.id) ?? [],
teamShifts,
dates,
location.timezone,
isPublished,
);
return {
teamId: team.id,
teamName: team.name,
hidden,
scheduleId: schedule?.id ?? null,
status: schedule?.status ?? 'DRAFT',
version: schedule?.version ?? 0,
publishedAt: schedule?.publishedAt ?? null,
rows,
unassignedRow,
} satisfies BoardSection;
});
const plannedMinutes = sections
.flatMap((section) => section.rows)
.reduce((total, row) => total + row.counters.plannedMinutes, 0);
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
week,
label: weekLabel(week),
headings: dayHeadings(week),
dates,
weekParam: formatWeekParam(week),
previousParam: formatWeekParam(previousIsoWeek(week)),
nextParam: formatWeekParam(nextIsoWeek(week)),
sections,
labels: labels.map((label) => ({
id: label.id,
code: label.code,
name: label.name,
poste: posteOf(label.paletteKey),
})),
totals: {
plannedMinutes,
shifts: shifts.filter((shift) => shift.membershipId).length,
unassigned: shifts.filter((shift) => !shift.membershipId).length,
},
} satisfies WeekBoard;
},
locationId ? { locationId } : undefined,
);
}
/** Salariés rattachés à chaque équipe, avec leur durée contractuelle. */
async function loadPeople(
db: ScopedClient,
teamIds: string[],
): Promise<Map<string, Awaited<ReturnType<typeof toPerson>>[]>> {
const assignments = await db.teamMember.findMany({
where: { teamId: { in: teamIds } },
orderBy: [{ position: 'asc' }],
include: {
membership: {
include: {
profile: { select: { firstName: true, lastName: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
select: {
weeklyHours: true,
workTimeArrangement: true,
jobTitleId: true,
},
},
},
},
},
});
const jobTitleIds = [
...new Set(
assignments
.flatMap((assignment) => assignment.membership.contracts)
.map((contract) => contract.jobTitleId)
.filter((id): id is string => Boolean(id)),
),
];
const jobTitles = await db.jobTitle.findMany({
where: { id: { in: jobTitleIds } },
select: { id: true, name: true },
});
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
const byTeam = new Map<string, Awaited<ReturnType<typeof toPerson>>[]>();
for (const assignment of assignments) {
const list = byTeam.get(assignment.teamId) ?? [];
list.push(toPerson(assignment, jobTitleById));
byTeam.set(assignment.teamId, list);
}
return byTeam;
}
function toPerson(
assignment: {
membershipId: string;
membership: {
employeeNumber: string;
profile: { firstName: string; lastName: string } | null;
contracts: Array<{
weeklyHours: { toString(): string };
workTimeArrangement: string;
jobTitleId: string | null;
}>;
};
},
jobTitleById: Map<string, string>,
) {
const contract = assignment.membership.contracts[0];
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
return {
membershipId: assignment.membershipId,
firstName: assignment.membership.profile?.firstName ?? '',
lastName:
assignment.membership.profile?.lastName ??
assignment.membership.employeeNumber,
job: contract?.jobTitleId
? (jobTitleById.get(contract.jobTitleId) ?? 'Poste à préciser')
: 'Poste à préciser',
forfaitJours,
contractMinutes: contract
? Math.round(Number(contract.weeklyHours.toString()) * 60)
: 0,
};
}
/**
* Vue jour : qui est présent, et quand.
*
* Elle répond à une question différente de la vue semaine — « qui tient la
* caisse à 14 h » plutôt que « qui fait combien d'heures » — d'où une requête
* distincte plutôt qu'un filtrage de la grille hebdomadaire.
*/
export interface DayLaneShift {
id: string;
poste: PosteCode;
/** Minutes depuis minuit local ; peut dépasser 1440 sur un créneau de nuit. */
startMinutes: number;
endMinutes: number;
state: ShiftState;
}
export interface DayLane {
id: string;
name: string;
job: string;
initials: string;
unassigned: boolean;
shifts: DayLaneShift[];
}
export interface DayBoard {
location: { id: string; name: string; timezone: string };
locations: Array<{ id: string; name: string }>;
isoDate: string;
label: string;
previousDate: string;
nextDate: string;
lanes: DayLane[];
fromHour: number;
toHour: number;
}
const DAY_FORMAT = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
function shiftDate(isoDate: string, days: number): string {
const base = new Date(`${isoDate}T00:00:00Z`);
return new Date(base.getTime() + days * 86_400_000).toISOString().slice(0, 10);
}
export async function getDayBoard(
isoDate: string,
locationId?: string,
): Promise<DayBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
const location =
locations.find((candidate) => candidate.id === locationId) ??
locations[0];
if (!location) return null;
const from = zonedMidnight(isoDate, location.timezone);
const to = zonedMidnight(shiftDate(isoDate, 1), location.timezone);
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
select: { id: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
select: { id: true, status: true },
});
const statusById = new Map(
schedules.map((schedule) => [schedule.id, schedule.status]),
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
startAt: { gte: from, lt: to },
},
orderBy: { startAt: 'asc' },
});
const labels = await db.label.findMany({
select: { id: true, paletteKey: true },
});
const paletteById = new Map(
labels.map((label) => [label.id, label.paletteKey]),
);
const memberIds = [
...new Set(
shifts
.map((shift) => shift.membershipId)
.filter((id): id is string => Boolean(id)),
),
];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: {
profile: { select: { firstName: true, lastName: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
select: { jobTitleId: true },
},
},
});
const jobTitles = await db.jobTitle.findMany({
select: { id: true, name: true },
});
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
const laneById = new Map<string, DayLane>();
for (const member of members) {
const jobTitleId = member.contracts[0]?.jobTitleId;
laneById.set(member.id, {
id: member.id,
name: `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
job: jobTitleId
? (jobTitleById.get(jobTitleId) ?? 'Poste à préciser')
: 'Poste à préciser',
initials:
`${member.profile?.firstName?.charAt(0) ?? ''}${member.profile?.lastName?.charAt(0) ?? ''}`.toUpperCase(),
unassigned: false,
shifts: [],
});
}
const unassignedLane: DayLane = {
id: 'unassigned',
name: 'Non assigné',
job: 'Besoins à couvrir',
initials: '?',
unassigned: true,
shifts: [],
};
for (const shift of shifts) {
const startMinutes = localMinutes(shift.startAt, location.timezone);
let endMinutes = localMinutes(shift.endAt, location.timezone);
// Un créneau de nuit finit « avant » son début en heure murale : le
// reporter au lendemain garde la barre continue au lieu de l'inverser.
if (endMinutes <= startMinutes) endMinutes += 24 * 60;
const isPublished =
statusById.get(shift.weeklyScheduleId) === 'PUBLISHED';
const lane = shift.membershipId
? laneById.get(shift.membershipId)
: unassignedLane;
lane?.shifts.push({
id: shift.id,
poste: posteOf(paletteById.get(shift.labelId ?? '')),
startMinutes,
endMinutes,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
});
}
const lanes = [...laneById.values()]
.filter((lane) => lane.shifts.length > 0)
.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
if (unassignedLane.shifts.length > 0) lanes.push(unassignedLane);
// L'amplitude s'ajuste à la journée réelle : figer 06 h–21 h couperait
// un inventaire de nuit, et étirerait une journée creuse.
const all = lanes.flatMap((lane) => lane.shifts);
const fromHour = all.length
? Math.max(0, Math.floor(Math.min(...all.map((s) => s.startMinutes)) / 60) - 1)
: 7;
const toHour = all.length
? Math.min(30, Math.ceil(Math.max(...all.map((s) => s.endMinutes)) / 60) + 1)
: 21;
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
isoDate,
label: DAY_FORMAT.format(new Date(`${isoDate}T00:00:00Z`)),
previousDate: shiftDate(isoDate, -1),
nextDate: shiftDate(isoDate, 1),
lanes,
fromHour,
toHour: Math.max(toHour, fromHour + 4),
} satisfies DayBoard;
},
locationId ? { locationId } : undefined,
);
}
/** Minutes depuis minuit, en heure locale de l'établissement. */
function localMinutes(instant: Date, timeZone: string): number {
const [hours, minutes] = zonedClock(instant, timeZone).split(':').map(Number);
return (hours ?? 0) * 60 + (minutes ?? 0);
}