Grille : aperçu des bénévoles affectés au survol d'une case

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-15 22:54:31 +02:00
co-authored by Claude Sonnet 5
parent efa652fffb
commit 9871785d89
2 changed files with 41 additions and 3 deletions
+1
View File
@@ -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.",
+40 -3
View File
@@ -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<string | null>(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<string, string[]>();
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 <Loading />;
if (matrix.error) return <ErrorNote error={matrix.error} />;
if (!matrix.data) return null;
@@ -167,6 +187,7 @@ export function GrillePage() {
columns={shownColumns}
selectedShiftId={selectedShiftId}
onSelect={setSelectedShiftId}
volunteerNamesByShift={volunteerNamesByShift}
/>
))}
</div>
@@ -229,11 +250,13 @@ function PositionRow({
columns,
selectedShiftId,
onSelect,
volunteerNamesByShift,
}: {
position: MatrixPosition;
columns: LatticeColumn[];
selectedShiftId: string | null;
onSelect: (id: string) => void;
volunteerNamesByShift: Map<string, string[]>;
}) {
const byColumn = new Map<string, MatrixShift>();
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 (
<div
key={column.key}
onClick={() => 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}`}
>
<span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>
{glyph}
</span>
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
{t('grille.cellPreview.staffing', {
assigned,
ideal: shift.ideal_staff,
min: shift.min_staff,
})}
</div>
{names.length > 0 ? (
names.map((name, i) => <div key={i}>{name}</div>)
) : (
<div className="opacity-70">{t('grille.panel.noneAssigned')}</div>
)}
</div>
{selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null}