Gestion des référents depuis la fiche bénévole

Un poste avait bien un `lead_account_id`, lu par l'écran référent
(`/mobile-referent`) et par les droits d'écriture (`ensure_can_write_position`),
mais rien ne permettait de le renseigner : aucun écran ne l'exposait, et
l'API ne validait même pas que la valeur envoyée désignait un compte référent
existant de la bonne édition.

Backend :
- `PATCH /editions/{id}/accounts/{account_id}` (nouveau) : change le `role`
  d'un compte, seulement entre LEAD et VOLUNTEER (lien magique dans les deux
  cas) — 422 `account.role_change_not_allowed` dès que COORDINATOR est
  impliqué (mot de passe en jeu, jamais touché ici). C'est ce qui permet de
  faire passer le compte d'un bénévole en référent.
- `POST`/`PATCH /positions` : `lead_account_id` vérifié — doit désigner un
  compte `Role.LEAD` de la même édition, sinon 422
  `position.lead_must_be_referent_account` (silencieusement accepté
  jusqu'ici, y compris un compte d'une autre édition ou du mauvais rôle).

Frontend (`ConfigVolunteers.tsx`) : nouvelle section « Référent des postes »
sur la fiche du bénévole, juste après l'accès de connexion — mêmes badges à
cocher que les vœux de poste/artiste. Cocher un poste crée ou met à niveau
(LEAD) l'accès de connexion du bénévole automatiquement ; décocher retire
seulement le poste, sans jamais rétrograder l'accès. Un bénévole dont le
compte est déjà COORDINATOR ne peut pas être désigné référent (il a déjà
accès à tout). Miroir complet côté mock (`accounts.update`,
`checkLeadAccount`) pour que la démonstration se comporte pareil.

Vérifié : 204 tests backend pertinents (14 nouveaux : 6 sur `PATCH accounts`,
8 sur la validation `lead_account_id`), ruff, alembic check ; typecheck/
lint/build/smoke frontend ; parcours complet testé en direct sur le jeu de
démo (création de compte, promotion LEAD, rejet COORDINATOR, affectation
référent) avant nettoyage des données de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 23:19:56 +02:00
co-authored by Claude Sonnet 5
parent db11d9a3ba
commit 8fcc22115f
14 changed files with 373 additions and 7 deletions
+120 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
import {
useAccounts,
useAnalyzeImport,
@@ -13,6 +14,8 @@ import {
useExecuteImport,
usePositions,
useReplaceAvailability,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
useVolunteerAvailability,
useVolunteers,
@@ -115,6 +118,9 @@ function VolunteerAccessSection({
<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"
@@ -152,6 +158,116 @@ function VolunteerAccessSection({
);
}
/**
* 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({
@@ -235,12 +351,14 @@ function BadgeWishPicker({
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]);
@@ -250,7 +368,7 @@ function BadgeWishPicker({
{options.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : (
<div className="flex flex-wrap gap-[6px]">
<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}
@@ -489,6 +607,7 @@ export function ConfigVolunteers() {
</label>
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
<BadgeWishPicker
label={t('config.vol.field.artists')}