Retire « constructeur de formulaire » des exclusions du CLAUDE.md (§ 5) — décision explicite prise en conversation. Un formulaire par édition (COORDINATION choisit les champs actifs parmi neuf : email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir de festival, vœu de compagnon en texte libre, disponibilité/ cotisation déclaratives, photo, commentaire ; nom/prénom toujours collectés) ouvert/fermé à volonté, avec un lien à partager (/inscription, aucun compte requis). Backend : FormTemplate/FormField (migration 0013), quatre nouveaux champs sur Volunteer (photo, companion_wish_note, self_declared_available, willing_to_pay_fee), app/api/forms.py — les deux seuls endpoints publics de toute l'API (GET .../form/public, POST .../form/public/submit), protégés par hCaptcha (app/forms/captcha.py, désactivé sans HCAPTCHA_SECRET_KEY) et un filet de sécurité global par édition (form_submission_limiter). resolve_owned_ids/set_artists/ set_wished_positions déplacés vers app/api/_common.py, partagés avec volunteers.py. Frontend : écran de configuration (/inscriptions) avec lien copiable et bascule ouvert/fermé, formulaire public (/inscription, hors RequireAuth) avec widget hCaptcha chargé dynamiquement, capture photo native (<input capture>), vœux de poste/artiste avec les mêmes règles que le back-end. Réponses du formulaire visibles et éditables depuis la fiche bénévole existante (ConfigVolunteers.tsx). Vérifié : pytest (273 passés), ruff, alembic check, tsc --noEmit, eslint, vite build, smoke (rendu SSR de chaque écran + flux de bout en bout du formulaire en mode démonstration) — tous verts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1354 lines
50 KiB
TypeScript
1354 lines
50 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
|
|
import { ApiError } from '@/api';
|
|
import { roleLabelKey } from '@/auth/roles';
|
|
import {
|
|
useAccounts,
|
|
useAnalyzeImport,
|
|
useArtists,
|
|
useCreateAccount,
|
|
useCreateVolunteer,
|
|
useDeleteAccount,
|
|
useDeleteVolunteer,
|
|
useEdition,
|
|
useExecuteImport,
|
|
usePositions,
|
|
useReplaceAvailability,
|
|
useUpdateAccount,
|
|
useUpdatePosition,
|
|
useUpdateVolunteer,
|
|
useVolunteerAvailability,
|
|
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 { foldAccents } from '@/lib/planning';
|
|
import { t } from '@/i18n';
|
|
import { shortDayLabel } from '@/lib/time';
|
|
import { cx } from '@/lib/cx';
|
|
|
|
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 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}
|
|
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
|
|
{deleteAccount.isError ? <ErrorNote error={deleteAccount.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, photo. 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 photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
|
|
|
|
const hasAnything =
|
|
volunteer.has_photo ||
|
|
Boolean(volunteer.companion_wish_note) ||
|
|
volunteer.self_declared_available ||
|
|
volunteer.willing_to_pay_fee;
|
|
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>
|
|
|
|
{photoUrl ? (
|
|
<img
|
|
src={photoUrl}
|
|
alt=""
|
|
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
|
|
/>
|
|
) : null}
|
|
|
|
{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>
|
|
<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]">{t('form.field.willingToPayFee')}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export function ConfigVolunteers() {
|
|
const volunteers = useVolunteers();
|
|
const artists = useArtists();
|
|
const positions = usePositions();
|
|
const updateVolunteer = useUpdateVolunteer();
|
|
const deleteVolunteer = useDeleteVolunteer();
|
|
|
|
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 });
|
|
|
|
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 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>
|
|
</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">{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="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.colWishes')}</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">
|
|
{t('config.vol.wishesCount', {
|
|
count:
|
|
volunteer.artist_ids.length +
|
|
volunteer.wished_position_ids.length +
|
|
volunteer.wished_companion_ids.length,
|
|
})}
|
|
</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="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)}
|
|
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]}
|
|
{selected.last_name[0]}
|
|
</span>
|
|
<span className="font-display text-[18px] font-black tracking-tight">
|
|
{selected.first_name} {selected.last_name}
|
|
</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>
|
|
|
|
<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')}
|
|
/>
|
|
|
|
<PublicFormAnswers volunteer={selected} patch={patch} />
|
|
|
|
<AvailabilityEditor volunteerId={selected.id} />
|
|
|
|
<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);
|
|
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,
|
|
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')}
|
|
<HelpTip text={t('config.vol.addAvailabilityHint')} side="bottom" />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// --------------------------------------------------------------------------- //
|
|
// Éditeur de disponibilités (fiche bénévole) //
|
|
// --------------------------------------------------------------------------- //
|
|
type WindowRow = { date: string; start: string; end: string };
|
|
|
|
function editionDays(start: string, end: string): string[] {
|
|
const out: string[] = [];
|
|
for (
|
|
let d = Date.parse(`${start}T00:00:00Z`);
|
|
d <= Date.parse(`${end}T00:00:00Z`);
|
|
d += 86_400_000
|
|
) {
|
|
out.push(new Date(d).toISOString().slice(0, 10));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function AvailabilityEditor({ volunteerId }: { volunteerId: string }) {
|
|
const edition = useEdition();
|
|
const windows = useVolunteerAvailability(volunteerId);
|
|
const replace = useReplaceAvailability();
|
|
|
|
const [rows, setRows] = useState<WindowRow[]>([]);
|
|
const [dirty, setDirty] = useState(false);
|
|
|
|
const serverRows = useMemo<WindowRow[]>(
|
|
() =>
|
|
(windows.data ?? []).map((w) => ({
|
|
date: w.date,
|
|
start: w.start_time.slice(0, 5),
|
|
end: w.end_time.slice(0, 5),
|
|
})),
|
|
[windows.data],
|
|
);
|
|
|
|
// Recharge depuis le serveur tant que l'utilisateur n'a rien modifié.
|
|
useEffect(() => {
|
|
if (!dirty) setRows(serverRows);
|
|
}, [serverRows, dirty]);
|
|
|
|
// Repart de zéro quand on change de bénévole.
|
|
useEffect(() => {
|
|
setDirty(false);
|
|
}, [volunteerId]);
|
|
|
|
const days = edition.data
|
|
? editionDays(edition.data.start_date, edition.data.end_date)
|
|
: [];
|
|
|
|
const patch = (i: number, next: Partial<WindowRow>) => {
|
|
setDirty(true);
|
|
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...next } : r)));
|
|
};
|
|
|
|
const save = () => {
|
|
replace.mutate(
|
|
{
|
|
volunteerId,
|
|
body: {
|
|
windows: rows.map((r) => ({
|
|
date: r.date,
|
|
start_time: `${r.start}:00`,
|
|
end_time: `${r.end}:00`,
|
|
})),
|
|
},
|
|
},
|
|
{ onSuccess: () => setDirty(false) },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<FieldLabel>
|
|
{t('config.vol.avail.title')} <HelpTip text={t('config.vol.avail.hint')} />
|
|
</FieldLabel>
|
|
<div className="flex flex-col gap-[6px]">
|
|
{rows.map((row, i) => (
|
|
<div key={i} className="flex flex-wrap items-center gap-1">
|
|
<select
|
|
value={row.date}
|
|
onChange={(e) => patch(i, { date: e.target.value })}
|
|
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[6px] font-mono text-[11px]"
|
|
>
|
|
{days.map((d) => (
|
|
<option key={d} value={d}>
|
|
{shortDayLabel(`${d}T12:00`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="time"
|
|
value={row.start}
|
|
onChange={(e) => patch(i, { start: e.target.value })}
|
|
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
|
/>
|
|
<input
|
|
type="time"
|
|
value={row.end}
|
|
onChange={(e) => patch(i, { end: e.target.value })}
|
|
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDirty(true);
|
|
setRows((rs) => rs.filter((_, j) => j !== i));
|
|
}}
|
|
aria-label={t('action.remove')}
|
|
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
{rows.length === 0 ? (
|
|
<span className="font-mono text-[10px] opacity-55">{t('config.vol.avail.none')}</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-2 flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={days.length === 0}
|
|
onClick={() => {
|
|
setDirty(true);
|
|
setRows((rs) => [...rs, { date: days[0], start: '08:00', end: '18:00' }]);
|
|
}}
|
|
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
|
>
|
|
+ {t('config.vol.avail.add')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={!dirty || replace.isPending}
|
|
onClick={save}
|
|
className="cursor-pointer border-2 border-ink bg-yellow px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-green hover:text-white disabled:opacity-50"
|
|
>
|
|
{t('config.vol.avail.save')}
|
|
</button>
|
|
</div>
|
|
{replace.isError ? <ErrorNote error={replace.error} /> : null}
|
|
</div>
|
|
);
|
|
}
|