Aligne le frontend sur le back-end (étape 14). - Types : suppression de `skill_codes` / `required_skill_codes` / `AvailabilityStatus` / `MISSING_REQUIRED_SKILL` ; ajout de `AvailabilityRead` / `AvailabilitySet` et de la surface API `availability` (list / replace / listEdition) + hooks associés. - Fiche bénévole : bloc « compétences » remplacé par un éditeur de disponibilités (plages date + heures libres, `PUT` en bloc). - Assistant d'import : plus de colonnes compétences ni disponibilités (mapping réduit à l'identité) ; rapport simplifié. - Grille des besoins : chaque candidat porte un badge de couverture (disponible / partiel / non déclaré) calculé sur ses fenêtres, et la liste est triée « disponibles d'abord ». Nouvel util `availabilityCoverage` / `uncoveredMinutes` dans `lib/planning`, portage du moteur back-end. - Mock : `state.availability` passe à des fenêtres `AvailabilityRead[]`, validation `OUTSIDE_AVAILABILITY` recalculée sur l'union des fenêtres, jeu de démo et import CSV adaptés. typecheck / lint / build / smoke au vert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { hoursBetween } from '@/lib/time';
|
|
|
|
import type {
|
|
AccountRead,
|
|
ArtistRead,
|
|
AssignmentRead,
|
|
AvailabilityRead,
|
|
DayPartRead,
|
|
EditionRead,
|
|
PositionCategory,
|
|
PositionRead,
|
|
ShiftRead,
|
|
VolunteerRead,
|
|
} from '../schema';
|
|
|
|
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. */
|
|
availability: Map<string, AvailabilityRead[]>;
|
|
positions: PositionRead[];
|
|
shifts: ShiftRead[];
|
|
assignments: AssignmentRead[];
|
|
accounts: AccountRead[];
|
|
/** accountId -> mot de passe en clair (mock uniquement). */
|
|
passwords: 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';
|
|
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`;
|
|
}
|
|
|
|
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,
|
|
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.
|
|
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,
|
|
created_at: NOW,
|
|
updated_at: NOW,
|
|
})),
|
|
);
|
|
|
|
const volunteers: VolunteerRead[] = [];
|
|
const availability = new Map<string, AvailabilityRead[]>();
|
|
let availSeq = 0;
|
|
const window = (
|
|
volunteerId: string,
|
|
date: string,
|
|
start_time: string,
|
|
end_time: string,
|
|
): AvailabilityRead => ({
|
|
id: `av${availSeq++}`,
|
|
edition_id,
|
|
volunteer_id: volunteerId,
|
|
date,
|
|
start_time,
|
|
end_time,
|
|
created_at: NOW,
|
|
updated_at: NOW,
|
|
});
|
|
|
|
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}`;
|
|
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
|
|
const windows: AvailabilityRead[] = [];
|
|
for (const dateStr of DAY_DATES) {
|
|
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
|
|
if (rnd() < (edge ? 0.55 : 0.8)) {
|
|
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
|
|
}
|
|
}
|
|
availability.set(id, windows);
|
|
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] : [];
|
|
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,
|
|
created_at: NOW,
|
|
updated_at: NOW,
|
|
});
|
|
}
|
|
|
|
const me = volunteers[0];
|
|
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
|
|
availability.set(
|
|
me.id,
|
|
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
|
|
);
|
|
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 {
|
|
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
|
|
? 100
|
|
: 0;
|
|
return available - (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,
|
|
created_at: NOW,
|
|
updated_at: NOW,
|
|
};
|
|
|
|
return {
|
|
edition,
|
|
dayParts,
|
|
artists,
|
|
volunteers,
|
|
positions,
|
|
shifts,
|
|
assignments,
|
|
accounts,
|
|
passwords,
|
|
availability,
|
|
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;
|
|
}
|