Bénévoles : ajout manuel, import de fichier guidé et gestion complète

Écran « Bénévoles & import » remanié autour des trois méthodes d'ajout
demandées (la 3e — formulaire d'inscription en ligne — reste à faire) :

- Ajout manuel : formulaire complet (prénom, nom, email, téléphone,
  taille de t-shirt, régime, commentaire, compétences) ; l'erreur 409
  `volunteer.duplicate_email` est affichée.
- Import de fichier : assistant en 3 étapes (fichier → association des
  colonnes + colonnes de disponibilité optionnelles → simulation puis
  import réel) avec aperçu et rapport ligne par ligne.
- Gestion : recherche par nom / email, fiche éditable étendue (régime et
  commentaire ajoutés), suppression RGPD en cascade inchangée.

Le bouton « créer 4 exemples » (maquette) est retiré. Traductions
`config.vol.*` et miroir des messages `import.*` du back-end.

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:59 +02:00
co-authored by Claude Sonnet 5
parent df8d6733db
commit ccf3438eef
2 changed files with 869 additions and 101 deletions
+793 -79
View File
@@ -1,133 +1,225 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { ApiError } from '@/api';
import {
useAnalyzeImport,
useCreateVolunteer,
useDeleteVolunteer,
useExecuteImport,
useUpdateVolunteer,
useVolunteers,
} from '@/api/hooks';
import type { TshirtSize } from '@/api/schema';
import type {
ImportAnalyzeResponse,
ImportFieldKey,
ImportIssue,
ImportMapping,
ImportReport,
TshirtSize,
VolunteerCreate,
} from '@/api/schema';
import { FieldLabel, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time';
import { cx } from '@/lib/cx';
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
const IMPORT_SAMPLE: Array<[string, string]> = [
['Océane', 'Thibault'],
['Samir', 'Amrani'],
['Lucie', 'Charpentier'],
['Noé', 'Delaunay'],
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
{ key: 'first_name', required: true },
{ key: 'last_name', required: true },
{ key: 'email', required: false },
{ key: 'phone', required: false },
{ key: 'tshirt_size', required: false },
{ key: 'diet', required: false },
{ key: 'comment', required: false },
{ key: 'skills', required: false },
];
const FIELD_LABEL: Record<ImportFieldKey, string> = {
first_name: 'config.vol.field.firstName',
last_name: 'config.vol.field.lastName',
email: 'config.vol.field.email',
phone: 'config.vol.field.phone',
tshirt_size: 'config.vol.field.tshirt',
diet: 'config.vol.field.diet',
comment: 'config.vol.field.comment',
skills: 'config.vol.field.skills',
};
type Mode = 'list' | 'add' | 'import';
/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */
function issueParams(issue: ImportIssue): Record<string, string | number> {
const out: Record<string, string | number> = {};
for (const [k, v] of Object.entries(issue.params)) {
if (v != null) out[k] = v;
}
return out;
}
export function ConfigVolunteers() {
const volunteers = useVolunteers();
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const createVolunteer = useCreateVolunteer();
const [mode, setMode] = useState<Mode>('list');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [skillDraft, setSkillDraft] = useState('');
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const list = [...(volunteers.data ?? [])];
if (!needle) return list;
return list.filter((v) =>
`${v.first_name} ${v.last_name} ${v.email ?? ''}`.toLowerCase().includes(needle),
);
}, [volunteers.data, search]);
const selected = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === selectedId) ?? list[0];
return list.find((v) => v.id === selectedId) ?? null;
}, [volunteers.data, selectedId]);
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
const rows = (volunteers.data ?? []).slice(0, 40);
const total = volunteers.data?.length ?? 0;
const rows = filtered.slice(0, 60);
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
selected && updateVolunteer.mutate({ id: selected.id, body });
const runImport = () => {
for (const [first_name, last_name] of IMPORT_SAMPLE) {
createVolunteer.mutate({ first_name, last_name });
}
};
if (mode === 'add') {
return <AddVolunteerForm onClose={() => setMode('list')} />;
}
if (mode === 'import') {
return <ImportWizard onClose={() => setMode('list')} />;
}
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
<div className="flex flex-1 items-center gap-[14px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
<span className="flex h-10 w-10 flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[16px] font-extrabold">
↓
</span>
<span className="min-w-0 flex-1">
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
<div>
<span className="block font-display text-[15px] font-black">
{t('config.vol.importTitle')}
</span>
<span className="block font-mono text-[10px] leading-relaxed opacity-70">
{t('config.vol.importHint')}
</span>
</span>
<button
type="button"
onClick={runImport}
disabled={createVolunteer.isPending}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.importButton')}
</button>
</div>
<div className="flex flex-wrap gap-[10px]">
<button
type="button"
onClick={() => setMode('add')}
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
>
+ {t('config.vol.addButton')}
</button>
<button
type="button"
onClick={() => setMode('import')}
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-yellow"
>
↓ {t('config.vol.importButton2')}
</button>
</div>
</div>
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
<div className="font-mono text-[24px] font-bold">{total}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.profiles')}
</div>
</div>
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected?.id;
return (
<button
key={volunteer.id}
type="button"
onClick={() => setSelectedId(volunteer.id)}
className={cx(
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.first_name[0]}
{volunteer.last_name[0]}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.first_name} {volunteer.last_name}
</span>
</span>
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
{volunteer.email ?? '—'}
</span>
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
</span>
</button>
);
})}
<div className="mb-[10px]">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('config.vol.search')}
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/>
</div>
{total === 0 ? (
<div className="border-[3px] border-dashed border-ink bg-paper p-[22px] font-display text-[13px] font-extrabold">
{t('config.vol.emptyList')}
</div>
) : rows.length === 0 ? (
<div className="border-[3px] border-dashed border-ink bg-paper p-[22px] font-display text-[13px] font-extrabold">
{t('config.vol.noMatch')}
</div>
) : (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected?.id;
return (
<button
key={volunteer.id}
type="button"
onClick={() => setSelectedId(volunteer.id)}
className={cx(
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.first_name[0]}
{volunteer.last_name[0]}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.first_name} {volunteer.last_name}
</span>
</span>
<span className="truncate font-mono text-[11px] opacity-80">
{volunteer.phone ?? '—'}
</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
{volunteer.email ?? '—'}
</span>
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
</span>
</button>
);
})}
{filtered.length > rows.length ? (
<div className="px-[10px] py-[7px] font-mono text-[10px] opacity-60">
{t('config.vol.count', { count: filtered.length })}
</div>
) : null}
</div>
)}
</div>
{selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="flex items-center justify-between">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<button
type="button"
onClick={() => setSelectedId(null)}
className="cursor-pointer font-mono text-[11px] text-cream/70 hover:text-cream"
>
✕
</button>
</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.first_name[0]}
@@ -142,24 +234,24 @@ export function ConfigVolunteers() {
<div className="flex flex-col gap-3 p-[13px]">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
<FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
<FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
</label>
</div>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.tshirt')}</FieldLabel>
<FieldLabel>{t('config.vol.field.tshirt')}</FieldLabel>
<select
value={selected.tshirt_size ?? ''}
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
@@ -173,9 +265,22 @@ export function ConfigVolunteers() {
))}
</select>
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
<TextInput value={selected.diet ?? ''} onChange={(v) => patch({ diet: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
<textarea
value={selected.comment ?? ''}
onChange={(e) => patch({ comment: e.target.value || null })}
rows={2}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
<div>
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.skill_codes.map((code) => (
<button
@@ -240,3 +345,612 @@ export function ConfigVolunteers() {
</div>
);
}
// --------------------------------------------------------------------------- //
// Ajout manuel //
// --------------------------------------------------------------------------- //
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const createVolunteer = useCreateVolunteer();
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
const [skills, setSkills] = useState<string[]>([]);
const [skillDraft, setSkillDraft] = useState('');
const [localError, setLocalError] = useState<string | null>(null);
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const submit = async () => {
setLocalError(null);
if (!form.first_name.trim() || !form.last_name.trim()) {
setLocalError(t('config.vol.requiredNames'));
return;
}
try {
await createVolunteer.mutateAsync({
first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email: form.email?.trim() || null,
phone: form.phone?.trim() || null,
tshirt_size: form.tshirt_size ?? null,
diet: form.diet?.trim() || null,
comment: form.comment?.trim() || null,
skill_codes: skills,
});
onClose();
} catch (err) {
setLocalError(err instanceof ApiError ? t(err.code) : t('error.generic'));
}
};
return (
<div className="max-h-[calc(100vh-118px)] min-h-0 flex-1 overflow-auto p-4">
<div className="mx-auto max-w-[560px] border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
<span className="font-display text-[14px] font-black">{t('config.vol.addTitle')}</span>
<button
type="button"
onClick={onClose}
className="cursor-pointer font-mono text-[12px] text-cream/70 hover:text-cream"
>
✕
</button>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="flex flex-col gap-3 p-[14px]"
>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.addHint')}
</div>
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.field.firstName')} *</FieldLabel>
<TextInput value={form.first_name} onChange={(v) => set('first_name', v)} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.lastName')} *</FieldLabel>
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
</label>
</div>
<label className="block">
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
<TextInput mono value={form.email ?? ''} onChange={(v) => set('email', v)} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
<TextInput mono value={form.phone ?? ''} onChange={(v) => set('phone', v)} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.tshirt')}</FieldLabel>
<select
value={form.tshirt_size ?? ''}
onChange={(e) => set('tshirt_size', (e.target.value || null) as TshirtSize | null)}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
>
<option value="">—</option>
{TSHIRT_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
<TextInput value={form.diet ?? ''} onChange={(v) => set('diet', v)} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
<textarea
value={form.comment ?? ''}
onChange={(e) => set('comment', e.target.value)}
rows={2}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
<div>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{skills.map((code) => (
<button
key={code}
type="button"
onClick={() => setSkills((s) => s.filter((c) => c !== code))}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{code} ✕
</button>
))}
{skills.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
) : null}
</div>
<div className="flex gap-2">
<input
value={skillDraft}
onChange={(e) => setSkillDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const code = skillDraft.trim().toUpperCase();
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
setSkillDraft('');
}
}}
placeholder={t('config.vol.skillPlaceholder')}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
/>
<button
type="button"
onClick={() => {
const code = skillDraft.trim().toUpperCase();
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
setSkillDraft('');
}}
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
>
{t('action.add')}
</button>
</div>
</div>
{localError ? (
<div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold">
{localError}
</div>
) : null}
<div className="flex gap-2">
<button
type="submit"
disabled={createVolunteer.isPending}
className="flex-1 cursor-pointer border-[3px] border-ink bg-yellow p-[11px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.create')}
</button>
<button
type="button"
onClick={onClose}
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
>
{t('config.vol.cancel')}
</button>
</div>
</form>
</div>
</div>
);
}
// --------------------------------------------------------------------------- //
// Import de fichier (assistant en 3 étapes) //
// --------------------------------------------------------------------------- //
type AvailRow = { header: string; date: string; day_part_code: string };
function ImportWizard({ onClose }: { onClose: () => void }) {
const analyze = useAnalyzeImport();
const execute = useExecuteImport();
const [file, setFile] = useState<File | null>(null);
const [analysis, setAnalysis] = useState<ImportAnalyzeResponse | null>(null);
const [colMap, setColMap] = useState<Partial<Record<ImportFieldKey, string>>>({});
const [availRows, setAvailRows] = useState<AvailRow[]>([]);
const [report, setReport] = useState<ImportReport | null>(null);
const [mapError, setMapError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const step: 1 | 2 | 3 = report ? 3 : analysis ? 2 : 1;
const pickFile = async (f: File) => {
setFile(f);
setAnalysis(null);
setReport(null);
setMapError(null);
try {
const res = await analyze.mutateAsync(f);
setAnalysis(res);
setColMap({ ...res.suggested_mapping });
setAvailRows([]);
} catch {
/* l'erreur est portée par analyze.error */
}
};
const buildMapping = (dryRun: boolean): ImportMapping => ({
dry_run: dryRun,
columns: {
first_name: colMap.first_name ?? '',
last_name: colMap.last_name ?? '',
email: colMap.email || null,
phone: colMap.phone || null,
tshirt_size: colMap.tshirt_size || null,
diet: colMap.diet || null,
comment: colMap.comment || null,
skills: colMap.skills || null,
},
availability_columns: availRows.filter((r) => r.header && r.date && r.day_part_code),
});
const run = async (dryRun: boolean) => {
if (!file) return;
setMapError(null);
if (!colMap.first_name || !colMap.last_name) {
setMapError(t('config.vol.wiz.mapMissing'));
return;
}
try {
const res = await execute.mutateAsync({ file, mapping: buildMapping(dryRun) });
setReport(res);
} catch {
/* l'erreur est portée par execute.error */
}
};
const restart = () => {
setReport(null);
};
return (
<div className="max-h-[calc(100vh-118px)] min-h-0 flex-1 overflow-auto p-4">
<div className="mx-auto max-w-[820px] border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
<span className="font-display text-[14px] font-black">{t('config.vol.wiz.title')}</span>
<button
type="button"
onClick={onClose}
className="cursor-pointer font-mono text-[12px] text-cream/70 hover:text-cream"
>
✕
</button>
</div>
<div className="flex gap-[6px] border-b-[3px] border-ink bg-sand px-[14px] py-[8px] font-display text-[9.5px] font-extrabold uppercase tracking-wide">
{[
['config.vol.wiz.step1', 1],
['config.vol.wiz.step2', 2],
['config.vol.wiz.step3', 3],
].map(([key, n]) => (
<span
key={n as number}
className={cx(
'border-2 border-ink px-[8px] py-[4px]',
step === n ? 'bg-ink text-cream' : 'bg-cream text-ink opacity-70',
)}
>
{t(key as string)}
</span>
))}
</div>
<div className="flex flex-col gap-[14px] p-[14px]">
{/* Étape 1 : fichier */}
{step === 1 ? (
<>
<input
ref={fileInput}
type="file"
accept=".csv,text/csv,.xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void pickFile(f);
}}
/>
<button
type="button"
onClick={() => fileInput.current?.click()}
className="cursor-pointer border-[3px] border-dashed border-ink bg-cream p-[22px] font-display text-[13px] font-black uppercase tracking-wide hover:bg-yellow"
>
{analyze.isPending ? t('config.vol.wiz.analyzing') : t('config.vol.wiz.pickFile')}
</button>
{file ? (
<div className="font-mono text-[11px] opacity-70">{file.name}</div>
) : null}
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.wiz.fileHint')}
</div>
{analyze.error ? <ErrorNote error={analyze.error} /> : null}
</>
) : null}
{/* Étape 2 : colonnes */}
{step === 2 && analysis ? (
<>
<div className="font-mono text-[11px] font-bold">
{t('config.vol.wiz.detected', {
rows: analysis.row_count,
cols: analysis.headers.length,
})}
</div>
<div>
<FieldLabel>{t('config.vol.wiz.mapTitle')}</FieldLabel>
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.wiz.mapHint')}
</div>
<div className="grid gap-2 sm:grid-cols-2">
{MAP_FIELDS.map(({ key, required }) => (
<label key={key} className="block">
<span className="mb-[4px] block font-display text-[10px] font-bold">
{t(FIELD_LABEL[key])}
{required ? ' *' : ''}
</span>
<select
value={colMap[key] ?? ''}
onChange={(e) =>
setColMap((m) => ({ ...m, [key]: e.target.value || undefined }))
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.headers.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</label>
))}
</div>
</div>
<div>
<FieldLabel>{t('config.vol.wiz.availTitle')}</FieldLabel>
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.wiz.availHint')}
</div>
<div className="flex flex-col gap-2">
{availRows.map((row, i) => (
<div key={i} className="flex flex-wrap items-end gap-2">
<label className="min-w-[140px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availColumn')}
</span>
<select
value={row.header}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) => (j === i ? { ...r, header: e.target.value } : r)),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.headers.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</label>
<label className="min-w-[130px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availDate')}
</span>
<select
value={row.date}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) => (j === i ? { ...r, date: e.target.value } : r)),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.edition_dates.map((d) => (
<option key={d} value={d}>
{shortDayLabel(`${d}T12:00`)} ({d})
</option>
))}
</select>
</label>
<label className="min-w-[130px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availPart')}
</span>
<select
value={row.day_part_code}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) =>
j === i ? { ...r, day_part_code: e.target.value } : r,
),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.day_parts.map((dp) => (
<option key={dp.code} value={dp.code}>
{dp.label}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setAvailRows((rs) => rs.filter((_, j) => j !== i))}
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-orange"
>
{t('action.remove')}
</button>
</div>
))}
<button
type="button"
onClick={() =>
setAvailRows((rs) => [...rs, { header: '', date: '', day_part_code: '' }])
}
className="cursor-pointer self-start border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow"
>
+ {t('config.vol.wiz.availAdd')}
</button>
</div>
</div>
<div className="overflow-x-auto border-2 border-ink">
<table className="w-full border-collapse font-mono text-[10px]">
<thead>
<tr className="bg-ink text-cream">
{analysis.headers.map((h) => (
<th key={h} className="border border-cream/30 px-[6px] py-[4px] text-left">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{analysis.preview.map((r) => (
<tr key={r.line}>
{analysis.headers.map((h) => (
<td key={h} className="border border-sand-line px-[6px] py-[3px]">
{r.values[h] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{mapError ? (
<div className="border-2 border-ink bg-orange p-[9px] font-display text-[11px] font-bold">
{mapError}
</div>
) : null}
{execute.error ? <ErrorNote error={execute.error} /> : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void run(true)}
disabled={execute.isPending}
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.wiz.simulate')}
</button>
<button
type="button"
onClick={() => {
setAnalysis(null);
setFile(null);
}}
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
>
{t('config.vol.wiz.back')}
</button>
</div>
</>
) : null}
{/* Étape 3 : rapport */}
{step === 3 && report ? (
<>
<div
className={cx(
'border-2 border-ink p-[10px] font-display text-[12px] font-black uppercase tracking-wide',
report.dry_run ? 'bg-yellow-soft' : 'bg-green text-white',
)}
>
{report.dry_run ? t('config.vol.wiz.dryRunTitle') : t('config.vol.wiz.doneTitle')}
</div>
<div className="flex flex-wrap gap-2 font-mono text-[11px]">
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.created', { count: report.created })}
</span>
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.updated', { count: report.updated })}
</span>
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.skipped', { count: report.skipped })}
</span>
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.availWritten', { count: report.availabilities_written })}
</span>
</div>
{report.skills_created.length > 0 ? (
<div className="font-mono text-[11px]">
{t('config.vol.wiz.skillsCreated', { list: report.skills_created.join(', ') })}
</div>
) : null}
{(() => {
const flagged = report.rows.filter((r) => r.issues.length > 0);
if (flagged.length === 0) {
return (
<div className="font-mono text-[11px] opacity-70">
{t('config.vol.wiz.noIssues')}
</div>
);
}
return (
<div>
<FieldLabel>{t('config.vol.wiz.rowsWithIssues')}</FieldLabel>
<div className="flex max-h-[280px] flex-col gap-[6px] overflow-auto border-2 border-ink p-[8px]">
{flagged.map((r) => (
<div key={r.line} className="font-mono text-[10.5px]">
<span className="font-bold">{t('config.vol.wiz.line', { line: r.line })}</span>{' '}
<span className="opacity-60">· {r.action}</span>
<ul className="ml-3 list-disc">
{r.issues.map((issue, k) => (
<li
key={k}
className={cx(
issue.severity === 'ERROR' && 'font-bold text-orange-dark',
)}
>
{t(issue.code, issueParams(issue))}
</li>
))}
</ul>
</div>
))}
</div>
</div>
);
})()}
{execute.error ? <ErrorNote error={execute.error} /> : null}
<div className="flex flex-wrap gap-2">
{report.dry_run ? (
<>
<button
type="button"
onClick={() => void run(false)}
disabled={execute.isPending}
className="cursor-pointer border-[3px] border-ink bg-green px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide text-white shadow-brutal hover:bg-ink disabled:opacity-60"
>
{t('config.vol.wiz.runImport')}
</button>
<button
type="button"
onClick={restart}
className="cursor-pointer border-[3px] border-ink bg-cream px-[14px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
>
{t('config.vol.wiz.back')}
</button>
</>
) : (
<button
type="button"
onClick={onClose}
className="cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-[10px] font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
>
{t('config.vol.wiz.close')}
</button>
)}
</div>
</>
) : null}
</div>
</div>
</div>
);
}