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:
co-authored by
Claude Sonnet 5
parent
db11d9a3ba
commit
8fcc22115f
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user