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.noArtists": "Aucun artiste choisi",
|
||||||
"config.vol.noPositions": "Aucun poste choisi",
|
"config.vol.noPositions": "Aucun poste choisi",
|
||||||
"config.vol.noCompanions": "Aucun bénévole 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.pickCompanion": "Ajouter un bénévole…",
|
||||||
|
"config.vol.searchCompanion": "Rechercher un bénévole…",
|
||||||
"config.vol.colWishes": "VŒUX",
|
"config.vol.colWishes": "VŒUX",
|
||||||
"config.vol.wishesCount": "{count} vœu(x)",
|
"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éé.",
|
"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,
|
VolunteerCreate,
|
||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
} from '@/api/schema';
|
} 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 { ErrorNote, Loading } from '@/components/state';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { shortDayLabel } from '@/lib/time';
|
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({
|
function WishPicker({
|
||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
@@ -158,6 +167,7 @@ function WishPicker({
|
|||||||
onChange,
|
onChange,
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
addLabel,
|
addLabel,
|
||||||
|
searchPlaceholder,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
options: WishOption[];
|
options: WishOption[];
|
||||||
@@ -165,9 +175,15 @@ function WishPicker({
|
|||||||
onChange: (ids: string[]) => void;
|
onChange: (ids: string[]) => void;
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
addLabel: string;
|
addLabel: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const byId = new Map(options.map((o) => [o.id, o]));
|
const byId = new Map(options.map((o) => [o.id, o]));
|
||||||
const available = options.filter((o) => !selected.includes(o.id));
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>{label}</FieldLabel>
|
<FieldLabel>{label}</FieldLabel>
|
||||||
@@ -186,16 +202,28 @@ function WishPicker({
|
|||||||
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
|
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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
|
<select
|
||||||
value=""
|
value=""
|
||||||
onChange={(e) => {
|
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}
|
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"
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px] disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">{addLabel}</option>
|
<option value="">{addLabel}</option>
|
||||||
{available.map((o) => (
|
{filtered.map((o) => (
|
||||||
<option key={o.id} value={o.id}>
|
<option key={o.id} value={o.id}>
|
||||||
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
||||||
</option>
|
</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';
|
type Mode = 'list' | 'add' | 'import';
|
||||||
|
|
||||||
/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */
|
/** 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} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|
||||||
<WishPicker
|
<BadgeWishPicker
|
||||||
label={t('config.vol.field.artists')}
|
label={t('config.vol.field.artists')}
|
||||||
options={artistOpts}
|
options={artistOpts}
|
||||||
selected={selected.artist_ids}
|
selected={selected.artist_ids}
|
||||||
onChange={(ids) => patch({ artist_ids: ids })}
|
onChange={(ids) => patch({ artist_ids: ids })}
|
||||||
emptyLabel={t('config.vol.noArtists')}
|
emptyLabel={t('config.vol.noArtists')}
|
||||||
addLabel={t('config.vol.pickArtist')}
|
|
||||||
/>
|
/>
|
||||||
<WishPicker
|
<BadgeWishPicker
|
||||||
label={t('config.vol.field.positions')}
|
label={t('config.vol.field.positions')}
|
||||||
options={positionOpts}
|
options={positionOpts}
|
||||||
selected={selected.wished_position_ids}
|
selected={selected.wished_position_ids}
|
||||||
onChange={(ids) => patch({ wished_position_ids: ids })}
|
onChange={(ids) => patch({ wished_position_ids: ids })}
|
||||||
emptyLabel={t('config.vol.noPositions')}
|
emptyLabel={t('config.vol.noPositions')}
|
||||||
addLabel={t('config.vol.pickPosition')}
|
|
||||||
/>
|
/>
|
||||||
<WishPicker
|
<WishPicker
|
||||||
label={
|
label={
|
||||||
@@ -461,6 +522,7 @@ export function ConfigVolunteers() {
|
|||||||
onChange={(ids) => patch({ wished_companion_ids: ids })}
|
onChange={(ids) => patch({ wished_companion_ids: ids })}
|
||||||
emptyLabel={t('config.vol.noCompanions')}
|
emptyLabel={t('config.vol.noCompanions')}
|
||||||
addLabel={t('config.vol.pickCompanion')}
|
addLabel={t('config.vol.pickCompanion')}
|
||||||
|
searchPlaceholder={t('config.vol.searchCompanion')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AvailabilityEditor volunteerId={selected.id} />
|
<AvailabilityEditor volunteerId={selected.id} />
|
||||||
@@ -581,21 +643,19 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<WishPicker
|
<BadgeWishPicker
|
||||||
label={t('config.vol.field.artists')}
|
label={t('config.vol.field.artists')}
|
||||||
options={artistOpts}
|
options={artistOpts}
|
||||||
selected={artistIds}
|
selected={artistIds}
|
||||||
onChange={setArtistIds}
|
onChange={setArtistIds}
|
||||||
emptyLabel={t('config.vol.noArtists')}
|
emptyLabel={t('config.vol.noArtists')}
|
||||||
addLabel={t('config.vol.pickArtist')}
|
|
||||||
/>
|
/>
|
||||||
<WishPicker
|
<BadgeWishPicker
|
||||||
label={t('config.vol.field.positions')}
|
label={t('config.vol.field.positions')}
|
||||||
options={positionOpts}
|
options={positionOpts}
|
||||||
selected={wishedPositionIds}
|
selected={wishedPositionIds}
|
||||||
onChange={setWishedPositionIds}
|
onChange={setWishedPositionIds}
|
||||||
emptyLabel={t('config.vol.noPositions')}
|
emptyLabel={t('config.vol.noPositions')}
|
||||||
addLabel={t('config.vol.pickPosition')}
|
|
||||||
/>
|
/>
|
||||||
<WishPicker
|
<WishPicker
|
||||||
label={
|
label={
|
||||||
@@ -609,6 +669,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
onChange={setWishedCompanionIds}
|
onChange={setWishedCompanionIds}
|
||||||
emptyLabel={t('config.vol.noCompanions')}
|
emptyLabel={t('config.vol.noCompanions')}
|
||||||
addLabel={t('config.vol.pickCompanion')}
|
addLabel={t('config.vol.pickCompanion')}
|
||||||
|
searchPlaceholder={t('config.vol.searchCompanion')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{localError ? (
|
{localError ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user