diff --git a/CLAUDE.md b/CLAUDE.md index be6de5e..860f7a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 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 -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 colonne de son propre créneau, jamais sur les autres colonnes qu'elle 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 | | 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 | +| 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 de formulaire, IA. Frontend : squelette + 6 écrans + diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 9e1202e..f7cf5b3 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx index 747163d..fc2fe2a 100644 --- a/frontend/src/pages/VolunteerViewPage.tsx +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -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('all'); const [sort, setSort] = useState('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 (
+
+ 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]" + /> +
{t('volunteers.filter.show')} setFilter('all')}> @@ -198,7 +220,7 @@ export function VolunteerViewPage() {
{rows.length === 0 ? ( - {t('volunteers.empty')} + {query ? t('volunteers.search.empty', { query: search.trim() }) : t('volunteers.empty')} ) : (
{/* 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,