Vue par bénévole : recherche sur le bénévole ou le poste
ci-cd / build-backend (push) Successful in 6s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s

Barre de recherche (sans accents ni casse) sur le nom du bénévole ou d'un
de ses postes, postes trouvés cernés d'orange, cumulable avec les filtres
et le tri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:15:14 +01:00
co-authored by Claude Opus 5.5
parent cc8c2e61ab
commit 749c7e0917
3 changed files with 35 additions and 5 deletions
+3
View File
@@ -110,6 +110,9 @@
"volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
"volunteers.search.placeholder": "Rechercher un bénévole ou un poste…",
"volunteers.search.label": "Rechercher un bénévole ou un poste",
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR",
+28 -4
View File
@@ -9,6 +9,7 @@ import {
columnWidthPx,
dayBoundaryFromDayParts,
festivalDay,
foldAccents,
hoursByVolunteer,
placeInTimeGrid,
type TimeGridDay,
@@ -43,6 +44,8 @@ interface Bar {
lane: number;
/** Chevauche un autre créneau du même bénévole. */
overlap: boolean;
/** Le poste correspond à la recherche (barre mise en avant). */
match: boolean;
label: string;
}
@@ -63,6 +66,10 @@ export function VolunteerViewPage() {
const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name');
// Recherche sur le nom du bénévole ou le nom d'un de ses postes, sans
// tenir compte des accents ni de la casse.
const [search, setSearch] = useState('');
const query = foldAccents(search.trim());
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo(
@@ -120,6 +127,7 @@ export function VolunteerViewPage() {
width: p.columnSpan * l.slotW,
lane: 0,
overlap: false,
match: false,
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
});
}
@@ -134,19 +142,23 @@ export function VolunteerViewPage() {
bar.lane = lane;
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end);
}
const nameMatch = !query || foldAccents(`${v.first_name} ${v.last_name}`).includes(query);
for (const bar of bars) bar.match = Boolean(query) && foldAccents(bar.positionName).includes(query);
return {
volunteer: v,
visible: nameMatch || bars.some((b) => b.match),
bars,
lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: items.length === 0,
};
});
if (sort === 'name') return built;
const shown = built.filter((r) => r.visible);
if (sort === 'name') return shown;
const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé.
return built.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort]);
return shown.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort, query]);
// Position (px) de l'heure actuelle dans la piste, `null` hors grille.
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
@@ -167,6 +179,16 @@ export function VolunteerViewPage() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="relative w-full sm:w-[260px]">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('volunteers.search.placeholder')}
aria-label={t('volunteers.search.label')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[6px] font-mono text-[12px]"
/>
</div>
<div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
@@ -198,7 +220,7 @@ export function VolunteerViewPage() {
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
{rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote>
<EmptyNote>{query ? t('volunteers.search.empty', { query: search.trim() }) : t('volunteers.empty')}</EmptyNote>
) : (
<div className="w-max border-b-[3px] border-ink">
{/* En-tête : jours, puis tranches de 30 min. */}
@@ -355,6 +377,8 @@ function VolunteerRow({
className={cx(
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none',
bar.overlap ? 'bg-yellow' : 'bg-blue',
// Poste trouvé par la recherche : cerné d'orange.
bar.match && 'outline outline-[3px] outline-offset-0 outline-orange',
)}
style={{
left: bar.left + 1,