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
+17 -1
View File
@@ -93,7 +93,15 @@ les tables sauf les tables d'association.
unique et éditable indépendamment par chacun). `CHECK (volunteer_id <>
companion_id)` : un bénévole ne peut pas se souhaiter lui-même.
- **`Position`** : `name`, `location`, `category` (`PositionCategory`),
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte).
`lead_account_id` (→ `Account`, `SET NULL` à la suppression du compte) —
doit désigner un compte `Role.LEAD` de la même édition (`POST`/`PATCH`
`/positions` le vérifient, 422 `position.lead_must_be_referent_account`
sinon). Se gère depuis la fiche du bénévole (`ConfigVolunteers.tsx` :
section « Référent des postes ») plutôt que depuis le poste : cocher un
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
retirer le poste, sans jamais rétrograder le compte automatiquement.
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
@@ -188,6 +196,13 @@ pour ne jamais révéler si un compte existe). Front : bouton « Donner un accè
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
`account.role_change_not_allowed` si `COORDINATOR` est impliqué dans un sens
ou l'autre (mot de passe en jeu, jamais touché par cette route). C'est ce qui
met à niveau le compte d'un bénévole en référent quand on le désigne
référent d'un poste (voir `Position.lead_account_id` plus haut).
### Emails (`app/email/`)
Envoi par **SMTP** (auto-hébergé). `EMAIL_ENABLED=false` (défaut dev/tests) ⇒
@@ -495,6 +510,7 @@ Aucune dépendance locale requise hors Docker.
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+25 -1
View File
@@ -9,7 +9,7 @@ from sqlmodel import Session, select
from app.api._common import owned_or_404
from app.api.deps import get_edition
from app.api.schemas import AccountCreate, AccountRead
from app.api.schemas import AccountCreate, AccountRead, AccountUpdate
from app.auth.deps import require_coordinator
from app.auth.passwords import MIN_LENGTH, hash_password
from app.db import get_session
@@ -85,6 +85,30 @@ def list_accounts(
)
@router.patch("/{account_id}", response_model=AccountRead)
def update_account(
account_id: uuid.UUID,
payload: AccountUpdate,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Seul le rôle change de main ici, et seulement entre LEAD et VOLUNTEER
(lien magique dans les deux cas) : devenir référent d'un poste (voir
`positions.py`) met à niveau le compte bénévole existant, sans toucher au
mot de passe d'un compte COORDINATION."""
account = owned_or_404(session.get(Account, account_id), edition.id)
if payload.role is not None and payload.role != account.role:
if Role.COORDINATOR in (account.role, payload.role):
raise HTTPException(
status_code=422, detail={"code": "account.role_change_not_allowed"}
)
account.role = payload.role
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account(
account_id: uuid.UUID,
+27 -4
View File
@@ -1,8 +1,13 @@
"""CRUD des postes."""
"""CRUD des postes.
`lead_account_id` (référent) ne peut désigner qu'un compte `Role.LEAD` de la
même édition — voir `_check_lead_account`. C'est le seul moyen de donner à un
bénévole une vue référent (`/mobile-referent`) à la connexion ; voir
`api/accounts.py:update_account` pour le faire passer de VOLUNTEER à LEAD."""
import uuid
from fastapi import APIRouter, Depends, Response, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select
from app.api._common import owned_or_404
@@ -21,13 +26,28 @@ from app.auth.deps import (
require_staff,
)
from app.db import get_session
from app.models import Account, Edition, Position
from app.models import Account, Edition, Position, Role
router = APIRouter(
prefix="/editions/{edition_id}/positions", tags=["positions"]
)
def _check_lead_account(
session: Session, edition: Edition, lead_account_id: uuid.UUID | None
) -> None:
"""`lead_account_id` doit désigner un compte RÉFÉRENT (`Role.LEAD`) de
cette même édition — sinon le poste se retrouve sans référent utilisable
(cf. `ensure_can_write_position`, `auth/deps.py`)."""
if lead_account_id is None:
return
lead = session.get(Account, lead_account_id)
if lead is None or lead.edition_id != edition.id or lead.role is not Role.LEAD:
raise HTTPException(
status_code=422, detail={"code": "position.lead_must_be_referent_account"}
)
@router.post("", response_model=PositionRead, status_code=status.HTTP_201_CREATED)
def create_position(
payload: PositionCreate,
@@ -35,6 +55,7 @@ def create_position(
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Position:
_check_lead_account(session, edition, payload.lead_account_id)
position = Position(edition_id=edition.id, **payload.model_dump())
session.add(position)
session.flush()
@@ -80,7 +101,9 @@ def update_position(
) -> Position:
position = owned_or_404(session.get(Position, position_id), edition.id)
ensure_can_write_position(account, position)
for field, value in payload.model_dump(exclude_unset=True).items():
changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id"))
for field, value in changes.items():
setattr(position, field, value)
session.commit()
session.refresh(position)
+6
View File
@@ -363,6 +363,12 @@ class AccountCreate(ApiModel):
password: str | None = None
class AccountUpdate(ApiModel):
# Seul le rôle est modifiable, et seulement entre LEAD et VOLUNTEER (pas
# de mot de passe en jeu) — voir `api/accounts.py:update_account`.
role: Role | None = None
# --------------------------------------------------------------------------- #
# Notifications par email #
# --------------------------------------------------------------------------- #
+2
View File
@@ -10,6 +10,7 @@
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent (rôle LEAD) de cette édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
@@ -25,6 +26,7 @@
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition.",
"account.password_required": "Un compte coordination requiert un mot de passe d'au moins {min} caractères.",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe.",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi.",
"import.error.empty_file": "Le fichier est vide.",
"import.error.encoding": "L'encodage du fichier n'est pas reconnu.",
"import.error.unreadable_file": "Fichier illisible ou format non pris en charge.",
+51
View File
@@ -0,0 +1,51 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/accounts{suffix}"
def test_promotes_a_volunteer_account_to_lead(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 200
assert resp.json()["role"] == "LEAD"
assert resp.json()["auth_method"] == "magic_link"
def test_demotes_a_lead_account_to_volunteer(client, edition, make_account):
account = make_account(Role.LEAD, email="l@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "VOLUNTEER"})
assert resp.status_code == 200
assert resp.json()["role"] == "VOLUNTEER"
def test_cannot_change_role_of_a_coordinator_account(client, edition, make_account):
account = make_account(Role.COORDINATOR, email="c@ex.fr", password="password123")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "LEAD"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_cannot_promote_a_volunteer_to_coordinator(client, edition, make_account):
account = make_account(Role.VOLUNTEER, email="v2@ex.fr")
resp = client.patch(_url(edition, f"/{account.id}"), json={"role": "COORDINATOR"})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "account.role_change_not_allowed"
def test_unknown_account_is_404(client, edition):
assert (
client.patch(_url(edition, f"/{uuid.uuid4()}"), json={"role": "LEAD"}).status_code
== 404
)
def test_volunteer_role_forbidden(edition, make_account, as_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v3@ex.fr")
other = make_account(Role.VOLUNTEER, email="v4@ex.fr")
api = as_account(volunteer_account)
resp = api.patch(_url(edition, f"/{other.id}"), json={"role": "LEAD"})
assert resp.status_code == 403
+74
View File
@@ -1,5 +1,7 @@
import uuid
from app.models import Role
def _url(edition, suffix=""):
return f"/editions/{edition.id}/positions{suffix}"
@@ -35,3 +37,75 @@ def test_unknown_edition_is_404(client):
assert (
client.get(f"/editions/{uuid.uuid4()}/positions").status_code == 404
)
def test_sets_a_referent(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent@ex.fr")
created = client.post(
_url(edition),
json={"name": "Bar", "category": "BAR"},
)
pid = created.json()["id"]
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] == str(lead.id)
def test_referent_must_have_the_lead_role(client, edition, make_account):
volunteer_account = make_account(Role.VOLUNTEER, email="v@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(volunteer_account.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_referent_must_belong_to_the_same_edition(client, edition, session, make_account):
import datetime as dt
from app.models import Edition
other_edition = Edition(year=2027, start_date=dt.date(2027, 7, 1), end_date=dt.date(2027, 7, 4))
session.add(other_edition)
session.commit()
session.refresh(other_edition)
other_lead = make_account(Role.LEAD, email="stranger@ex.fr")
other_lead.edition_id = other_edition.id
session.add(other_lead)
session.commit()
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(other_lead.id)}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_unknown_referent_is_422(client, edition):
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
resp = client.patch(
_url(edition, f"/{pid}"), json={"lead_account_id": str(uuid.uuid4())}
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position.lead_must_be_referent_account"
def test_clearing_the_referent_is_allowed(client, edition, make_account):
lead = make_account(Role.LEAD, email="referent2@ex.fr")
created = client.post(_url(edition), json={"name": "Bar", "category": "BAR"})
pid = created.json()["id"]
client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": str(lead.id)})
resp = client.patch(_url(edition, f"/{pid}"), json={"lead_account_id": None})
assert resp.status_code == 200
assert resp.json()["lead_account_id"] is None
+10
View File
@@ -8,6 +8,7 @@ import {
import { EDITION_ID, api } from './index';
import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
@@ -253,6 +254,15 @@ export function useCreateAccount() {
});
}
export function useUpdateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
api.accounts.update(edition, id, body),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() {
const invalidate = useInvalidate();
return useMutation({
+3
View File
@@ -1,5 +1,6 @@
import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
@@ -114,6 +115,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
list: (editionId) => request(`${base(editionId)}/accounts`),
create: (editionId, body: AccountCreate) =>
request(`${base(editionId)}/accounts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AccountUpdate) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
},
+24
View File
@@ -129,6 +129,16 @@ function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
return resolved;
}
/** `lead_account_id` d'un poste : doit désigner un compte référent (LEAD)
* existant — sinon le poste se retrouve sans référent utilisable. */
function checkLeadAccount(leadAccountId: string | null | undefined): void {
if (leadAccountId == null) return;
const lead = state.accounts.find((a) => a.id === leadAccountId);
if (!lead || lead.role !== 'LEAD') {
throw new ApiError(422, 'position.lead_must_be_referent_account');
}
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -859,6 +869,18 @@ export function createMockApi(): PlanningApi {
state.accounts.push(account);
return delay(account);
},
update: (_edition, id, body) => {
const account = state.accounts.find((a) => a.id === id) ?? notFound();
if (body.role != null && body.role !== account.role) {
if (account.role === 'COORDINATOR' || body.role === 'COORDINATOR') {
throw new ApiError(422, 'account.role_change_not_allowed');
}
account.role = body.role;
account.auth_method = 'magic_link';
}
account.updated_at = touch();
return delay(account);
},
remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined);
@@ -1090,6 +1112,7 @@ export function createMockApi(): PlanningApi {
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => {
checkLeadAccount(body.lead_account_id);
const now = touch();
const position = {
id: `p${state.seq++}`,
@@ -1107,6 +1130,7 @@ export function createMockApi(): PlanningApi {
},
update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
Object.assign(p, {
...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}),
+5
View File
@@ -421,6 +421,11 @@ export interface AccountCreate {
password?: string | null;
}
export interface AccountUpdate {
/** Seulement entre LEAD et VOLUNTEER — jamais vers/depuis COORDINATOR. */
role?: Role;
}
export interface TokenResponse {
access_token: string;
token_type: string;
+3
View File
@@ -1,6 +1,7 @@
import type {
AccountCreate,
AccountRead,
AccountUpdate,
ArtistCreate,
ArtistRead,
ArtistUpdate,
@@ -73,6 +74,8 @@ export interface PlanningApi {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
list(editionId: string): Promise<AccountRead[]>;
create(editionId: string, body: AccountCreate): Promise<AccountRead>;
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>;
};
availability: {
+6
View File
@@ -15,10 +15,12 @@
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
"shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"position.lead_must_be_referent_account": "Le référent d'un poste doit être un compte référent de cette édition",
"account.duplicate": "Un compte existe déjà pour cet email ou ce bénévole",
"account.volunteer_wrong_edition": "Le bénévole n'appartient pas à cette édition",
"account.password_required": "Un compte coordination requiert un mot de passe",
"account.password_not_allowed": "Un compte référent ou bénévole se connecte par lien magique, sans mot de passe",
"account.role_change_not_allowed": "Un compte coordination ne peut pas changer de rôle ainsi",
"auth.invalid_credentials": "Email ou mot de passe incorrect",
"auth.invalid_token": "Ce lien de connexion est invalide ou expiré",
"role.coordinator": "Coordination",
@@ -234,6 +236,10 @@
"config.vol.access.revoke": "Révoquer",
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes",
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
"config.vol.referentIsCoordinator": "Ce bénévole a un compte coordination : il a déjà accès à tous les postes, inutile de le désigner référent.",
"config.vol.referentNeedsEmail": "Renseignez un email sur la fiche pour pouvoir désigner ce bénévole référent.",
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
"config.vol.create": "CRÉER LE BÉNÉVOLE",
+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')}