Frontend : consommation de l'API CRUD (matrice, affectations, validation)
Remplace le magasin d'état fabriqué par une vraie couche API typée, alignée sur le back-end (Étape 5) : - src/api/ : schema.ts (miroir de backend/app/api/schemas.py), client HTTP, implémentation mock en mémoire (même surface + portage du moteur de validation), sélection mock/http via VITE_API_MODE, hooks React Query (lectures + mutations avec invalidation). - Grille des besoins ← GET /matrix ; treillis de colonnes dérivé des créneaux réels (plus de plages figées). - Panneau d'affectation ← GET /assignments?shift_id, POST /assignments/validate par candidat (alertes bloquantes / avertissements du moteur), POST / PATCH / DELETE /assignments. - Vue par bénévole ← volunteers + shifts + assignments (surcharge / oubliés calculés côté client). - Confirmations (ex-« Publication ») ← affectations PROPOSED ; publication versionnée et emails signalés hors périmètre. - Mobiles ← assignments filtrées ; confirmation = PATCH ; pointage référent local (hors périmètre). - Configuration ← positions & shifts CRUD (min/idéal, ajout/retrait), volunteers CRUD dont suppression RGPD ; messages = maquette locale. - i18n mis à jour, aucun texte d'interface en dur. - Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (API mock + rendu headless des 6 écrans avec données préchargées). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ac469f0cd1
commit
c9dda65361
+115
-131
@@ -1,54 +1,57 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { useMatrix } from '@/api/hooks';
|
||||
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
||||
import { Chip } from '@/components/ui';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
||||
import {
|
||||
DAY_PARTS,
|
||||
DEMO_CATEGORIES,
|
||||
FESTIVAL_DAYS,
|
||||
categoryLabelKey,
|
||||
slotKey,
|
||||
} from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import type { PositionCategory } from '@/domain/types';
|
||||
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
type DayFilter = 'all' | string;
|
||||
type CategoryFilter = 'all' | PositionCategory;
|
||||
|
||||
export function GrillePage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [dayFilter, setDayFilter] = useState<DayFilter>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const matrix = useMatrix();
|
||||
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const lattice = useMemo(
|
||||
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
|
||||
[matrix.data],
|
||||
);
|
||||
|
||||
const shownDays =
|
||||
dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter);
|
||||
if (matrix.isLoading) return <Loading />;
|
||||
if (matrix.error) return <ErrorNote error={matrix.error} />;
|
||||
if (!matrix.data) return null;
|
||||
|
||||
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
|
||||
const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter);
|
||||
const shownColumns = shownDays.flatMap((d) => d.columns);
|
||||
const shownPositions =
|
||||
categoryFilter === 'all'
|
||||
? state.positions
|
||||
: state.positions.filter((p) => p.category === categoryFilter);
|
||||
? matrix.data.positions
|
||||
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
||||
|
||||
const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`;
|
||||
const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`;
|
||||
|
||||
const selected = selectedShiftId
|
||||
? matrix.data.positions
|
||||
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
|
||||
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Barre de filtres + légende */}
|
||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{FESTIVAL_DAYS.map((day) => (
|
||||
<Chip key={day.id} active={dayFilter === day.id} onClick={() => setDayFilter(day.id)}>
|
||||
{day.shortLabel}
|
||||
{lattice.days.map((day) => (
|
||||
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
@@ -58,82 +61,68 @@ export function GrillePage() {
|
||||
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{DEMO_CATEGORIES.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category}
|
||||
active={categoryFilter === category}
|
||||
onClick={() => setCategoryFilter(category)}
|
||||
>
|
||||
{t(categoryLabelKey(category)).toUpperCase()}
|
||||
{categoryLabel(category).toUpperCase()}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||
<span className="font-mono text-[8.5px] opacity-60">{t('grille.legend.title')}</span>
|
||||
<LegendItem swatchClass="bg-hatch-orange" glyph="▲">
|
||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||
{t('grille.legend.critical')}
|
||||
</LegendItem>
|
||||
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
|
||||
<LegendItem swatch="bg-yellow-soft" glyph="◧">
|
||||
{t('grille.legend.incomplete')}
|
||||
</LegendItem>
|
||||
<LegendItem swatchClass="bg-paper" glyph="✓">
|
||||
<LegendItem swatch="bg-paper" glyph="✓">
|
||||
{t('grille.legend.complete')}
|
||||
</LegendItem>
|
||||
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||
<span className="h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||
{t('grille.legend.unpublished')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
<div
|
||||
className="grid w-max border-b-[3px] border-ink"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{/* Ligne 1 : coin + entêtes de journée */}
|
||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||
<div className="sticky left-0 top-0 z-[6] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||
{t('grille.positionsCount', { count: state.positions.length })}
|
||||
{t('grille.positionsCount', { count: matrix.data.positions.length })}
|
||||
</div>
|
||||
{shownDays.map((day) => (
|
||||
<div
|
||||
key={day.id}
|
||||
key={day.date}
|
||||
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||
style={{ gridColumn: 'span 4' }}
|
||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-tight">
|
||||
{day.shortLabel}
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</span>
|
||||
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Ligne 2 : légende de colonne + libellés de plage */}
|
||||
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||
{t('grille.assignedRequired')}
|
||||
</div>
|
||||
{shownDays.map((day) =>
|
||||
DAY_PARTS.map((part) => (
|
||||
<div
|
||||
key={`${day.id}-${part.code}`}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{part.label}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
{shownColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{column.time}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Lignes de postes */}
|
||||
{shownPositions.map((position) => (
|
||||
<PositionRow
|
||||
key={position.id}
|
||||
position={position}
|
||||
shownDays={shownDays}
|
||||
bySlot={index.bySlot}
|
||||
selectedSlot={state.selectedSlot}
|
||||
onSelect={(slot) => dispatch({ type: 'selectSlot', slot })}
|
||||
columns={shownColumns}
|
||||
selectedShiftId={selectedShiftId}
|
||||
onSelect={setSelectedShiftId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,8 +132,14 @@ export function GrillePage() {
|
||||
</div>
|
||||
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
{state.selectedSlot ? (
|
||||
<AssignmentPanel slot={state.selectedSlot} index={index} />
|
||||
{selected ? (
|
||||
<AssignmentPanel
|
||||
positionName={selected.position.name}
|
||||
positionLocation={selected.position.location}
|
||||
positionCategory={selected.position.category}
|
||||
shift={selected.shift}
|
||||
onClose={() => setSelectedShiftId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
|
||||
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
|
||||
@@ -163,11 +158,11 @@ export function GrillePage() {
|
||||
}
|
||||
|
||||
function LegendItem({
|
||||
swatchClass,
|
||||
swatch,
|
||||
glyph,
|
||||
children,
|
||||
}: {
|
||||
swatchClass: string;
|
||||
swatch: string;
|
||||
glyph: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -176,7 +171,7 @@ function LegendItem({
|
||||
<span
|
||||
className={cx(
|
||||
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
|
||||
swatchClass,
|
||||
swatch,
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
@@ -188,75 +183,64 @@ function LegendItem({
|
||||
|
||||
function PositionRow({
|
||||
position,
|
||||
shownDays,
|
||||
bySlot,
|
||||
selectedSlot,
|
||||
columns,
|
||||
selectedShiftId,
|
||||
onSelect,
|
||||
}: {
|
||||
position: import('@/domain/types').Position;
|
||||
shownDays: typeof FESTIVAL_DAYS;
|
||||
bySlot: Map<string, import('@/domain/types').Assignment[]>;
|
||||
selectedSlot: string | null;
|
||||
onSelect: (slot: string) => void;
|
||||
position: MatrixPosition;
|
||||
columns: LatticeColumn[];
|
||||
selectedShiftId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const byColumn = new Map<string, MatrixShift>();
|
||||
for (const shift of position.shifts) {
|
||||
byColumn.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, shift);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||
<span className="font-display text-[12px] font-bold tracking-tight">{position.name}</span>
|
||||
<span className="font-mono text-[8.5px] opacity-55">{position.zone}</span>
|
||||
<span className="truncate font-display text-[12px] font-bold tracking-tight">
|
||||
{position.name}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
|
||||
</div>
|
||||
{shownDays.map((day) =>
|
||||
DAY_PARTS.map((part) => {
|
||||
const need = position.needs.find(
|
||||
(n) => n.dayId === day.id && n.dayPartCode === part.code,
|
||||
);
|
||||
const key = slotKey(position.id, day.id, part.code);
|
||||
if (!need) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="h-9 border-b border-r border-ink bg-hatch-sand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const list = bySlot.get(key) ?? [];
|
||||
const count = list.length;
|
||||
const isCrit = count < need.minStaff;
|
||||
const isInc = count >= need.minStaff && count < need.idealStaff;
|
||||
const pending = list.some((a) => a.pending);
|
||||
const selected = selectedSlot === key;
|
||||
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onSelect(key)}
|
||||
className={cx(
|
||||
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
isCrit && 'bg-hatch-orange',
|
||||
isInc && 'bg-yellow-soft hover:bg-yellow',
|
||||
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[14px] font-bold">{count}</span>
|
||||
<span className="font-mono text-[10px] opacity-60">/{need.idealStaff}</span>
|
||||
<span
|
||||
className={cx(
|
||||
'font-mono text-[10px] font-bold',
|
||||
!isCrit && !isInc && 'text-green',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
{pending ? (
|
||||
<span className="absolute right-0 top-0 h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||
) : null}
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const shift = byColumn.get(column.key);
|
||||
if (!shift) {
|
||||
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
|
||||
}
|
||||
const assigned = shift.confirmed + shift.proposed;
|
||||
const isCrit = assigned < shift.min_staff;
|
||||
const isInc = !isCrit && assigned < shift.ideal_staff;
|
||||
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
||||
const selected = selectedShiftId === shift.shift_id;
|
||||
return (
|
||||
<div
|
||||
key={column.key}
|
||||
onClick={() => onSelect(shift.shift_id)}
|
||||
className={cx(
|
||||
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
isCrit && 'bg-hatch-orange',
|
||||
isInc && 'bg-yellow-soft hover:bg-yellow',
|
||||
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
|
||||
)}
|
||||
title={`${shift.confirmed} confirmé(s) · ${shift.proposed} proposé(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
|
||||
>
|
||||
<span className="font-mono text-[13px] font-bold">{shift.confirmed}</span>
|
||||
{shift.proposed > 0 ? (
|
||||
<span className="font-mono text-[9px] opacity-60">+{shift.proposed}</span>
|
||||
) : null}
|
||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
|
||||
{glyph}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user