Files
benevoles/frontend/src/pages/config/ConfigVolunteers.tsx
T
AdamThomasandClaude Opus 5.5 05cdb0276b
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s
Lien de connexion directe permanent dans le récapitulatif de planning
{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:01:03 +01:00

1550 lines
60 KiB
TypeScript

import { useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
import {
useAccounts,
useAnalyzeImport,
useArtists,
useCreateAccount,
useCreateVolunteer,
useDeleteAccount,
useResetLoginLink,
useDeleteVolunteer,
useExecuteImport,
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useRemoveVolunteerPhoto,
useSetVolunteerPhoto,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
useVolunteerPhotoUrl,
useVolunteers,
} from '@/api/hooks';
import type {
ArtistRead,
ImportAnalyzeResponse,
ImportFieldKey,
ImportIssue,
ImportMapping,
ImportReport,
PositionRead,
VolunteerCreate,
VolunteerRead,
VolunteerUpdate,
} from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { feeMode } from '@/lib/fee';
import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { cx } from '@/lib/cx';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
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: 'comment', 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',
comment: 'config.vol.field.comment',
};
type WishOption = { id: string; label: string; hint?: string };
function artistOptions(artists: ArtistRead[] | undefined): WishOption[] {
return [...(artists ?? [])]
.sort(
(a, b) =>
a.performance_date.localeCompare(b.performance_date) ||
a.name.localeCompare(b.name, 'fr'),
)
.map((a) => ({
id: a.id,
label: a.name,
hint: shortDayLabel(`${a.performance_date}T12:00`),
}));
}
function positionOptions(positions: PositionRead[] | undefined): WishOption[] {
return [...(positions ?? [])]
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
.map((p) => ({ id: p.id, label: p.name, hint: p.location ?? undefined }));
}
/** Les autres bénévoles, pour le vœu de compagnon (jamais soi-même). */
function volunteerOptions(
volunteers: VolunteerRead[] | undefined,
excludeId?: string,
): WishOption[] {
return [...(volunteers ?? [])]
.filter((v) => v.id !== excludeId)
.sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') ||
a.first_name.localeCompare(b.first_name, 'fr'),
)
.map((v) => ({ id: v.id, label: `${v.first_name} ${v.last_name}` }));
}
/**
* Accès de connexion (lien magique) du bénévole. Ajouter un email sur sa
* fiche ne crée aucun compte : sans ce geste explicite, `auth/magic-link` ne
* trouve personne à qui écrire et ne fait rien (par conception, pour ne
* jamais révéler si un email existe).
*/
function VolunteerAccessSection({
volunteerId,
email,
}: {
volunteerId: string;
email: string | null;
}) {
const accounts = useAccounts();
const createAccount = useCreateAccount();
const deleteAccount = useDeleteAccount();
const resetLoginLink = useResetLoginLink();
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
return (
<div>
<FieldLabel>
{t('config.vol.access.title')} <HelpTip text={t('config.vol.access.hint')} />
</FieldLabel>
{account ? (
<div className="flex items-center justify-between gap-2 border-2 border-ink bg-cream p-[9px]">
<span className="min-w-0 truncate font-mono text-[10px]">
{t('config.vol.access.active', { email: account.email })}
<span className="ml-[6px] border-[1.5px] border-ink bg-yellow px-1 py-[1px] font-display text-[8px] font-extrabold uppercase tracking-wide">
{t(roleLabelKey(account.role))}
</span>
</span>
<button
type="button"
disabled={deleteAccount.isPending}
onClick={() => {
if (window.confirm(t('config.vol.access.revokeConfirm'))) {
deleteAccount.mutate(account.id);
}
}}
className="flex-none cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-orange disabled:opacity-50"
>
{t('config.vol.access.revoke')}
</button>
</div>
) : (
<button
type="button"
disabled={!email || createAccount.isPending}
onClick={() =>
email && createAccount.mutate({ email, role: 'VOLUNTEER', volunteer_id: volunteerId })
}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[7px] font-display text-[10.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
>
{createAccount.isPending ? t('app.loading') : t('config.vol.access.grant')}
</button>
)}
{!account && !email ? (
<div className="mt-1 font-mono text-[9.5px] opacity-60">
{t('config.vol.access.needEmail')}
</div>
) : null}
{account && account.auth_method === 'magic_link' ? (
<div className="mt-[6px] flex flex-wrap items-center gap-2">
<button
type="button"
disabled={resetLoginLink.isPending}
onClick={() => {
if (window.confirm(t('config.vol.access.resetLinkConfirm'))) {
resetLoginLink.mutate(account.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
>
{t('config.vol.access.resetLink')}
</button>
<HelpTip text={t('config.vol.access.resetLinkHint')} />
{resetLoginLink.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold">{t('config.vol.access.resetLinkDone')}</span>
) : null}
</div>
) : null}
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
</div>
);
}
const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.feeStatus.pending',
PAID: 'config.vol.feeStatus.paid',
FAILED: 'config.vol.feeStatus.failed',
EXPIRED: 'config.vol.feeStatus.expired',
};
/**
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
*/
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
const [draft, setDraft] = useState(String(value));
const parse = (raw: string) => {
const n = Number(raw);
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
};
return (
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeCount')}
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
</FieldLabel>
<input
type="number"
min={1}
max={100}
step={1}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parse(e.target.value);
if (n !== null && n !== value) onChange(n);
}}
onBlur={() => {
if (parse(draft) === null) setDraft(String(value));
}}
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
/>
</label>
);
}
/**
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
* pour un bénévole qui n'en a pas donné (ajouté à la main, importé…).
*/
function VolunteerPhotoSection({ volunteer }: { volunteer: VolunteerRead }) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo, volunteer.updated_at);
const setPhoto = useSetVolunteerPhoto();
const removePhoto = useRemoveVolunteerPhoto();
const inputRef = useRef<HTMLInputElement>(null);
const pending = setPhoto.isPending || removePhoto.isPending;
return (
<div>
<FieldLabel>
{t('config.vol.photo.title')} <HelpTip text={t('config.vol.photo.hint')} />
</FieldLabel>
<div className="flex items-center gap-3">
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : (
<span className="flex h-[90px] w-[90px] flex-none items-center justify-center border-2 border-dashed border-ink/50 bg-cream font-mono text-[9.5px] opacity-60">
{t('config.vol.photo.none')}
</span>
)}
<div className="flex flex-col items-start gap-2">
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[7px] font-display text-[10.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
>
{setPhoto.isPending
? t('app.loading')
: volunteer.has_photo
? t('config.vol.photo.replace')
: t('config.vol.photo.add')}
</button>
{volunteer.has_photo ? (
<button
type="button"
disabled={pending}
onClick={() => {
if (window.confirm(t('config.vol.photo.removeConfirm'))) {
removePhoto.mutate(volunteer.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-orange disabled:opacity-50"
>
{t('config.vol.photo.remove')}
</button>
) : null}
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setPhoto.mutate({ id: volunteer.id, file });
e.target.value = '';
}}
/>
</div>
{setPhoto.isError ? <ErrorNote error={setPhoto.error} /> : null}
{removePhoto.isError ? <ErrorNote error={removePhoto.error} /> : null}
</div>
);
}
/**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
* cases informatives (la photo a sa propre section, `VolunteerPhotoSection`).
* N'apparaît que si le bénévole vient
* effectivement de ce formulaire (sinon rien à afficher).
*/
function PublicFormAnswers({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const hasAnything =
Boolean(volunteer.companion_wish_note) || volunteer.self_declared_available;
if (!hasAnything) return null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
</FieldLabel>
{volunteer.companion_wish_note ? (
<label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
<textarea
value={volunteer.companion_wish_note}
onChange={(e) => patch({ companion_wish_note: e.target.value || null })}
rows={2}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/>
</label>
) : null}
<div className="flex flex-wrap gap-[14px]">
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.self_declared_available}
onChange={(e) => patch({ self_declared_available: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
</div>
</div>
);
}
/**
* Cotisation du bénévole (voir `lib/fee.ts:feeMode`) : paiement en ligne
* (type, montant, statut, mécénat), paiement sur place, ou rien. Toujours
* affichée, pour qu'un « rien » se voie aussi ; le paiement sur place reste
* modifiable à la main (bénévole ajouté sans le formulaire, changement
* d'avis…), un paiement en ligne non — son statut vient de SumUp.
*/
function FeeSection({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const mode = feeMode(volunteer);
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>{t('config.vol.fee.title')}</FieldLabel>
{mode === 'ONLINE' && volunteer.fee_payment_status ? (
<>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="border-2 border-ink bg-blue px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.online')}
</span>
{volunteer.mecenat_address ? (
<span className="border-2 border-ink bg-mauve px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.mecenat')}
</span>
) : null}
<span
className={cx(
'border-2 border-ink px-[8px] py-[3px] font-mono text-[10.5px] font-bold',
volunteer.fee_payment_status === 'PAID' ? 'bg-green text-cream' : 'bg-yellow-soft',
)}
>
{t(FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status])}
</span>
</div>
{volunteer.fee_payment_label ? (
<p className="font-mono text-[11px]">
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
</p>
) : null}
{volunteer.fee_payment_status === 'PAID' ? <FeeTransaction volunteer={volunteer} /> : null}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
{volunteer.mecenat_postal_code} {volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
</>
) : (
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px] font-bold">{t('config.vol.fee.onSite')}</span>
{mode === 'NONE' ? (
<span className="font-mono text-[10px] opacity-60">— {t('config.vol.fee.none')}</span>
) : null}
</label>
)}
</div>
);
}
/**
* Rapprochement avec SumUp : date du paiement et transaction qui l'a réglé.
* Le code (`TEENSK4W2K`) est celui que montre le tableau de bord SumUp —
* c'est lui qu'on y recherche pour retrouver le paiement de ce bénévole.
* Absent pour un paiement confirmé avant que l'appli ne l'enregistre.
*/
function FeeTransaction({ volunteer }: { volunteer: VolunteerRead }) {
const paidAt = volunteer.fee_paid_at;
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-[10px] gap-y-[3px] font-mono text-[10.5px]">
{paidAt ? (
<>
<dt className="opacity-70">{t('config.vol.fee.paidAt')}</dt>
<dd>
{datePart(paidAt).split('-').reverse().join('/')} {timePart(paidAt)}
</dd>
</>
) : null}
<dt className="flex items-center gap-[4px] opacity-70">
{t('config.vol.fee.transaction')}{' '}
<HelpTip text={t('config.vol.fee.transactionHint')} />
</dt>
<dd className="min-w-0">
{volunteer.fee_transaction_code ? (
<span className="select-all break-all font-bold">{volunteer.fee_transaction_code}</span>
) : (
<span className="opacity-60">{t('config.vol.fee.transactionUnknown')}</span>
)}
</dd>
{volunteer.fee_transaction_id ? (
<>
<dt className="opacity-70">{t('config.vol.fee.transactionId')}</dt>
<dd className="min-w-0 select-all break-all opacity-70">{volunteer.fee_transaction_id}</dd>
</>
) : null}
</dl>
);
}
/**
* Postes dont ce bénévole est référent (`Position.lead_account_id`). Cocher
* un poste crée ou met à niveau son accès de connexion en RÉFÉRENT (LEAD) —
* à sa prochaine connexion il verra l'écran référent, pas l'écran bénévole
* (voir `auth/roles.ts:homePath`). Décocher retire juste son nom du poste,
* sans jamais rétrograder son accès automatiquement.
*/
function VolunteerReferentSection({
volunteerId,
email,
}: {
volunteerId: string;
email: string | null;
}) {
const accounts = useAccounts();
const positions = usePositions();
const createAccount = useCreateAccount();
const updateAccount = useUpdateAccount();
const updatePosition = useUpdatePosition();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
const isCoordinator = account?.role === 'COORDINATOR';
const options = useMemo(() => positionOptions(positions.data), [positions.data]);
const referentIds = (positions.data ?? [])
.filter((p) => account && p.lead_account_id === account.id)
.map((p) => p.id);
const ensureLeadAccountId = async (): Promise<string> => {
if (account) {
if (account.role !== 'LEAD') {
await updateAccount.mutateAsync({ id: account.id, body: { role: 'LEAD' } });
}
return account.id;
}
if (!email) throw new ApiError(422, 'config.vol.referentNeedsEmail');
const created = await createAccount.mutateAsync({
email,
role: 'LEAD',
volunteer_id: volunteerId,
});
return created.id;
};
const handleChange = (nextIds: string[]) => {
const added = nextIds.filter((id) => !referentIds.includes(id));
const removed = referentIds.filter((id) => !nextIds.includes(id));
if (added.length === 0 && removed.length === 0) return;
setError(null);
setPending(true);
void (async () => {
try {
let leadAccountId: string | null = null;
if (added.length > 0) leadAccountId = await ensureLeadAccountId();
for (const id of added) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: leadAccountId } });
}
for (const id of removed) {
await updatePosition.mutateAsync({ id, body: { lead_account_id: null } });
}
} catch (err) {
setError(err instanceof ApiError ? t(err.code) : t('error.generic'));
} finally {
setPending(false);
}
})();
};
const label = (
<>
{t('config.vol.field.referentPositions')} <HelpTip text={t('config.vol.referentHint')} />
</>
);
if (isCoordinator) {
return (
<div>
<FieldLabel>{label}</FieldLabel>
<div className="font-mono text-[10px] opacity-60">
{t('config.vol.referentIsCoordinator')}
</div>
</div>
);
}
return (
<div>
<BadgeWishPicker
label={label}
options={options}
selected={referentIds}
onChange={handleChange}
emptyLabel={t('config.vol.noPositions')}
disabled={pending}
/>
{referentIds.length === 0 && !email ? (
<div className="mt-1 font-mono text-[9.5px] opacity-60">
{t('config.vol.access.needEmail')}
</div>
) : null}
{error ? (
<div className="mt-1 border-2 border-ink bg-orange p-[7px] font-display text-[10px] font-bold">
{error}
</div>
) : null}
</div>
);
}
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
* longue) — options déjà triées par ordre alphabétique par l'appelant. */
function WishPicker({
label,
options,
selected,
onChange,
emptyLabel,
addLabel,
searchPlaceholder,
}: {
label: ReactNode;
options: WishOption[];
selected: string[];
onChange: (ids: string[]) => void;
emptyLabel: string;
addLabel: string;
searchPlaceholder: string;
}) {
const [search, setSearch] = useState('');
const byId = new Map(options.map((o) => [o.id, o]));
const available = options.filter((o) => !selected.includes(o.id));
const needle = foldAccents(search.trim());
const filtered = needle
? available.filter((o) => foldAccents(`${o.hint ?? ''} ${o.label}`).includes(needle))
: available;
return (
<div>
<FieldLabel>{label}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.map((id) => (
<button
key={id}
type="button"
onClick={() => onChange(selected.filter((x) => x !== id))}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{byId.get(id)?.label ?? id} ✕
</button>
))}
{selected.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : null}
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
disabled={available.length === 0}
className="mb-[6px] w-full border-2 border-ink bg-paper p-[7px] font-mono text-[11px] disabled:opacity-50"
/>
<select
value=""
onChange={(e) => {
if (e.target.value) {
onChange([...selected, e.target.value]);
setSearch('');
}
}}
disabled={available.length === 0}
size={search ? Math.min(6, Math.max(2, filtered.length + 1)) : undefined}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px] disabled:opacity-50"
>
<option value="">{addLabel}</option>
{filtered.map((o) => (
<option key={o.id} value={o.id}>
{o.hint ? `${o.hint} · ${o.label}` : o.label}
</option>
))}
</select>
</div>
);
}
/** Sélecteur de vœux en badges à cocher (artistes, postes : peu d'options,
* toutes visibles d'un coup, pas de liste déroulante à ouvrir). */
function BadgeWishPicker({
label,
options,
selected,
onChange,
emptyLabel,
disabled,
}: {
label: ReactNode;
options: WishOption[];
selected: string[];
onChange: (ids: string[]) => void;
emptyLabel: string;
disabled?: boolean;
}) {
const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
return (
<div>
<FieldLabel>{label}</FieldLabel>
{options.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : (
<div className={cx('flex flex-wrap gap-[6px]', disabled && 'pointer-events-none opacity-50')}>
{options.map((o) => (
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
{o.hint ? `${o.hint} · ${o.label}` : o.label}
</Chip>
))}
</div>
)}
</div>
);
}
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;
}
// Mobile : nom + cotisation seulement (téléphone, email et vœux restent sur
// la fiche) ; toutes les colonnes à partir de `md:`.
const LIST_COLUMNS =
'grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.2fr)]';
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
* d'un paiement en ligne non abouti. */
function FeeTag({ volunteer }: { volunteer: VolunteerRead }) {
const mode = feeMode(volunteer);
if (mode === 'NONE') return <span className="font-mono text-[10px] opacity-40">—</span>;
if (mode === 'ON_SITE') {
return (
<span className="truncate font-mono text-[10px] font-bold">{t('config.vol.fee.onSiteShort')}</span>
);
}
const paid = volunteer.fee_payment_status === 'PAID';
return (
<span className={cx('truncate font-mono text-[10px] font-bold', paid ? 'text-green' : 'text-orange-dark')}>
{t(volunteer.mecenat_address ? 'config.vol.fee.mecenatShort' : 'config.vol.fee.onlineShort')}
{paid ? ' ✓' : ` · ${t(FEE_STATUS_SHORT_KEY[volunteer.fee_payment_status ?? 'PENDING'])}`}
</span>
);
}
const FEE_STATUS_SHORT_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.fee.short.pending',
PAID: 'config.vol.fee.short.paid',
FAILED: 'config.vol.fee.short.failed',
EXPIRED: 'config.vol.fee.short.expired',
};
export function ConfigVolunteers() {
const volunteers = useVolunteers();
const artists = useArtists();
const positions = usePositions();
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const exportVolunteers = useExportVolunteers();
const exportMecenatReceipts = useExportMecenatReceipts();
const [mode, setMode] = useState<Mode>('list');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
const companionOpts = useMemo(
() => volunteerOptions(volunteers.data, selectedId ?? undefined),
[volunteers.data, selectedId],
);
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) ?? null;
}, [volunteers.data, selectedId]);
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
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 handleExport = async () => {
try {
const blob = await exportVolunteers.mutateAsync();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'benevoles.xlsx';
a.click();
URL.revokeObjectURL(url);
} catch {
// Erreur exposée via exportVolunteers.isError / .error, affichée plus bas.
}
};
const handleExportMecenatReceipts = async () => {
try {
const blob = await exportMecenatReceipts.mutateAsync();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recus-mecenat.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// Erreur exposée via exportMecenatReceipts.isError / .error, affichée plus bas.
}
};
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="min-w-0 flex-1 p-3 sm:p-4 lg:max-h-[calc(100vh-118px)] lg:overflow-auto">
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
<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="flex items-center gap-[7px] font-display text-[15px] font-black">
{t('config.vol.importTitle')}
<HelpTip text={t('config.vol.importHint')} side="bottom" />
</span>
</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>
<button
type="button"
onClick={handleExport}
disabled={exportVolunteers.isPending}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
↑ {exportVolunteers.isPending ? t('config.vol.exporting') : t('config.vol.exportButton')}
</button>
<button
type="button"
onClick={handleExportMecenatReceipts}
disabled={exportMecenatReceipts.isPending}
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 disabled:cursor-not-allowed disabled:opacity-50"
>
↑{' '}
{exportMecenatReceipts.isPending
? t('config.vol.exporting')
: t('config.vol.mecenatReceiptsButton')}
</button>
<HelpTip text={t('config.vol.mecenatReceiptsHint')} />
</div>
{exportVolunteers.isError ? (
<ErrorNote error={exportVolunteers.error} />
) : null}
{exportMecenatReceipts.isError ? (
<ErrorNote error={exportMecenatReceipts.error} />
) : null}
</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">{total}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.profiles')}
</div>
</div>
</div>
<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={cx(
'grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream',
LIST_COLUMNS,
)}
>
<span>{t('config.vol.colName')}</span>
<span className="hidden md:block">{t('config.vol.colPhone')}</span>
<span className="hidden md:block">{t('config.vol.colEmail')}</span>
<span className="hidden md:block">{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</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-[9px] text-left md:py-[7px]',
LIST_COLUMNS,
active ? 'bg-yellow' : 'hover:bg-cream',
)}
>
<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">
{volunteerInitials(volunteer)}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteerName(volunteer)}
</span>
</span>
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
{volunteer.phone ?? '—'}
</span>
<span className="hidden truncate font-mono text-[10.5px] opacity-70 md:block">
{volunteer.email ?? '—'}
</span>
<span className="hidden truncate font-mono text-[10px] opacity-70 md:block">
{t('config.vol.wishesCount', {
count:
volunteer.artist_ids.length +
volunteer.wished_position_ids.length +
volunteer.wished_companion_ids.length,
})}
</span>
<FeeTag volunteer={volunteer} />
</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>
{/* Fiche : à côté de la liste sur grand écran, par-dessus (plein écran
sur mobile, tiroir à droite sur tablette) en dessous de `lg:`. */}
{selected ? (
<div
aria-hidden
onClick={() => setSelectedId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
{selected ? (
<aside className="fixed inset-y-0 right-0 z-50 w-full overflow-auto bg-paper sm:w-[400px] sm:border-l-[3px] sm:border-ink lg:static lg:z-auto lg:max-h-[calc(100vh-118px)] lg:w-[360px] lg:flex-none lg:border-l-[3px]">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-center justify-between">
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
{t('config.vol.ficheKicker')}
<HelpTip text={t('config.vol.note')} side="bottom" />
</div>
<button
type="button"
onClick={() => setSelectedId(null)}
aria-label={t('action.close')}
className="h-8 w-8 cursor-pointer border-2 border-cream/40 font-mono text-[12px] text-cream/80 hover:text-cream lg:h-auto lg:w-auto lg:border-0"
>
✕
</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">
{volunteerInitials(selected)}
</span>
<span className="font-display text-[18px] font-black tracking-tight">
{volunteerName(selected)}
</span>
</div>
</div>
<div className="flex flex-col gap-3 p-[13px]">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<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.field.lastName')}</FieldLabel>
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
</label>
</div>
<label className="block">
<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.field.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: 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>
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeText')}
<HelpTip text={t('config.vol.field.badgeText.help')} className="ml-[6px]" />
</FieldLabel>
<TextInput
value={selected.badge_text ?? ''}
onChange={(v) => patch({ badge_text: v || null })}
/>
</label>
<BadgeCountInput
key={`${selected.id}-${selected.badge_count}`}
value={selected.badge_count}
onChange={(badge_count) => patch({ badge_count })}
/>
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
<BadgeWishPicker
label={t('config.vol.field.artists')}
options={artistOpts}
selected={selected.artist_ids}
onChange={(ids) => patch({ artist_ids: ids })}
emptyLabel={t('config.vol.noArtists')}
/>
<BadgeWishPicker
label={t('config.vol.field.positions')}
options={positionOpts}
selected={selected.wished_position_ids}
onChange={(ids) => patch({ wished_position_ids: ids })}
emptyLabel={t('config.vol.noPositions')}
/>
<WishPicker
label={
<>
{t('config.vol.field.companions')}{' '}
<HelpTip text={t('config.vol.companionsHint')} />
</>
}
options={companionOpts}
selected={selected.wished_companion_ids}
onChange={(ids) => patch({ wished_companion_ids: ids })}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<FeeSection volunteer={selected} patch={patch} />
<PublicFormAnswers volunteer={selected} patch={patch} />
<button
type="button"
onClick={() => {
if (window.confirm(t('config.vol.deleteConfirm'))) {
deleteVolunteer.mutate(selected.id);
setSelectedId(null);
}
}}
className="mt-2 cursor-pointer border-[3px] border-ink bg-cream p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-orange"
>
{t('config.vol.delete')}
</button>
</div>
</aside>
) : null}
</div>
);
}
// --------------------------------------------------------------------------- //
// Ajout manuel //
// --------------------------------------------------------------------------- //
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const createVolunteer = useCreateVolunteer();
const artists = useArtists();
const positions = usePositions();
const volunteers = useVolunteers();
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
const [artistIds, setArtistIds] = useState<string[]>([]);
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
const [wishedCompanionIds, setWishedCompanionIds] = useState<string[]>([]);
const [localError, setLocalError] = useState<string | null>(null);
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
const companionOpts = useMemo(() => volunteerOptions(volunteers.data), [volunteers.data]);
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const submit = async () => {
setLocalError(null);
// Aucun champ obligatoire : la fiche peut être complétée plus tard.
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,
comment: form.comment?.trim() || null,
artist_ids: artistIds,
wished_position_ids: wishedPositionIds,
wished_companion_ids: wishedCompanionIds,
});
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="flex items-center gap-[7px] 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="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')} <HelpTip text={t('config.vol.addHint')} />
</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.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>
<BadgeWishPicker
label={t('config.vol.field.artists')}
options={artistOpts}
selected={artistIds}
onChange={setArtistIds}
emptyLabel={t('config.vol.noArtists')}
/>
<BadgeWishPicker
label={t('config.vol.field.positions')}
options={positionOpts}
selected={wishedPositionIds}
onChange={setWishedPositionIds}
emptyLabel={t('config.vol.noPositions')}
/>
<WishPicker
label={
<>
{t('config.vol.field.companions')}{' '}
<HelpTip text={t('config.vol.companionsHint')} />
</>
}
options={companionOpts}
selected={wishedCompanionIds}
onChange={setWishedCompanionIds}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
searchPlaceholder={t('config.vol.searchCompanion')}
/>
{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) //
// --------------------------------------------------------------------------- //
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 [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 });
} 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,
comment: colMap.comment || null,
},
});
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="flex items-center gap-[7px] font-display text-[14px] font-black">
{t('config.vol.wiz.title')}
{step === 1 ? <HelpTip text={t('config.vol.wiz.fileHint')} side="bottom" /> : null}
</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}
{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')} <HelpTip text={t('config.vol.wiz.mapHint')} />
</FieldLabel>
<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 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>
</div>
{(() => {
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>
);
}