From df8d6733db3963fba8d552f71064c0c95b5438a4 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 21:55:50 +0200 Subject: [PATCH] =?UTF-8?q?Import=20b=C3=A9n=C3=A9voles=20:=20couche=20API?= =?UTF-8?q?=20(analyse=20+=20ex=C3=A9cution)=20et=20mock=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/src/api/hooks.ts | 23 ++ frontend/src/api/http-client.ts | 19 +- frontend/src/api/mock/mock-client.ts | 365 +++++++++++++++++++++++++++ frontend/src/api/schema.ts | 90 +++++++ frontend/src/api/types.ts | 9 + 5 files changed, 505 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index b565c8e..ac26347 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -13,6 +13,7 @@ import type { DayPartCreate, DayPartUpdate, EditionUpdate, + ImportMapping, PlanningNotificationRequest, PositionCreate, PositionUpdate, @@ -197,6 +198,28 @@ export function useDeleteVolunteer() { }); } +/** Import de bénévoles — phase 1 : analyse du fichier. */ +export function useAnalyzeImport() { + return useMutation({ + mutationFn: (file: File) => api.imports.analyze(edition, file), + }); +} + +/** Import de bénévoles — phase 2 : simulation ou exécution réelle. */ +export function useExecuteImport() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ file, mapping }: { file: File; mapping: ImportMapping }) => + api.imports.execute(edition, file, mapping), + onSuccess: (report) => { + if (!report.dry_run) { + invalidate.volunteers(); + invalidate.planning(); + } + }, + }); +} + export function useCreatePosition() { const invalidate = useInvalidate(); return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 9acd193..8c6bdd1 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -5,6 +5,7 @@ import type { DayPartCreate, DayPartUpdate, EditionUpdate, + ImportMapping, LoginRequest, MagicLinkRequest, PlanningNotificationRequest, @@ -23,10 +24,13 @@ import { ApiError, type PlanningApi } from './types'; export function createHttpApi(baseUrl: string): PlanningApi { async function request(path: string, init?: RequestInit): Promise { const token = getAuthToken(); + // Pour un envoi `multipart/form-data`, on laisse le navigateur fixer + // lui-même l'en-tête `Content-Type` (avec la frontière). + const isForm = init?.body instanceof FormData; const res = await fetch(`${baseUrl}${path}`, { ...init, headers: { - 'Content-Type': 'application/json', + ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(token ? { Authorization: `Bearer ${token}` } : {}), ...init?.headers, }, @@ -93,6 +97,19 @@ export function createHttpApi(baseUrl: string): PlanningApi { remove: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), }, + imports: { + analyze: (editionId, file: File) => { + const form = new FormData(); + form.append('file', file); + return request(`${base(editionId)}/import/analyze`, { method: 'POST', body: form }); + }, + execute: (editionId, file: File, mapping: ImportMapping) => { + const form = new FormData(); + form.append('file', file); + form.append('mapping', JSON.stringify(mapping)); + return request(`${base(editionId)}/import/execute`, { method: 'POST', body: form }); + }, + }, positions: { list: (editionId) => request(`${base(editionId)}/positions`), get: (editionId, id) => request(`${base(editionId)}/positions/${id}`), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index f1c25e4..8d02867 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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 = { + 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 = { '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 { + const result: Record = {}; + const used = new Set(); + 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({ + 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(); + const createdSkills = new Set(); + // 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(); + 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'))), diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 81400e4..86b7bbb 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -135,6 +135,96 @@ export interface VolunteerUpdate { skill_codes?: string[] | null; } +// --------------------------------------------------------------------------- // +// Import de bénévoles (fichier CSV / Excel) // +// --------------------------------------------------------------------------- // +export type ImportFieldKey = + | 'first_name' + | 'last_name' + | 'email' + | 'phone' + | 'tshirt_size' + | 'diet' + | 'comment' + | 'skills'; + +export interface ImportColumnMapping { + first_name: string; + last_name: string; + email?: string | null; + phone?: string | null; + tshirt_size?: string | null; + diet?: string | null; + comment?: string | null; + skills?: string | null; +} + +export interface ImportAvailabilityColumn { + header: string; + /** Date ISO (YYYY-MM-DD). */ + date: string; + day_part_code: string; +} + +export interface ImportMapping { + dry_run: boolean; + columns: ImportColumnMapping; + availability_columns: ImportAvailabilityColumn[]; +} + +export interface ImportPreviewRow { + line: number; + values: Record; +} + +export interface ImportDayPartInfo { + code: string; + label: string; + start_time: string; + end_time: string; + order: number; +} + +export interface ImportAnalyzeResponse { + filename: string; + format: 'csv' | 'xlsx'; + headers: string[]; + row_count: number; + preview: ImportPreviewRow[]; + suggested_mapping: Partial>; + /** Dates ISO couvertes par l'édition. */ + edition_dates: string[]; + day_parts: ImportDayPartInfo[]; +} + +export type ImportSeverity = 'ERROR' | 'WARNING'; +export type ImportRowAction = 'CREATED' | 'UPDATED' | 'SKIPPED'; + +export interface ImportIssue { + code: string; + severity: ImportSeverity; + field?: string | null; + params: Record; +} + +export interface ImportRowReport { + line: number; + action: ImportRowAction; + volunteer_id: string | null; + issues: ImportIssue[]; +} + +export interface ImportReport { + dry_run: boolean; + total_rows: number; + created: number; + updated: number; + skipped: number; + availabilities_written: number; + skills_created: string[]; + rows: ImportRowReport[]; +} + // --------------------------------------------------------------------------- // // Position // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 2a73211..9d0df4e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -12,6 +12,9 @@ import type { EditionRead, EditionUpdate, GenerateShiftsResult, + ImportAnalyzeResponse, + ImportMapping, + ImportReport, LoginRequest, MagicLinkRequest, MatrixResponse, @@ -53,6 +56,12 @@ export interface PlanningApi { update(editionId: string, id: string, body: VolunteerUpdate): Promise; remove(editionId: string, id: string): Promise; }; + imports: { + /** Phase 1 : lit le fichier, propose un mapping des colonnes. */ + analyze(editionId: string, file: File): Promise; + /** Phase 2 : exécute (ou simule si `mapping.dry_run`) l'import. */ + execute(editionId: string, file: File, mapping: ImportMapping): Promise; + }; positions: { list(editionId: string): Promise; get(editionId: string, id: string): Promise;