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
+205
View File
@@ -0,0 +1,205 @@
import {
DAY_PARTS,
DEMO_CATEGORIES,
FESTIVAL_DAYS,
availabilityKey,
dayPartHours,
} from '@/domain/festival';
import type { Assignment, AssignmentStatus, Position, Volunteer } from '@/domain/types';
import { buildPositions } from './positions';
export interface DemoDataset {
positions: Position[];
volunteers: Volunteer[];
assignments: Assignment[];
removed: { volunteerName: string; positionName: string; zone: string; dayShortLabel: string; dayPartLabel: string }[];
nextAssignmentSeq: number;
}
// Générateur pseudo-aléatoire déterministe (mulberry32), repris du design pour
// obtenir exactement le même jeu de démonstration à chaque chargement.
function mulberry32(seed: number): () => number {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const FIRST_NAMES = [
'Camille', 'Yanis', 'Léa', 'Mathis', 'Soraya', 'Thomas', 'Awa', 'Julien', 'Chloé', 'Nadia',
'Hugo', 'Inès', 'Baptiste', 'Manon', 'Karim', 'Élodie', 'Rémi', 'Sarah', 'Antoine', 'Fanny',
'Malik', 'Clara', 'Victor', 'Jeanne',
];
const LAST_NAMES = [
'Fournier', 'Berger', 'Moreau', 'Lambert', 'Benali', 'Rey', 'Diallo', 'Chevalier', 'Marchand',
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
'Renard', 'Dupuy',
];
const VOLUNTEER_COUNT = 64;
const NEVER_ASSIGNED_INDEXES = [7, 21, 34, 48, 55];
const OVERLOADED_INDEXES = [3, 9];
function statusFromRoll(roll: number): AssignmentStatus {
if (roll < 0.72) return 'CONFIRMED';
if (roll < 0.95) return 'PROPOSED';
return 'DECLINED';
}
export function buildDemoDataset(): DemoDataset {
const positions = buildPositions();
const rnd = mulberry32(20260715);
const volunteers: Volunteer[] = [];
for (let i = 0; i < VOLUNTEER_COUNT; i += 1) {
const firstName = FIRST_NAMES[i % FIRST_NAMES.length];
const lastName = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const availability = new Set<string>();
FESTIVAL_DAYS.forEach((day) => {
DAY_PARTS.forEach((part) => {
const isEdgeDay = day.index === 0 || day.index === 3;
if (rnd() < (isEdgeDay ? 0.4 : 0.6)) {
availability.add(availabilityKey(day.id, part.code));
}
});
});
const preferredCategories = [DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]];
if (rnd() < 0.45) {
preferredCategories.push(DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]);
}
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const phone = `06 ${pair()} ${pair()} ${pair()} ${pair()}`;
volunteers.push({
id: `v${i}`,
firstName,
lastName,
fullName: `${firstName} ${lastName}`,
initials: firstName[0] + lastName[0],
phone,
availability,
preferredCategories,
});
}
// Bénévole de démonstration (« moi ») : disponible partout, demande Bar + Accueil.
const me = volunteers[0];
me.preferredCategories = ['BAR', 'WELCOME'];
FESTIVAL_DAYS.forEach((day) =>
DAY_PARTS.forEach((part) => me.availability.add(availabilityKey(day.id, part.code))),
);
// Référent de démonstration.
volunteers[1].preferredCategories = ['BAR'];
// Bénévoles inscrits mais jamais affectés (« oubliés »).
NEVER_ASSIGNED_INDEXES.forEach((i) => {
volunteers[i].neverAssigned = true;
});
const assignments: Assignment[] = [];
const busy: Record<string, boolean> = {};
const dailyHours: Record<string, number> = {};
const totalHours: Record<string, number> = {};
let seq = 1;
let slotIndex = 0;
positions.forEach((position) => {
FESTIVAL_DAYS.forEach((day) => {
position.needs
.filter((need) => need.dayId === day.id)
.forEach((need) => {
slotIndex += 1;
let target = need.idealStaff;
if (slotIndex % 7 === 0) target = Math.max(1, need.minStaff - 1);
else if (slotIndex % 5 === 0) target = need.minStaff;
else if (slotIndex % 3 === 0) target = Math.max(need.minStaff, need.idealStaff - 1);
const hours = dayPartHours(need.dayPartCode);
const pool = volunteers
.filter(
(v) =>
!v.neverAssigned &&
!busy[`${v.id}|${day.id}|${need.dayPartCode}`] &&
(dailyHours[`${v.id}|${day.id}`] ?? 0) + hours <= 8,
)
.sort((a, b) => score(b) - score(a));
function score(v: Volunteer): number {
const available = v.availability.has(availabilityKey(day.id, need.dayPartCode)) ? 100 : 0;
const wanted = v.preferredCategories.includes(position.category) ? 40 : 0;
return available + wanted - (totalHours[v.id] ?? 0) * 3;
}
pool.slice(0, target).forEach((v) => {
const status = statusFromRoll(rnd());
assignments.push({
id: `a${seq}`,
volunteerId: v.id,
positionId: position.id,
dayId: day.id,
dayPartCode: need.dayPartCode,
status,
source: 'SOLVER',
locked: false,
pending: null,
});
seq += 1;
if (status !== 'DECLINED') {
busy[`${v.id}|${day.id}|${need.dayPartCode}`] = true;
dailyHours[`${v.id}|${day.id}`] = (dailyHours[`${v.id}|${day.id}`] ?? 0) + hours;
totalHours[v.id] = (totalHours[v.id] ?? 0) + hours;
}
});
});
});
});
// « Moi » : 1 créneau confirmé puis des propositions à confirmer.
assignments
.filter((a) => a.volunteerId === me.id)
.forEach((a, i) => {
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
});
// Deux surcharges volontaires : plus de 8 h sur le vendredi.
OVERLOADED_INDEXES.forEach((index) => {
const volunteer = volunteers[index];
const dayId = 'ven';
const taken = assignments
.filter((a) => a.volunteerId === volunteer.id && a.dayId === dayId)
.map((a) => a.dayPartCode);
const freeCode = ['A', 'S', 'N', 'M'].find((code) => !taken.includes(code));
if (!freeCode) return;
const position = positions.find((p) =>
p.needs.some((n) => n.dayId === dayId && n.dayPartCode === freeCode),
);
if (!position) return;
assignments.push({
id: `a${seq}`,
volunteerId: volunteer.id,
positionId: position.id,
dayId,
dayPartCode: freeCode,
status: 'CONFIRMED',
source: 'MANUAL',
locked: false,
pending: null,
});
seq += 1;
});
// Modifications non encore publiées.
assignments.forEach((a, i) => {
if (i % 29 === 7) a.pending = 'ADD';
});
const removed = [
{ volunteerName: 'Sarah Prévost', positionName: 'Parking', zone: 'Nord', dayShortLabel: 'VEN 17', dayPartLabel: '17–22' },
{ volunteerName: 'Karim Benali', positionName: 'Propreté', zone: 'Site', dayShortLabel: 'SAM 18', dayPartLabel: '12–17' },
];
return { positions, volunteers, assignments, removed, nextAssignmentSeq: seq };
}