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
+70 -124
View File
@@ -1,3 +1,4 @@
import { uncoveredMinutes } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
@@ -7,7 +8,8 @@ import type {
ArtistUpdate,
AssignmentRead,
AssignmentValidateResult,
AvailabilityStatus,
AvailabilityRead,
AvailabilitySet,
DayPartCreate,
DayPartRead,
DayPartUpdate,
@@ -92,6 +94,17 @@ const slugCode = (label: string): string =>
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
[...ws].sort(
(a, b) =>
a.volunteer_id.localeCompare(b.volunteer_id) ||
a.date.localeCompare(b.date) ||
a.start_time.localeCompare(b.start_time),
);
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
function resolveWishes<T extends { id: string }>(
pool: T[],
@@ -131,7 +144,6 @@ function generateMissingShifts(positionId: string): number {
ends_at,
min_staff: 0,
ideal_staff: 0,
required_skill_codes: [],
created_at: now,
updated_at: now,
});
@@ -157,21 +169,8 @@ const IMPORT_HINTS: Record<string, string[]> = {
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
comment: ['commentaire', 'remarque', 'note', 'precision'],
skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'],
};
const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const AVAIL_AVAILABLE = new Set([
'x', 'o', 'oui', 'y', 'yes', 'ok', '1', 'vrai', 'true', 'dispo', 'disponible',
'present', 'presente', 'matin', 'aprem', 'apres-midi', 'apres midi', 'midi',
'soir', 'soiree', 'journee', 'jour',
]);
const AVAIL_IF_NEEDED = new Set([
'si besoin', 'sib', 'peut-etre', 'peut etre', '?', '2', 'backup', 'reserve', 'au besoin',
]);
const AVAIL_UNAVAILABLE = new Set([
'non', 'n', 'no', '0', 'faux', 'false', 'indispo', 'indisponible', 'absent', '-', 'x-',
]);
const AVAIL_EMPTY = new Set(['', 'na', 'n/a', 'nc']);
function normalizeText(value: string): string {
return (value ?? '')
@@ -182,15 +181,6 @@ function normalizeText(value: string): string {
.replace(/\s+/g, ' ');
}
function parseAvailabilityCell(value: string): [AvailabilityStatus | null, boolean] {
const n = normalizeText(value);
if (AVAIL_EMPTY.has(n)) return [null, true];
if (AVAIL_AVAILABLE.has(n)) return ['AVAILABLE', true];
if (AVAIL_IF_NEEDED.has(n)) return ['IF_NEEDED', true];
if (AVAIL_UNAVAILABLE.has(n)) return ['UNAVAILABLE', true];
return [null, false];
}
function splitCsvLine(line: string, delimiter: string): string[] {
const cells: string[] = [];
let cur = '';
@@ -292,17 +282,20 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// OUTSIDE_AVAILABILITY (avertissement) — le mock ne connaît que AVAILABLE / non déclaré.
const declared = state.availability.get(volunteerId) ?? new Set<string>();
const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`;
if (!declared.has(availKey)) {
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
// fenêtres de disponibilité déclarées.
const gapMin = uncoveredMinutes(
state.availability.get(volunteerId) ?? [],
candidate.starts_at,
candidate.ends_at,
);
if (gapMin > 0) {
violations.push({
code: 'OUTSIDE_AVAILABILITY',
severity: 'WARNING',
params: {
date: candidate.starts_at.slice(0, 10),
day_part_code: candidate.starts_at.slice(11, 16),
status: 'UNDECLARED',
uncovered_minutes: gapMin,
},
});
}
@@ -346,17 +339,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// MISSING_REQUIRED_SKILL (avertissement)
for (const code of [...candidate.required_skill_codes].sort()) {
if (!volunteer.skill_codes.includes(code)) {
violations.push({
code: 'MISSING_REQUIRED_SKILL',
severity: 'WARNING',
params: { skill_code: code },
});
}
}
return violations;
}
@@ -537,7 +519,6 @@ export function createMockApi(): PlanningApi {
email: body.email ?? null,
phone: body.phone ?? null,
comment: body.comment ?? null,
skill_codes: body.skill_codes ?? [],
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
wished_position_ids: resolveWishes(
state.positions,
@@ -548,7 +529,7 @@ export function createMockApi(): PlanningApi {
updated_at: now,
};
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, new Set());
state.availability.set(volunteer.id, []);
return delay(volunteer);
},
update: (_edition, id, body) => {
@@ -559,7 +540,6 @@ export function createMockApi(): PlanningApi {
...('email' in body ? { email: body.email ?? null } : {}),
...('phone' in body ? { phone: body.phone ?? null } : {}),
...('comment' in body ? { comment: body.comment ?? null } : {}),
...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}),
...(body.artist_ids != null
? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') }
: {}),
@@ -584,6 +564,50 @@ export function createMockApi(): PlanningApi {
},
},
availability: {
list: (_edition, volunteerId) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
},
listEdition: () =>
delay(
sortWindows(
[...state.availability.values()].flat().filter((w) =>
state.volunteers.some((v) => v.id === w.volunteer_id),
),
),
),
replace: (_edition, volunteerId, body: AvailabilitySet) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
const seen = new Set<string>();
const windows: AvailabilityRead[] = [];
for (const w of body.windows) {
if (w.date < state.edition.start_date || w.date > state.edition.end_date) {
throw new ApiError(422, 'availability.date_outside_edition');
}
const start = hms(w.start_time);
const end = hms(w.end_time);
if (start === end) throw new ApiError(422, 'availability.empty_window');
const key = `${w.date}|${start}|${end}`;
if (seen.has(key)) continue;
seen.add(key);
const now = touch();
windows.push({
id: `av${state.seq++}`,
edition_id: state.edition.id,
volunteer_id: volunteerId,
date: w.date,
start_time: start,
end_time: end,
created_at: now,
updated_at: now,
});
}
state.availability.set(volunteerId, windows);
return delay(sortWindows(windows));
},
},
imports: {
analyze: async (_edition, file: File) => {
const { headers, rows } = await readImportTable(file);
@@ -591,14 +615,6 @@ export function createMockApi(): PlanningApi {
line: i + 2,
values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])),
}));
const editionDates: string[] = [];
for (
let d = state.edition.start_date;
d <= state.edition.end_date;
d = addDays(d, 1)
) {
editionDates.push(d);
}
return delay<ImportAnalyzeResponse>({
filename: file.name || '',
format: 'csv',
@@ -606,16 +622,6 @@ export function createMockApi(): PlanningApi {
row_count: rows.length,
preview,
suggested_mapping: suggestImportMapping(headers),
edition_dates: editionDates,
day_parts: [...state.dayParts]
.sort((a, b) => a.order - b.order)
.map((dp) => ({
code: dp.code,
label: dp.label,
start_time: dp.start_time,
end_time: dp.end_time,
order: dp.order,
})),
});
},
execute: async (_edition, file: File, mapping: ImportMapping) => {
@@ -632,16 +638,11 @@ export function createMockApi(): PlanningApi {
created: 0,
updated: 0,
skipped: 0,
availabilities_written: 0,
skills_created: [],
rows: [],
};
const dpByCode = new Map(state.dayParts.map((d) => [d.code, d]));
const emailSeen = new Map<string, number>();
const createdSkills = new Set<string>();
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
const working = state.volunteers.map((v) => ({ ...v, skill_codes: [...v.skill_codes] }));
const availWrites: Array<{ vid: string; key: string; on: boolean }> = [];
const working = state.volunteers.map((v) => ({ ...v }));
rows.forEach((row, i) => {
const line = i + 2;
@@ -729,7 +730,6 @@ export function createMockApi(): PlanningApi {
email: null,
phone: null,
comment: null,
skill_codes: [],
artist_ids: [],
wished_position_ids: [],
created_at: now,
@@ -744,48 +744,6 @@ export function createMockApi(): PlanningApi {
if (has(cols.email)) vol.email = emailRaw || null;
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null;
if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null;
if (has(cols.skills)) {
const tokens = cell(row, cols.skills)
.split(/[;,/\n]+/)
.map((s) => s.trim())
.filter(Boolean);
for (const token of tokens) {
const code = slugCode(token);
if (!code) continue;
const known = working.some((v) => v.skill_codes.includes(code));
if (!known && !createdSkills.has(code)) {
createdSkills.add(code);
entry.issues.push({
code: 'import.row.skill_created',
severity: 'WARNING',
params: { code, label: token },
});
}
if (!vol.skill_codes.includes(code)) vol.skill_codes.push(code);
}
}
for (const avCol of mapping.availability_columns) {
const dp = dpByCode.get(avCol.day_part_code);
if (!dp) continue;
const [status, recognized] = parseAvailabilityCell(cell(row, avCol.header));
if (!recognized) {
entry.issues.push({
code: 'import.row.unknown_availability_value',
severity: 'WARNING',
field: avCol.header,
params: { column: avCol.header, value: cell(row, avCol.header) },
});
continue;
}
if (status === null) continue;
availWrites.push({
vid: vol.id,
key: `${avCol.date}|${dp.start_time.slice(0, 5)}`,
on: status !== 'UNAVAILABLE',
});
report.availabilities_written += 1;
}
entry.volunteer_id = vol.id;
report.rows.push(entry);
@@ -793,18 +751,10 @@ export function createMockApi(): PlanningApi {
else report.updated += 1;
});
report.skills_created = [...createdSkills].sort();
if (!mapping.dry_run) {
state.volunteers = working;
for (const v of working) {
if (!state.availability.has(v.id)) state.availability.set(v.id, new Set());
}
for (const w of availWrites) {
const set = state.availability.get(w.vid) ?? new Set<string>();
if (w.on) set.add(w.key);
else set.delete(w.key);
state.availability.set(w.vid, set);
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
}
}
return delay(report);
@@ -883,7 +833,6 @@ export function createMockApi(): PlanningApi {
ends_at: body.ends_at,
min_staff: body.min_staff ?? 0,
ideal_staff: body.ideal_staff ?? 0,
required_skill_codes: body.required_skill_codes ?? [],
created_at: now,
updated_at: now,
};
@@ -898,9 +847,6 @@ export function createMockApi(): PlanningApi {
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}),
...(body.required_skill_codes != null
? { required_skill_codes: body.required_skill_codes }
: {}),
updated_at: touch(),
});
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {