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:
co-authored by
Claude Sonnet 5
parent
06f8736c33
commit
df8d6733db
@@ -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({
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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'))),
|
||||||
|
|||||||
@@ -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 //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user