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
+23
View File
@@ -13,6 +13,7 @@ import type {
DayPartCreate, DayPartCreate,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
ImportMapping,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionUpdate, 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() { export function useCreatePosition() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
+18 -1
View File
@@ -5,6 +5,7 @@ import type {
DayPartCreate, DayPartCreate,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
ImportMapping,
LoginRequest, LoginRequest,
MagicLinkRequest, MagicLinkRequest,
PlanningNotificationRequest, PlanningNotificationRequest,
@@ -23,10 +24,13 @@ import { ApiError, type PlanningApi } from './types';
export function createHttpApi(baseUrl: string): PlanningApi { export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> { async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken(); 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}`, { const res = await fetch(`${baseUrl}${path}`, {
...init, ...init,
headers: { headers: {
'Content-Type': 'application/json', ...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers, ...init?.headers,
}, },
@@ -93,6 +97,19 @@ export function createHttpApi(baseUrl: string): PlanningApi {
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), 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: { positions: {
list: (editionId) => request(`${base(editionId)}/positions`), list: (editionId) => request(`${base(editionId)}/positions`),
get: (editionId, id) => request(`${base(editionId)}/positions/${id}`), get: (editionId, id) => request(`${base(editionId)}/positions/${id}`),
+365
View File
@@ -4,14 +4,21 @@ import type {
AccountRead, AccountRead,
AssignmentRead, AssignmentRead,
AssignmentValidateResult, AssignmentValidateResult,
AvailabilityStatus,
DayPartCreate, DayPartCreate,
DayPartRead, DayPartRead,
DayPartUpdate, DayPartUpdate,
EditionUpdate, EditionUpdate,
ImportAnalyzeResponse,
ImportMapping,
ImportPreviewRow,
ImportReport,
ImportRowReport,
MatrixResponse, MatrixResponse,
PlanningNotificationRequest, PlanningNotificationRequest,
ShiftRead, ShiftRead,
TokenResponse, TokenResponse,
TshirtSize,
Violation, Violation,
} from '../schema'; } from '../schema';
import { getAuthToken } from '../session'; import { getAuthToken } from '../session';
@@ -127,6 +134,119 @@ function shiftById(id: string): ShiftRead {
return shift; 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 { function overlaps(a: ShiftRead, b: ShiftRead): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at; 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: { positions: {
list: () => list: () =>
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
+90
View File
@@ -135,6 +135,96 @@ export interface VolunteerUpdate {
skill_codes?: string[] | null; 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<string, string>;
}
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<Record<ImportFieldKey, string>>;
/** 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<string, string | number | null>;
}
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 // // Position //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+9
View File
@@ -12,6 +12,9 @@ import type {
EditionRead, EditionRead,
EditionUpdate, EditionUpdate,
GenerateShiftsResult, GenerateShiftsResult,
ImportAnalyzeResponse,
ImportMapping,
ImportReport,
LoginRequest, LoginRequest,
MagicLinkRequest, MagicLinkRequest,
MatrixResponse, MatrixResponse,
@@ -53,6 +56,12 @@ export interface PlanningApi {
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>; update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
}; };
imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
/** Phase 2 : exécute (ou simule si `mapping.dry_run`) l'import. */
execute(editionId: string, file: File, mapping: ImportMapping): Promise<ImportReport>;
};
positions: { positions: {
list(editionId: string): Promise<PositionRead[]>; list(editionId: string): Promise<PositionRead[]>;
get(editionId: string, id: string): Promise<PositionRead>; get(editionId: string, id: string): Promise<PositionRead>;