Photo du bénévole ajoutée par la coordination
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 14s

Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:17:25 +01:00
co-authored by Claude Opus 5.5
parent 54cba467fc
commit 6892e0307d
12 changed files with 288 additions and 64 deletions
+81 -14
View File
@@ -14,6 +14,8 @@ import {
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useRemoveVolunteerPhoto,
useSetVolunteerPhoto,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
@@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
EXPIRED: 'config.vol.feeStatus.expired',
};
/**
* 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, photo. N'apparaît que si le bénévole vient
* 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({
@@ -181,12 +259,8 @@ function PublicFormAnswers({
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;
Boolean(volunteer.companion_wish_note) || volunteer.self_declared_available;
if (!hasAnything) return null;
return (
@@ -195,14 +269,6 @@ function PublicFormAnswers({
{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>
@@ -894,6 +960,7 @@ export function ConfigVolunteers() {
onChange={(v) => patch({ badge_text: v || null })}
/>
</label>
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />