From 9871785d89f8fe46740f4f95f109010bb4b71881 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 15 Sep 2026 22:54:31 +0200 Subject: [PATCH] =?UTF-8?q?Grille=20:=20aper=C3=A7u=20des=20b=C3=A9n=C3=A9?= =?UTF-8?q?voles=20affect=C3=A9s=20au=20survol=20d'une=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque case ne montrait qu'un nombre (affecté/idéal) ; il fallait cliquer et ouvrir le panneau pour savoir qui exactement. Une bulle au survol affiche maintenant les noms des bénévoles déjà affectés à ce créneau, sans clic. - Construit une table (créneau → noms) à partir des affectations et des bénévoles de l'édition (mêmes requêtes déjà utilisées par le panneau d'affectation, donc pas de coût réseau supplémentaire une fois en cache). - Remplace l'ancien `title` natif (lent, sans style) par la même bulle que `HelpTip` (bordures franches, apparaît sous la case) ; garde le rappel affecté/idéal/minimum en tête de bulle, puis la liste triée des noms, ou le message « personne d'affecté » sinon. Vérifié : typecheck, lint, build, smoke frontend OK. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/src/i18n/locales/fr.json | 1 + frontend/src/pages/GrillePage.tsx | 43 ++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 98827bb..ae295e6 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -81,6 +81,7 @@ "grille.positionsCount": "{count} POSTES", "grille.assignedRequired": "AFFECTÉS / IDÉAL", "grille.footnote": "Chaque cellule est un créneau. Nombre = bénévoles affectés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.", + "grille.cellPreview.staffing": "{assigned}/{ideal} affecté(s) · min {min}", "grille.empty": "Aucun créneau pour le moment. Créez des postes dans Configuration → « Postes & créneaux » — un créneau est généré par jour et par créneau type.", "grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.", "grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.", diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 1ccd63b..57a23ff 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; -import { useMatrix, useRunSolver } from '@/api/hooks'; +import { useAssignments, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks'; import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; @@ -13,6 +13,8 @@ import { cx } from '@/lib/cx'; export function GrillePage() { const matrix = useMatrix(); const runSolver = useRunSolver(); + const assignments = useAssignments(); + const volunteers = useVolunteers(); const [dayFilter, setDayFilter] = useState<'all' | string>('all'); const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all'); const [selectedShiftId, setSelectedShiftId] = useState(null); @@ -22,6 +24,24 @@ export function GrillePage() { [matrix.data], ); + // Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une + // case (voir `PositionRow`). Pas bloquant : peut rester vide un instant le + // temps que ces requêtes (déjà utilisées par le panneau d'affectation) + // reviennent du cache. + const volunteerNamesByShift = useMemo(() => { + const volunteersById = new Map((volunteers.data ?? []).map((v) => [v.id, v])); + const map = new Map(); + for (const a of assignments.data ?? []) { + const v = volunteersById.get(a.volunteer_id); + if (!v) continue; + const list = map.get(a.shift_id) ?? []; + list.push(`${v.first_name} ${v.last_name}`); + map.set(a.shift_id, list); + } + for (const list of map.values()) list.sort((a, b) => a.localeCompare(b, 'fr')); + return map; + }, [assignments.data, volunteers.data]); + if (matrix.isLoading) return ; if (matrix.error) return ; if (!matrix.data) return null; @@ -167,6 +187,7 @@ export function GrillePage() { columns={shownColumns} selectedShiftId={selectedShiftId} onSelect={setSelectedShiftId} + volunteerNamesByShift={volunteerNamesByShift} /> ))} @@ -229,11 +250,13 @@ function PositionRow({ columns, selectedShiftId, onSelect, + volunteerNamesByShift, }: { position: MatrixPosition; columns: LatticeColumn[]; selectedShiftId: string | null; onSelect: (id: string) => void; + volunteerNamesByShift: Map; }) { const byColumn = new Map(); for (const shift of position.shifts) { @@ -272,25 +295,39 @@ function PositionRow({ ? '◧' : '✓'; const selected = selectedShiftId === shift.shift_id; + const names = volunteerNamesByShift.get(shift.shift_id) ?? []; return (
onSelect(shift.shift_id)} className={cx( - 'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', + 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', undefinedNeed && 'bg-sand-dark hover:bg-sand', isCrit && 'bg-hatch-orange', isOver && 'bg-hatch-mauve hover:bg-mauve-hover', isInc && 'bg-yellow-soft hover:bg-yellow', isOk && 'bg-paper hover:bg-green/10', )} - title={`${assigned} affecté(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`} > {assigned} /{shift.ideal_staff} {glyph} +
+
+ {t('grille.cellPreview.staffing', { + assigned, + ideal: shift.ideal_staff, + min: shift.min_staff, + })} +
+ {names.length > 0 ? ( + names.map((name, i) =>
{name}
) + ) : ( +
{t('grille.panel.noneAssigned')}
+ )} +
{selected ? ( ) : null}