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
@@ -0,0 +1,251 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Badge, Btn } from '@/components/ui';
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
import type { PlanningIndex } from '@/domain/selectors';
import { t } from '@/i18n';
function violationText(violation: Violation): string {
return t(`validation.${violation.code}`, violation.params);
}
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
const { state, dispatch } = usePlanning();
const [positionId, dayId, dayPartCode] = slot.split('|');
const positionsById = useMemo(
() => new Map(state.positions.map((p) => [p.id, p])),
[state.positions],
);
const position = positionsById.get(positionId);
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
const durationHours = dayPartHours(dayPartCode);
const candidates = useMemo(() => {
if (!position || !need) return [];
return state.volunteers
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
.map((volunteer) => {
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
const violations = evaluateAssignment({
volunteer,
target: { position, dayId, dayPartCode },
currentAssignments,
positionsById,
thresholds: {
maxDailyHours: state.config.maxDailyHours,
minRestHours: state.config.minRestHours,
},
});
const blocked = hasBlocking(violations);
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
const warnings = violations.filter((v) => v.severity === 'WARNING');
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
const wanted = volunteer.preferredCategories.includes(position.category);
const hours = index.totalHours.get(volunteer.id) ?? 0;
const score =
(available ? 100 : 0) +
(wanted ? 40 : 0) -
warnings.length * 12 -
hours * 2 -
(blocked ? 1000 : 0);
return {
volunteer,
blocked,
blocking,
warnings,
available,
wanted,
hours,
slots: currentAssignments.length,
score,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, 9);
}, [
position,
need,
state.volunteers,
state.config.maxDailyHours,
state.config.minRestHours,
assignedList,
index,
positionsById,
dayId,
dayPartCode,
]);
if (!position || !need) {
return (
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
{t('grille.panel.empty.body')}
</div>
);
}
const day = festivalDay(dayId);
const part = dayPart(dayPartCode);
return (
<div>
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="font-display text-[16px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10px] text-yellow">
{position.zone} · {position.location}
</div>
</div>
<button
type="button"
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
aria-label={t('action.close')}
>
✕
</button>
</div>
<div className="mt-[9px] flex items-stretch gap-2">
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
{day.shortLabel} · {part.label}
</div>
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
{t('grille.panel.slotDuration', { hours: durationHours })}
</div>
</div>
<div className="mt-[9px] flex gap-[14px] font-mono text-[10px]">
<span>
{t('grille.panel.assigned')}{' '}
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
</span>
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
</div>
</div>
<div className="border-b-2 border-ink px-[13px] py-3">
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.assignmentsTitle', { count: assignedList.length })}
</div>
{assignedList.length === 0 ? (
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
) : (
assignedList.map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
if (!volunteer) return null;
return (
<div
key={assignment.id}
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
>
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
{volunteer.initials}
</span>
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
{volunteer.fullName}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursTotal', {
hours: index.totalHours.get(volunteer.id) ?? 0,
})}
</span>
</span>
{assignment.status === 'CONFIRMED' ? (
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
) : (
<Badge tone="proposed">{t('assignment.status.proposed')}</Badge>
)}
<button
type="button"
onClick={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
✕
</button>
</div>
);
})
)}
</div>
<div className="px-[13px] py-3">
<div className="mb-[9px] flex items-baseline gap-2">
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.candidates')}
</span>
<span className="font-mono text-[9px] opacity-60">
{t('grille.panel.candidatesHint')}
</span>
</div>
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
<div
key={volunteer.id}
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
>
<div className="flex items-center gap-2 p-[7px]">
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
{volunteer.initials}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.fullName}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursAssigned', { hours, slots })}
</span>
</span>
{blocked ? (
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
{t('grille.panel.blocked')}
</span>
) : (
<Btn onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
{t('grille.panel.assign')}
</Btn>
)}
</div>
{(available || wanted) && (
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
{available ? (
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.availableTag')}
</span>
) : null}
{wanted ? (
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.wantedTag')}
</span>
) : null}
</div>
)}
{blocked && blocking ? (
<div className="flex items-stretch border-t-2 border-ink">
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
⛔
</span>
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('grille.panel.blockingPrefix', { reason: violationText(blocking) })}
</span>
</div>
) : null}
{!blocked && warnings.length > 0 ? (
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
{t('grille.panel.warningsTitle')}
</div>
{warnings.map((warning, i) => (
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
· {violationText(warning)}
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
);
}