Files
benevoles/frontend/src/api/mock/dataset.ts
T
AdamThomasandClaude Opus 5.5 16d8fb44a7
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s
Écrans bénévole et référent redessinés, messages aux bénévoles
Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:14 +01:00

518 lines
19 KiB
TypeScript

import { hoursBetween } from '@/lib/time';
import type {
AccountRead,
ArtistRead,
AssignmentRead,
BadgeFieldRead,
DayPartRead,
EditionRead,
FeePaymentStatus,
FormFieldType,
FormPaymentOptionRead,
PositionCategory,
PositionRead,
ShiftRead,
VolunteerRead,
} from '../schema';
/** Inscription en attente de paiement en ligne (mode démonstration) — voir
* `PendingFormSubmission` côté back-end (CLAUDE.md § 2, Paiement de la
* cotisation). `pollCount` simule le délai de confirmation réel (webhook
* asynchrone) : la première vérification reste `PENDING`, la suivante
* confirme — sans vrai réseau ni vrai SumUp en mode mock. */
export interface MockPendingSubmission {
id: string;
editionId: string;
firstName: string | null;
lastName: string | null;
email: string | null;
phone: string | null;
comment: string | null;
companionWishNote: string | null;
available: boolean;
positionIds: string[];
artistIds: string[];
photo: string | null; // data URL
paymentLabel: string;
paymentAmount: number;
requiresMecenatInfo: boolean;
mecenatAddress: string | null;
mecenatCity: string | null;
mecenatPostalCode: string | null;
mecenatCountry: string | null;
checkoutId: string | null;
status: FeePaymentStatus | null;
promotedVolunteerId: string | null;
pollCount: number;
}
const PART_LABELS: Record<string, string> = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' };
const PART_CODES: Record<string, string> = { M: 'MATIN', A: 'APRES_MIDI', S: 'SOIREE', N: 'NUIT' };
const hhmm = (h: number) => `${String(h % 24).padStart(2, '0')}:00:00`;
// Jeu de démonstration déterministe, mis en forme comme les réponses de l'API.
// Sert quand `VITE_API_MODE=mock` (défaut tant que le back-end n'a pas de seed).
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001';
/** Identifiants de démonstration (mode mock uniquement). */
export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr';
export const DEMO_COORDINATOR_PASSWORD = 'demo';
export interface MockState {
edition: EditionRead;
dayParts: DayPartRead[];
artists: ArtistRead[];
volunteers: VolunteerRead[];
/** volunteerId -> fenêtres de disponibilité déclarées. */
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
accounts: AccountRead[];
/** accountId -> mot de passe en clair (mock uniquement). */
passwords: Map<string, string>;
badgeTemplate: {
frontImage: string | null; // data URL
frontImageType: string | null;
frontImageWidthMm: number | null;
frontImageHeightMm: number | null;
backImage: string | null; // data URL
backImageType: string | null;
fields: BadgeFieldRead[];
};
formTemplate: {
isOpen: boolean;
fields: FormFieldType[];
logo: string | null; // data URL
logoType: string | null;
description: string | null;
sumupMerchantCode: string | null;
sumupApiKey: string | null;
accessToken: string;
};
formPaymentOptions: FormPaymentOptionRead[];
/** Inscriptions en attente de paiement en ligne (voir CLAUDE.md § 2,
* Paiement de la cotisation) — clé : jeton public (id). */
pendingSubmissions: Map<string, MockPendingSubmission>;
/** volunteerId -> photo (formulaire public), data URL. */
photos: Map<string, string>;
seq: number;
}
const DAY_DATES = ['2026-10-15', '2026-10-16', '2026-10-17', '2026-10-18'];
const PART_BOUNDS: Array<[string, number, number]> = [
['M', 8, 12],
['A', 12, 17],
['S', 17, 22],
['N', 22, 26], // 26 => 02 h le lendemain
];
type RawSlots = Record<string, Array<[string, number, number]>>;
interface RawPosition {
id: string;
category: PositionCategory;
name: string;
location: string;
requiredSkills?: string[];
slots: RawSlots;
}
const RAW_POSITIONS: RawPosition[] = [
{ id: 'BAR', category: 'BAR', name: 'Bar — Grande scène', location: 'Chapiteau nord', slots: { '2026-10-16': [['A', 3, 4], ['S', 4, 6], ['N', 3, 5]], '2026-10-17': [['A', 3, 4], ['S', 5, 6], ['N', 4, 5]], '2026-10-18': [['M', 2, 3]] } },
{ id: 'BARV', category: 'BAR', name: 'Bar — Village assos', location: 'Village est', slots: { '2026-10-16': [['A', 2, 3], ['S', 3, 4]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 3]], '2026-10-18': [['M', 2, 2]] } },
{ id: 'BILA', category: 'TICKETING', name: 'Billetterie — Entrée A', location: 'Portail est', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 4, 5], ['S', 3, 4]], '2026-10-17': [['A', 4, 5], ['S', 3, 4]] } },
{ id: 'BILB', category: 'TICKETING', name: 'Billetterie — Entrée B', location: 'Portail ouest', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 2, 3]] } },
{ id: 'ACCB', category: 'WELCOME', name: 'Accueil — Bénévoles', location: 'Base bénévoles', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-18': [['M', 2, 2]] } },
{ id: 'ACCA', category: 'WELCOME', name: 'Accueil — Artistes', location: 'Loges', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } },
{ id: 'PARN', category: 'PARKING', name: 'Parking — Nord', location: 'Route de Bréal', slots: { '2026-10-16': [['A', 3, 4], ['S', 3, 4], ['N', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 4, 5], ['N', 2, 3]] } },
{ id: 'PARS', category: 'PARKING', name: 'Parking — Sud', location: 'Chemin des Prés', slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 2]] } },
{ id: 'PROS', category: 'CLEANING', name: 'Propreté — Site', location: 'Ensemble du site', slots: { '2026-10-16': [['S', 2, 3], ['N', 3, 4]], '2026-10-17': [['M', 3, 4], ['S', 2, 3], ['N', 3, 5]], '2026-10-18': [['M', 4, 6], ['A', 4, 6]] } },
{ id: 'PROA', category: 'CLEANING', name: 'Propreté — Sanitaires', location: 'Blocs 1 à 4', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['M', 2, 2], ['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } },
{ id: 'CATB', category: 'CATERING', name: 'Catering — Bénévoles', location: 'Tente cuisine', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-17': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-18': [['M', 2, 3], ['A', 2, 2]] } },
{ id: 'CATA', category: 'CATERING', name: 'Catering — Artistes', location: 'Backstage', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } },
{ id: 'MONS', category: 'SETUP', name: 'Montage — Scène', location: 'Grande scène', requiredSkills: ['CARISTE'], slots: { '2026-10-15': [['M', 6, 8], ['A', 6, 8], ['S', 4, 5]], '2026-10-18': [['A', 6, 8], ['S', 4, 6]] } },
{ id: 'MONI', category: 'SETUP', name: 'Montage — Signalétique', location: 'Site entier', slots: { '2026-10-15': [['M', 3, 4], ['A', 3, 4]], '2026-10-18': [['A', 3, 4]] } },
{ id: 'REG', category: 'STAGE', name: 'Régie — Régie centrale', location: 'Cabane régie', requiredSkills: ['REGIE'], slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } },
];
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 ARTIST_POOL = [
'La Battante',
'Orchestre Miroir',
'Kntongo',
'Louve',
'Les Frères Vent',
'Nuage Bleu',
'Sœur Machine',
'Comète',
];
const NOW = '2026-10-10T09:00:00';
/** Date d'inscription de démonstration du i-ème bénévole : plus dense en
* fin de période (relances), jamais après `NOW`. */
function registeredAt(i: number): string {
const daysBefore = Math.floor(((i * 37) % 64) ** 1.4 / 9);
const d = new Date(Date.UTC(2026, 9, 10 - daysBefore, 9 + (i % 10), (i * 13) % 60));
return d.toISOString().slice(0, 19);
}
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
const OVERLOADED = [3, 9];
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
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;
};
}
function iso(dateStr: string, hour: number): string {
if (hour >= 24) {
const [y, m, d] = dateStr.split('-').map(Number);
const next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
return `${next}T${String(hour - 24).padStart(2, '0')}:00:00`;
}
return `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
}
function demoEmail(firstName: string, lastName: string): string {
const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
return `${n(firstName)}.${n(lastName)}@mail.fr`;
}
const DEMO_POSITION_MESSAGES: Record<string, string> = {
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
};
export function buildMockState(): MockState {
const rnd = mulberry32(20260715);
const edition_id = MOCK_EDITION_ID;
const positions: PositionRead[] = RAW_POSITIONS.map((p) => ({
id: p.id,
edition_id,
name: p.name,
location: p.location,
category: p.category,
lead_account_id: null,
// Quelques consignes de démo (voir `PositionRead.message`).
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
created_at: NOW,
updated_at: NOW,
}));
const dayParts: DayPartRead[] = PART_BOUNDS.map(([code, start, end], i) => ({
id: `dp_${code}`,
edition_id,
code: PART_CODES[code] ?? code,
label: PART_LABELS[code] ?? code,
start_time: hhmm(start),
end_time: hhmm(end),
order: i,
created_at: NOW,
updated_at: NOW,
}));
const shifts: ShiftRead[] = [];
let shiftSeq = 1;
for (const raw of RAW_POSITIONS) {
for (const [dateStr, slots] of Object.entries(raw.slots)) {
for (const [code, min_staff, ideal_staff] of slots) {
const bounds = PART_BOUNDS.find((b) => b[0] === code)!;
shifts.push({
id: `s${shiftSeq}`,
edition_id,
position_id: raw.id,
day_part_id: `dp_${code}`,
starts_at: iso(dateStr, bounds[1]),
ends_at: iso(dateStr, bounds[2]),
min_staff,
ideal_staff,
created_at: NOW,
updated_at: NOW,
});
shiftSeq += 1;
}
}
}
// Programmation : 2 artistes par jour de l'édition, à des horaires distincts.
const ARTIST_SLOTS: Array<[string, string]> = [
['18:00:00', '19:00:00'],
['20:30:00', '22:00:00'],
];
const artists: ArtistRead[] = DAY_DATES.flatMap((dateStr, d) =>
[0, 1].map((k) => ({
id: `art${d * 2 + k}`,
edition_id,
name: ARTIST_POOL[(d * 2 + k) % ARTIST_POOL.length],
performance_date: dateStr,
start_time: ARTIST_SLOTS[k][0],
end_time: ARTIST_SLOTS[k][1],
created_at: NOW,
updated_at: NOW,
})),
);
const volunteers: VolunteerRead[] = [];
for (let i = 0; i < 64; i += 1) {
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const id = `v${i}`;
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids =
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
// Vœu de compagnon (couple, amis) : un autre bénévole de la liste, jamais
// soi-même (décalage d'au moins un cran).
const wished_companion_ids =
rnd() < 0.2 ? [`v${(i + 1 + Math.floor(rnd() * 5)) % 64}`] : [];
volunteers.push({
id,
edition_id,
first_name,
last_name,
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
comment: null,
artist_ids,
wished_position_ids,
wished_companion_ids,
has_photo: false,
companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false,
willing_to_pay_fee: false,
fee_payment_label: null,
fee_amount: null,
mecenat_address: null,
mecenat_city: null,
mecenat_postal_code: null,
mecenat_country: null,
fee_payment_status: null,
fee_paid_at: null,
fee_transaction_code: null,
fee_transaction_id: null,
created_at: NOW,
updated_at: NOW,
});
// Démo des statistiques : inscriptions étalées sur ~5 semaines et
// quelques cotisations — dérivées de `i`, pas de `rnd()`, pour ne rien
// décaler du reste du jeu de démonstration.
const v = volunteers[volunteers.length - 1];
v.created_at = registeredAt(i);
if (i % 4 === 1) v.willing_to_pay_fee = true;
if (i % 7 === 2 || i % 11 === 3) {
const mecenat = i % 11 === 3;
Object.assign(v, {
willing_to_pay_fee: true,
fee_payment_label: mecenat ? 'Mécénat' : 'Cotisation classique',
fee_amount: mecenat ? 40 : 15,
fee_payment_status: 'PAID',
fee_paid_at: v.created_at,
fee_transaction_code: `TDEMO${String(i).padStart(5, '0')}`,
fee_transaction_id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
...(mecenat
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
: {}),
});
}
}
const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
NEVER_ASSIGNED.forEach((i) => {
volunteers[i].comment = 'Inscrit, pas encore affecté';
});
const assignments: AssignmentRead[] = [];
const busy = new Set<string>();
const dayHours = new Map<string, number>();
const totalHours = new Map<string, number>();
let seq = 1;
let slotIndex = 0;
for (const shift of shifts) {
slotIndex += 1;
let target = shift.ideal_staff;
if (slotIndex % 7 === 0) target = Math.max(1, shift.min_staff - 1);
else if (slotIndex % 5 === 0) target = shift.min_staff;
else if (slotIndex % 3 === 0) target = Math.max(shift.min_staff, shift.ideal_staff - 1);
else if (slotIndex % 8 === 0) target = shift.ideal_staff + 2; // sureffectif
const duration = hoursBetween(shift.starts_at, shift.ends_at);
const dateStr = shift.starts_at.slice(0, 10);
const pool = volunteers
.filter(
(v, idx) =>
!NEVER_ASSIGNED.has(idx) &&
!busy.has(`${v.id}|${shift.id}`) &&
(dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration <= 8,
)
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
return -(totalHours.get(v.id) ?? 0) * 3;
}
for (const v of pool.slice(0, target)) {
assignments.push({
id: `a${seq}`,
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
source: 'SOLVER',
locked: false,
created_at: NOW,
updated_at: NOW,
});
seq += 1;
busy.add(`${v.id}|${shift.id}`);
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
totalHours.set(v.id, (totalHours.get(v.id) ?? 0) + duration);
}
}
OVERLOADED.forEach((index) => {
const v = volunteers[index];
const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17');
const shift = friday.find(
(s) =>
!assignments.some(
(a) =>
a.volunteer_id === v.id &&
shiftsOverlap(shifts.find((x) => x.id === a.shift_id), s),
),
);
if (!shift) return;
assignments.push({
id: `a${seq}`,
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
source: 'MANUAL',
locked: false,
created_at: NOW,
updated_at: NOW,
});
seq += 1;
});
// ---- Comptes ----
const accounts: AccountRead[] = [];
const passwords = new Map<string, string>();
const coordinator: AccountRead = {
id: `acc${seq++}`,
edition_id,
email: DEMO_COORDINATOR_EMAIL,
role: 'COORDINATOR',
volunteer_id: null,
auth_method: 'password',
created_at: NOW,
updated_at: NOW,
};
accounts.push(coordinator);
passwords.set(coordinator.id, DEMO_COORDINATOR_PASSWORD);
// Trois référents, rattachés à trois postes (lien magique, sans mot de passe).
['MONS', 'MONI', 'REG'].forEach((positionId, i) => {
const account: AccountRead = {
id: `acc${seq++}`,
edition_id,
email: `referent${i + 1}@lessonsdautomne.fr`,
role: 'LEAD',
volunteer_id: null,
auth_method: 'magic_link',
created_at: NOW,
updated_at: NOW,
};
accounts.push(account);
const position = positions.find((p) => p.id === positionId);
if (position) position.lead_account_id = account.id;
});
// Bénévoles connectés : tous ceux qui ont une adresse email.
volunteers
.filter((v) => v.email)
.forEach((v) => {
accounts.push({
id: `acc${seq++}`,
edition_id,
email: v.email as string,
role: 'VOLUNTEER',
volunteer_id: v.id,
auth_method: 'magic_link',
created_at: NOW,
updated_at: NOW,
});
});
const edition: EditionRead = {
id: edition_id,
year: 2026,
name: "Les Sons d'Automne",
start_date: DAY_DATES[0],
end_date: DAY_DATES[DAY_DATES.length - 1],
retention_days: 365,
volunteer_message:
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
volunteer_message_updated_at: NOW,
created_at: NOW,
updated_at: NOW,
};
return {
edition,
dayParts,
artists,
volunteers,
positions,
shifts,
assignments,
accounts,
passwords,
badgeTemplate: {
frontImage: null,
frontImageType: null,
frontImageWidthMm: null,
frontImageHeightMm: null,
backImage: null,
backImageType: null,
fields: [],
},
formTemplate: {
isOpen: true, fields: [], logo: null, logoType: null, description: null,
sumupMerchantCode: null, sumupApiKey: null, accessToken: 'demo-access-token',
},
formPaymentOptions: [],
pendingSubmissions: new Map(),
photos: new Map(),
seq,
};
}
function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
if (!a) return false;
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}