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 = { 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 (
{t('config.vol.access.title')} {account ? (
{t('config.vol.access.active', { email: account.email })} {t(roleLabelKey(account.role))}
) : ( )} {!account && !email ? (
{t('config.vol.access.needEmail')}
) : null} {account && account.auth_method === 'magic_link' ? (
{resetLoginLink.isSuccess ? ( {t('config.vol.access.resetLinkDone')} ) : null}
) : null} {createAccount.isError ? : null} {deleteAccount.isError ? : null} {resetLoginLink.isError ? : null}
); } const FEE_STATUS_LABEL_KEY: Record, 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 ( ); } /** * 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(null); const pending = setPhoto.isPending || removePhoto.isPending; return (
{t('config.vol.photo.title')}
{photoUrl ? ( ) : ( {t('config.vol.photo.none')} )}
{volunteer.has_photo ? ( ) : null}
{ const file = e.target.files?.[0]; if (file) setPhoto.mutate({ id: volunteer.id, file }); e.target.value = ''; }} />
{setPhoto.isError ? : null} {removePhoto.isError ? : null}
); } /** * 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 (
{t('config.vol.publicForm.title')} {volunteer.companion_wish_note ? (