Import bénévoles : couche API (analyse + exécution) et mock CSV

- `PlanningApi.imports.analyze/execute` : envoi `multipart/form-data`
  (le client HTTP laisse le navigateur poser le Content-Type).
- Types miroir des schémas back-end (`ImportAnalyzeResponse`,
  `ImportMapping`, `ImportReport`, `ImportRowReport`, `ImportIssue`…).
- Hooks `useAnalyzeImport` / `useExecuteImport` ; l'exécution réelle
  invalide les bénévoles et le planning.
- Implémentation mock (mode démo) : lecture CSV, suggestion de mapping,
  simulation/exécution avec rapport ligne par ligne. Excel renvoie
  `import.error.xlsx_demo_only`.

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 21:55:50 +02:00
co-authored by Claude Sonnet 5
parent 06f8736c33
commit df8d6733db
5 changed files with 505 additions and 1 deletions
+365
View File
@@ -4,14 +4,21 @@ import type {
AccountRead,
AssignmentRead,
AssignmentValidateResult,
AvailabilityStatus,
DayPartCreate,
DayPartRead,
DayPartUpdate,
EditionUpdate,
ImportAnalyzeResponse,
ImportMapping,
ImportPreviewRow,
ImportReport,
ImportRowReport,
MatrixResponse,
PlanningNotificationRequest,
ShiftRead,
TokenResponse,
TshirtSize,
Violation,
} from '../schema';
import { getAuthToken } from '../session';
@@ -127,6 +134,119 @@ function shiftById(id: string): ShiftRead {
return shift;
}
// --------------------------------------------------------------------------- //
// Import de bénévoles (mode démo : fichiers CSV uniquement) //
// --------------------------------------------------------------------------- //
const IMPORT_HINTS: Record<string, string[]> = {
email: ['mail', 'courriel'],
first_name: ['prenom', 'first name', 'firstname', 'given name'],
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
tshirt_size: ['tshirt', 't shirt', 'taille', 'shirt'],
diet: ['regime', 'alimentaire', 'diet', 'allergie'],
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']);
const TSHIRT_ALIASES: Record<string, TshirtSize> = { '2XL': 'XXL', '3XL': 'XXXL' };
const TSHIRT_VALUES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
function normalizeText(value: string): string {
return (value ?? '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.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 = '';
let quoted = false;
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if (quoted) {
if (ch === '"' && line[i + 1] === '"') {
cur += '"';
i += 1;
} else if (ch === '"') {
quoted = false;
} else {
cur += ch;
}
} else if (ch === '"') {
quoted = true;
} else if (ch === delimiter) {
cells.push(cur);
cur = '';
} else {
cur += ch;
}
}
cells.push(cur);
return cells.map((c) => c.trim());
}
async function readImportTable(file: File): Promise<{ headers: string[]; rows: string[][] }> {
const name = (file.name || '').toLowerCase();
const buf = new Uint8Array(await file.arrayBuffer());
const isXlsx =
name.endsWith('.xlsx') ||
name.endsWith('.xlsm') ||
(buf[0] === 0x50 && buf[1] === 0x4b && buf[2] === 0x03 && buf[3] === 0x04);
if (isXlsx) throw new ApiError(422, 'import.error.xlsx_demo_only');
const text = new TextDecoder('utf-8')
.decode(buf)
.replace(/^\uFEFF/, '')
.replace(/\r\n?/g, '\n')
.trim();
if (!text) throw new ApiError(400, 'import.error.empty_file');
const lines = text.split('\n').filter((l) => l.trim() !== '');
const delimiter = [';', '\t', ','].find((d) => lines[0].includes(d)) ?? ',';
const headers = splitCsvLine(lines[0], delimiter);
const rows = lines.slice(1).map((l) => splitCsvLine(l, delimiter));
if (headers.length === 0) throw new ApiError(400, 'import.error.empty_file');
return { headers, rows };
}
function suggestImportMapping(headers: string[]): Record<string, string> {
const result: Record<string, string> = {};
const used = new Set<string>();
for (const [field, hints] of Object.entries(IMPORT_HINTS)) {
for (const header of headers) {
if (used.has(header)) continue;
if (hints.some((h) => normalizeText(header).includes(h))) {
result[field] = header;
used.add(header);
break;
}
}
}
return result;
}
function overlaps(a: ShiftRead, b: ShiftRead): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
@@ -382,6 +502,251 @@ export function createMockApi(): PlanningApi {
},
},
imports: {
analyze: async (_edition, file: File) => {
const { headers, rows } = await readImportTable(file);
const preview: ImportPreviewRow[] = rows.slice(0, 5).map((cells, i) => ({
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',
headers,
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) => {
const { headers, rows } = await readImportTable(file);
const cols = mapping.columns;
const has = (h?: string | null) => Boolean(h) && headers.includes(h as string);
const cell = (row: string[], h?: string | null) => {
const i = h ? headers.indexOf(h) : -1;
return i >= 0 ? (row[i] ?? '').trim() : '';
};
const report: ImportReport = {
dry_run: mapping.dry_run,
total_rows: rows.length,
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 }> = [];
rows.forEach((row, i) => {
const line = i + 2;
const entry: ImportRowReport = {
line,
action: 'SKIPPED',
volunteer_id: null,
issues: [],
};
const first = cell(row, cols.first_name);
const last = cell(row, cols.last_name);
const emailRaw = cell(row, cols.email);
const email = emailRaw.toLowerCase() || null;
if (!first || !last) {
entry.issues.push({
code: 'import.row.missing_required_field',
severity: 'ERROR',
field: !first ? 'first_name' : 'last_name',
params: {},
});
report.rows.push(entry);
report.skipped += 1;
return;
}
if (email && !IMPORT_EMAIL_RE.test(email)) {
entry.issues.push({
code: 'import.row.invalid_email',
severity: 'ERROR',
field: 'email',
params: { value: emailRaw },
});
report.rows.push(entry);
report.skipped += 1;
return;
}
if (email) {
const seenAt = emailSeen.get(email);
if (seenAt != null) {
entry.issues.push({
code: 'import.row.duplicate_email_in_file',
severity: 'ERROR',
field: 'email',
params: { value: emailRaw, first_line: seenAt },
});
report.rows.push(entry);
report.skipped += 1;
return;
}
emailSeen.set(email, line);
}
let vol = email
? working.find((v) => (v.email ?? '').toLowerCase() === email)
: undefined;
if (vol) {
entry.action = 'UPDATED';
entry.issues.push({
code: 'import.row.updated_existing',
severity: 'WARNING',
params: { volunteer_id: vol.id },
});
} else {
if (!email) {
const match = working.find(
(v) =>
!v.email &&
normalizeText(v.last_name) === normalizeText(last) &&
normalizeText(v.first_name) === normalizeText(first),
);
if (match) {
entry.issues.push({
code: 'import.row.possible_duplicate_name',
severity: 'WARNING',
params: { volunteer_id: match.id },
});
}
}
const now = touch();
vol = {
id: `v${state.seq++}`,
edition_id: state.edition.id,
first_name: first,
last_name: last,
email: null,
phone: null,
tshirt_size: null,
diet: null,
comment: null,
skill_codes: [],
created_at: now,
updated_at: now,
};
working.push(vol);
entry.action = 'CREATED';
}
vol.first_name = first;
vol.last_name = last;
if (has(cols.email)) vol.email = emailRaw || null;
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null;
if (has(cols.diet)) vol.diet = cell(row, cols.diet) || null;
if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null;
if (has(cols.tshirt_size)) {
const raw = cell(row, cols.tshirt_size);
const key = raw.toUpperCase().replace(/[\s-]/g, '');
const size = TSHIRT_ALIASES[key] ?? (key as TshirtSize);
if (raw) {
if (TSHIRT_VALUES.includes(size)) {
vol.tshirt_size = size;
} else {
entry.issues.push({
code: 'import.row.invalid_tshirt_size',
severity: 'WARNING',
field: 'tshirt_size',
params: { value: raw },
});
}
}
}
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);
if (entry.action === 'CREATED') report.created += 1;
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);
}
}
return delay(report);
},
},
positions: {
list: () =>
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),