Front : retrait des compétences, disponibilités en fenêtres horaires

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
This commit is contained in:
AdamThomas
2026-09-10 23:01:59 +02:00
co-authored by Claude Sonnet 5
parent be51770b51
commit 36d108b47d
11 changed files with 487 additions and 439 deletions
+32 -29
View File
@@ -4,6 +4,7 @@ import type {
AccountRead,
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
EditionRead,
PositionCategory,
@@ -30,14 +31,14 @@ export interface MockState {
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>;
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
availability: Map<string, Set<string>>;
seq: number;
}
@@ -88,7 +89,6 @@ const LAST_NAMES = [
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
'Renard', 'Dupuy',
];
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
const ARTIST_POOL = [
'La Battante',
'Orchestre Miroir',
@@ -171,7 +171,6 @@ export function buildMockState(): MockState {
ends_at: iso(dateStr, bounds[2]),
min_staff,
ideal_staff,
required_skill_codes: raw.requiredSkills ?? [],
created_at: NOW,
updated_at: NOW,
});
@@ -193,26 +192,39 @@ export function buildMockState(): MockState {
);
const volunteers: VolunteerRead[] = [];
const availability = new Map<string, Set<string>>();
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}`;
const avail = new Set<string>();
// 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];
for (const [, startHour] of PART_BOUNDS) {
if (rnd() < (edge ? 0.4 : 0.6)) {
avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`);
}
if (rnd() < (edge ? 0.55 : 0.8)) {
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
}
}
availability.set(id, avail);
availability.set(id, windows);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : [];
const artist_ids = artists
.filter(() => rnd() < 0.25)
.map((a) => a.id);
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({
@@ -223,7 +235,6 @@ export function buildMockState(): MockState {
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
comment: null,
skill_codes,
artist_ids,
wished_position_ids,
created_at: NOW,
@@ -235,11 +246,7 @@ export function buildMockState(): MockState {
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
new Set(
DAY_DATES.flatMap((dateStr) =>
PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`),
),
),
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é';
@@ -262,7 +269,6 @@ export function buildMockState(): MockState {
const duration = hoursBetween(shift.starts_at, shift.ends_at);
const dateStr = shift.starts_at.slice(0, 10);
const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`;
const pool = volunteers
.filter(
@@ -274,13 +280,10 @@ export function buildMockState(): MockState {
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
const available = availability.get(v.id)?.has(availKey) ? 100 : 0;
const skilled =
shift.required_skill_codes.length > 0 &&
shift.required_skill_codes.every((c) => v.skill_codes.includes(c))
? 40
: 0;
return available + skilled - (totalHours.get(v.id) ?? 0) * 3;
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)) {