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:
co-authored by
Claude Sonnet 5
parent
efa652fffb
commit
9871785d89
@@ -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.",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user