WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user