Fiche bénévole : recherche pour les compagnons, badges pour artistes/postes

- Vœu de compagnon : le sélecteur (déjà trié par ordre alphabétique) gagne un
  champ de recherche (insensible aux accents/casse) qui filtre la liste
  déroulante — plus pratique avec 100+ bénévoles.
- Vœux d'artiste et de poste : remplacé le menu déroulant par des badges à
  cocher (`BadgeWishPicker`, réutilise le composant `Chip`) — toutes les
  options sont visibles d'un coup, un clic les bascule, plus besoin d'ouvrir
  une liste pour un petit nombre d'options.

Vérifié : typecheck, lint, build, smoke frontend OK.

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 22:05:52 +02:00
co-authored by Claude Sonnet 5
parent 6a4bcf8782
commit 166e2c3366
2 changed files with 73 additions and 13 deletions
+1 -2
View File
@@ -293,9 +293,8 @@
"config.vol.noArtists": "Aucun artiste choisi",
"config.vol.noPositions": "Aucun poste choisi",
"config.vol.noCompanions": "Aucun bénévole choisi",
"config.vol.pickArtist": "Ajouter un artiste…",
"config.vol.pickPosition": "Ajouter un poste…",
"config.vol.pickCompanion": "Ajouter un bénévole…",
"config.vol.searchCompanion": "Rechercher un bénévole…",
"config.vol.colWishes": "VŒUX",
"config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
+72 -11
View File
@@ -28,7 +28,7 @@ import type {
VolunteerCreate,
VolunteerRead,
} from '@/api/schema';
import { FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time';
@@ -151,6 +151,15 @@ function VolunteerAccessSection({
);
}
function foldAccents(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/** 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({
label,
options,
@@ -158,6 +167,7 @@ function WishPicker({
onChange,
emptyLabel,
addLabel,
searchPlaceholder,
}: {
label: ReactNode;
options: WishOption[];
@@ -165,9 +175,15 @@ function WishPicker({
onChange: (ids: string[]) => void;
emptyLabel: string;
addLabel: string;
searchPlaceholder: string;
}) {
const [search, setSearch] = useState('');
const byId = new Map(options.map((o) => [o.id, o]));
const available = options.filter((o) => !selected.includes(o.id));
const needle = foldAccents(search.trim());
const filtered = needle
? available.filter((o) => foldAccents(`${o.hint ?? ''} ${o.label}`).includes(needle))
: available;
return (
<div>
<FieldLabel>{label}</FieldLabel>
@@ -186,16 +202,28 @@ function WishPicker({
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : null}
</div>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={searchPlaceholder}
disabled={available.length === 0}
className="mb-[6px] w-full border-2 border-ink bg-paper p-[7px] font-mono text-[11px] disabled:opacity-50"
/>
<select
value=""
onChange={(e) => {
if (e.target.value) onChange([...selected, e.target.value]);
if (e.target.value) {
onChange([...selected, e.target.value]);
setSearch('');
}
}}
disabled={available.length === 0}
size={search ? Math.min(6, Math.max(2, filtered.length + 1)) : undefined}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px] disabled:opacity-50"
>
<option value="">{addLabel}</option>
{available.map((o) => (
{filtered.map((o) => (
<option key={o.id} value={o.id}>
{o.hint ? `${o.hint} · ${o.label}` : o.label}
</option>
@@ -205,6 +233,41 @@ function WishPicker({
);
}
/** Sélecteur de vœux en badges à cocher (artistes, postes : peu d'options,
* toutes visibles d'un coup, pas de liste déroulante à ouvrir). */
function BadgeWishPicker({
label,
options,
selected,
onChange,
emptyLabel,
}: {
label: ReactNode;
options: WishOption[];
selected: string[];
onChange: (ids: string[]) => void;
emptyLabel: string;
}) {
const toggle = (id: string) =>
onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id]);
return (
<div>
<FieldLabel>{label}</FieldLabel>
{options.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
) : (
<div className="flex flex-wrap gap-[6px]">
{options.map((o) => (
<Chip key={o.id} active={selected.includes(o.id)} onClick={() => toggle(o.id)}>
{o.hint ? `${o.hint} · ${o.label}` : o.label}
</Chip>
))}
</div>
)}
</div>
);
}
type Mode = 'list' | 'add' | 'import';
/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */
@@ -433,21 +496,19 @@ export function ConfigVolunteers() {
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<WishPicker
<BadgeWishPicker
label={t('config.vol.field.artists')}
options={artistOpts}
selected={selected.artist_ids}
onChange={(ids) => patch({ artist_ids: ids })}
emptyLabel={t('config.vol.noArtists')}
addLabel={t('config.vol.pickArtist')}
/>
<WishPicker
<BadgeWishPicker
label={t('config.vol.field.positions')}
options={positionOpts}
selected={selected.wished_position_ids}
onChange={(ids) => patch({ wished_position_ids: ids })}
emptyLabel={t('config.vol.noPositions')}
addLabel={t('config.vol.pickPosition')}
/>
<WishPicker
label={
@@ -461,6 +522,7 @@ export function ConfigVolunteers() {
onChange={(ids) => patch({ wished_companion_ids: ids })}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<AvailabilityEditor volunteerId={selected.id} />
@@ -581,21 +643,19 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
/>
</label>
<WishPicker
<BadgeWishPicker
label={t('config.vol.field.artists')}
options={artistOpts}
selected={artistIds}
onChange={setArtistIds}
emptyLabel={t('config.vol.noArtists')}
addLabel={t('config.vol.pickArtist')}
/>
<WishPicker
<BadgeWishPicker
label={t('config.vol.field.positions')}
options={positionOpts}
selected={wishedPositionIds}
onChange={setWishedPositionIds}
emptyLabel={t('config.vol.noPositions')}
addLabel={t('config.vol.pickPosition')}
/>
<WishPicker
label={
@@ -609,6 +669,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
onChange={setWishedCompanionIds}
emptyLabel={t('config.vol.noCompanions')}
addLabel={t('config.vol.pickCompanion')}
searchPlaceholder={t('config.vol.searchCompanion')}
/>
{localError ? (