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
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
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 };
|
||
}
|