Frontend : intégration du design Claude Design (6 écrans)

Portage du projet « Bénévoles Festival.dc.html » en implémentation statique
pilotée par un jeu de démonstration déterministe (portage du générateur du
design : 15 postes, 64 bénévoles, ~209 affectations).

- Système visuel néo-brutaliste : palette + primitives Tailwind, polices
  Archivo / DM Mono, primitives dans components/ui.tsx.
- Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation
  (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel
  de publication.
- État applicatif : PlanningProvider (reducer) — affecter / désaffecter /
  publier / confirmer / éditer besoins et bénévoles / import simulé / pointage.
- Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés,
  alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole,
  05 Mobile référent, 06 Configuration (4 sous-onglets).
- i18n : aucun texte d'interface en dur, locales/fr.json.
- Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans
  (npm run smoke).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent 5154a3c61b
commit ac469f0cd1
35 changed files with 4267 additions and 125 deletions
+262
View File
@@ -0,0 +1,262 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Chip } from '@/components/ui';
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 { 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 index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const shownDays =
dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter);
const shownPositions =
categoryFilter === 'all'
? state.positions
: state.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`;
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}
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.position')}</span>
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
{t('filter.all')}
</Chip>
{DEMO_CATEGORIES.map((category) => (
<Chip
key={category}
active={categoryFilter === category}
onClick={() => setCategoryFilter(category)}
>
{t(categoryLabelKey(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="▲">
{t('grille.legend.critical')}
</LegendItem>
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
{t('grille.legend.incomplete')}
</LegendItem>
<LegendItem swatchClass="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="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 })}
</div>
{shownDays.map((day) => (
<div
key={day.id}
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' }}
>
<span className="font-display text-[12px] font-black tracking-tight">
{day.shortLabel}
</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>
)),
)}
{/* 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 })}
/>
))}
</div>
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('grille.footnote')}
</div>
</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} />
) : (
<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" />
<div className="font-display text-[14px] font-extrabold leading-tight">
{t('grille.panel.empty.title')}
</div>
<div className="font-mono text-[10.5px] leading-relaxed opacity-65">
{t('grille.panel.empty.body')}
</div>
</div>
)}
</aside>
</div>
</div>
);
}
function LegendItem({
swatchClass,
glyph,
children,
}: {
swatchClass: string;
glyph: string;
children: React.ReactNode;
}) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span
className={cx(
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
swatchClass,
)}
>
{glyph}
</span>
{children}
</span>
);
}
function PositionRow({
position,
shownDays,
bySlot,
selectedSlot,
onSelect,
}: {
position: import('@/domain/types').Position;
shownDays: typeof FESTIVAL_DAYS;
bySlot: Map<string, import('@/domain/types').Assignment[]>;
selectedSlot: string | null;
onSelect: (slot: string) => void;
}) {
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>
</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>
);
}),
)}
</>
);
}