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 = { M: 'Matin', A: 'Après-midi', S: 'Soirée', N: 'Nuit' }; const PART_CODES: Record = { 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; 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; /** volunteerId -> photo (formulaire public), data URL. */ photos: Map; 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>; 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 = { '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(); const dayHours = new Map(); const totalHours = new Map(); 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(); 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; }