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:
co-authored by
Claude Sonnet 5
parent
6a4bcf8782
commit
166e2c3366
@@ -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éé.",
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user