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
+247
View File
@@ -0,0 +1,247 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function ConfigGrid() {
const { state, dispatch } = usePlanning();
const [positionId, setPositionId] = useState(state.positions[0]?.id ?? '');
const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0];
const totals = useMemo(() => {
let slots = 0;
let min = 0;
let ideal = 0;
state.positions.forEach((p) =>
p.needs.forEach((need) => {
slots += 1;
min += need.minStaff * dayPartHours(need.dayPartCode);
ideal += need.idealStaff * dayPartHours(need.dayPartCode);
}),
);
return { slots, min, ideal };
}, [state.positions]);
const positionMin = position.needs.reduce(
(sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode),
0,
);
const positionIdeal = position.needs.reduce(
(sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode),
0,
);
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.slots })}
</div>
{state.positions.map((entry) => {
const active = entry.id === position.id;
return (
<button
key={entry.id}
type="button"
onClick={() => setPositionId(entry.id)}
className={cx(
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
active
? 'border-l-[6px] bg-yellow'
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
)}
>
<div className="font-display text-[12.5px] font-bold leading-tight">
{entry.name} <span className="opacity-60">{entry.zone}</span>
</div>
<div className="font-mono text-[9px] opacity-60">
{t('config.grid.slotsCount', { count: entry.needs.length })}
</div>
</button>
);
})}
<button
type="button"
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addPosition')}
</button>
</div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div>
<div className="font-display text-[26px] font-black tracking-tight">
{position.name} — {position.zone}
</div>
<div className="font-mono text-[10.5px] opacity-65">
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionMin })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.minTotal')}
</div>
</div>
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionIdeal })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.idealTotal')}
</div>
</div>
</div>
</div>
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{FESTIVAL_DAYS.map((day) => (
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
</div>
{DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
if (!need) {
return (
<div
key={part.code}
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
>
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
{part.label}
</span>
<span className="font-display text-[10px] font-semibold opacity-55">
{t('config.grid.closed')}
</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
>
{t('config.grid.open')}
</button>
</div>
);
}
return (
<div
key={part.code}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
<NeedStepper
label={t('config.grid.min')}
value={need.minStaff}
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'min',
delta,
})
}
/>
<NeedStepper
label={t('config.grid.ideal')}
value={need.idealStaff}
accent
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'ideal',
delta,
})
}
/>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.close')}
>
✕
</button>
</div>
);
})}
</div>
))}
</div>
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
{t('config.grid.note')}
</div>
</div>
</div>
);
}
function NeedStepper({
label,
value,
accent = false,
onChange,
}: {
label: string;
value: number;
accent?: boolean;
onChange: (delta: number) => void;
}) {
return (
<span className="flex items-center gap-1">
<span className="font-display text-[8.5px] font-extrabold uppercase tracking-wide opacity-60">
{label}
</span>
<button
type="button"
onClick={() => onChange(-1)}
className={cx(
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-orange',
accent ? 'bg-yellow-soft' : 'bg-cream',
)}
>
–
</button>
<span className="min-w-[18px] text-center font-mono text-[14px] font-bold">{value}</span>
<button
type="button"
onClick={() => onChange(1)}
className={cx(
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-green hover:text-white',
accent ? 'bg-yellow-soft' : 'bg-cream',
)}
>
+
</button>
</span>
);
}