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
+4 -1
View File
@@ -806,7 +806,9 @@ jour) : chaque affectation est une barre étirée sur toute la durée de son
créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
l'en-tête des tranches. Avant l'étape 60, une colonne par créneau distinct l'en-tête des tranches. Barre de recherche (nom du bénévole ou d'un de
ses postes, sans accents ni casse) : ne garde que les bénévoles qui
correspondent, et cerne d'orange les postes trouvés. Avant l'étape 60, une colonne par créneau distinct
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la (`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
colonne de son propre créneau, jamais sur les autres colonnes qu'elle colonne de son propre créneau, jamais sur les autres colonnes qu'elle
couvrait, et deux postes simultanés se masquaient. couvrait, et deux postes simultanés se masquaient.
@@ -1059,6 +1061,7 @@ Aucune dépendance locale requise hors Docker.
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider | | 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider | | 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider | | 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+3
View File
@@ -110,6 +110,9 @@
"volunteers.legend.now": "MAINTENANT", "volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}", "volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste", "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.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H", "volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR", "volunteers.sort.label": "TRIER PAR",
+28 -4
View File
@@ -9,6 +9,7 @@ import {
columnWidthPx, columnWidthPx,
dayBoundaryFromDayParts, dayBoundaryFromDayParts,
festivalDay, festivalDay,
foldAccents,
hoursByVolunteer, hoursByVolunteer,
placeInTimeGrid, placeInTimeGrid,
type TimeGridDay, type TimeGridDay,
@@ -43,6 +44,8 @@ interface Bar {
lane: number; lane: number;
/** Chevauche un autre créneau du même bénévole. */ /** Chevauche un autre créneau du même bénévole. */
overlap: boolean; overlap: boolean;
/** Le poste correspond à la recherche (barre mise en avant). */
match: boolean;
label: string; label: string;
} }
@@ -63,6 +66,10 @@ export function VolunteerViewPage() {
const now = useNowMinutes(); const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name'); 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 dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo( const shiftsById = useMemo(
@@ -120,6 +127,7 @@ export function VolunteerViewPage() {
width: p.columnSpan * l.slotW, width: p.columnSpan * l.slotW,
lane: 0, lane: 0,
overlap: false, overlap: false,
match: false,
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
}); });
} }
@@ -134,19 +142,23 @@ export function VolunteerViewPage() {
bar.lane = lane; bar.lane = lane;
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end); 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 { return {
volunteer: v, volunteer: v,
visible: nameMatch || bars.some((b) => b.match),
bars, bars,
lanes: Math.max(1, laneEnds.length), lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10, total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: items.length === 0, 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; const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé. // `sort` est stable : à total égal, l'ordre par nom est conservé.
return built.sort((a, b) => direction * (a.total - b.total)); return shown.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort]); }, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort, query]);
// Position (px) de l'heure actuelle dans la piste, `null` hors grille. // Position (px) de l'heure actuelle dans la piste, `null` hors grille.
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]); const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
@@ -167,6 +179,16 @@ export function VolunteerViewPage() {
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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="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]"> <div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span> <span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
<Chip active={filter === 'all'} onClick={() => setFilter('all')}> <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"> <div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
{rows.length === 0 ? ( {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"> <div className="w-max border-b-[3px] border-ink">
{/* En-tête : jours, puis tranches de 30 min. */} {/* En-tête : jours, puis tranches de 30 min. */}
@@ -355,6 +377,8 @@ function VolunteerRow({
className={cx( className={cx(
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none', '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', 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={{ style={{
left: bar.left + 1, left: bar.left + 1,