Photo du bénévole ajoutée par la coordination
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:
co-authored by
Claude Opus 5.5
parent
54cba467fc
commit
6892e0307d
@@ -103,9 +103,15 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
|
||||
* `null` si absente. */
|
||||
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
||||
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
|
||||
* ajoutée par la coordination), ou `null` si absente. `version` (ex.
|
||||
* `updated_at`) force le rechargement quand la photo est remplacée —
|
||||
* `hasPhoto` resterait `true` et rien ne changerait sinon. */
|
||||
export function useVolunteerPhotoUrl(
|
||||
volunteerId: string | null | undefined,
|
||||
hasPhoto: boolean,
|
||||
version?: string,
|
||||
) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
@@ -123,7 +129,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [volunteerId, hasPhoto]);
|
||||
}, [volunteerId, hasPhoto, version]);
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -361,6 +367,23 @@ export function useUpdateVolunteer() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetVolunteerPhoto() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) =>
|
||||
api.volunteers.setPhoto(edition, id, file),
|
||||
onSuccess: invalidate.volunteers,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveVolunteerPhoto() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.volunteers.removePhoto(edition, id),
|
||||
onSuccess: invalidate.volunteers,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVolunteer() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
|
||||
@@ -155,6 +155,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
planning: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
|
||||
setPhoto: (editionId, id, file: File) => {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
|
||||
},
|
||||
removePhoto: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
|
||||
exportXlsx: async (editionId) => {
|
||||
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||
|
||||
@@ -1069,6 +1069,21 @@ export function createMockApi(): PlanningApi {
|
||||
if (!dataUrl) return null;
|
||||
return (await fetch(dataUrl)).blob();
|
||||
},
|
||||
setPhoto: async (_edition, id, file) => {
|
||||
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
||||
if (!['image/png', 'image/jpeg'].includes(file.type)) {
|
||||
throw new ApiError(422, 'form.photo_type_not_supported');
|
||||
}
|
||||
state.photos.set(id, await fileToDataUrl(file));
|
||||
Object.assign(v, { has_photo: true, updated_at: touch() });
|
||||
return delay(v);
|
||||
},
|
||||
removePhoto: (_edition, id) => {
|
||||
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
|
||||
state.photos.delete(id);
|
||||
Object.assign(v, { has_photo: false, updated_at: touch() });
|
||||
return delay(v);
|
||||
},
|
||||
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||
exportMecenatReceipts: () => {
|
||||
const paid = state.volunteers.some(
|
||||
|
||||
@@ -88,8 +88,13 @@ export interface PlanningApi {
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
|
||||
/** Photo transmise via le formulaire public, `null` si absente. */
|
||||
/** Photo transmise via le formulaire public (ou ajoutée par la
|
||||
* coordination), `null` si absente. */
|
||||
photoBlob(editionId: string, id: string): Promise<Blob | null>;
|
||||
/** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */
|
||||
setPhoto(editionId: string, id: string, file: File): Promise<VolunteerRead>;
|
||||
/** Retire la photo (COORDINATION). */
|
||||
removePhoto(editionId: string, id: string): Promise<VolunteerRead>;
|
||||
/** Export Excel de la liste des bénévoles (COORDINATION). */
|
||||
exportXlsx(editionId: string): Promise<Blob>;
|
||||
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
|
||||
|
||||
@@ -232,6 +232,13 @@
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
|
||||
"config.vol.photo.title": "Photo",
|
||||
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
|
||||
"config.vol.photo.none": "Aucune photo",
|
||||
"config.vol.photo.add": "Ajouter une photo",
|
||||
"config.vol.photo.replace": "Remplacer la photo",
|
||||
"config.vol.photo.remove": "Retirer",
|
||||
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
|
||||
"config.vol.access.title": "Accès de connexion",
|
||||
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
|
||||
"config.vol.access.grant": "Donner un accès de connexion",
|
||||
@@ -356,7 +363,7 @@
|
||||
"badges.field.role.placeholder": "Référent",
|
||||
"badges.field.photo": "Photo du bénévole",
|
||||
"badges.field.photo.placeholder": "Photo",
|
||||
"badges.field.photo.help": "La photo envoyée avec le formulaire d'inscription, recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||
"badges.field.freeText": "Texte libre",
|
||||
"badges.field.freeText.placeholder": "Accès backstage",
|
||||
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
|
||||
@@ -543,6 +550,7 @@
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
|
||||
"volunteer.empty_photo": "Le fichier de la photo est vide.",
|
||||
"form.photo_too_large": "La photo est trop volumineuse.",
|
||||
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user