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.positionsCount": "{count} POSTES",
|
||||||
"grille.assignedRequired": "AFFECTÉS / IDÉAL",
|
"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.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.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.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.",
|
"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 { 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 type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
||||||
import { Chip, HelpTip } from '@/components/ui';
|
import { Chip, HelpTip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
@@ -13,6 +13,8 @@ import { cx } from '@/lib/cx';
|
|||||||
export function GrillePage() {
|
export function GrillePage() {
|
||||||
const matrix = useMatrix();
|
const matrix = useMatrix();
|
||||||
const runSolver = useRunSolver();
|
const runSolver = useRunSolver();
|
||||||
|
const assignments = useAssignments();
|
||||||
|
const volunteers = useVolunteers();
|
||||||
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
||||||
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
||||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||||
@@ -22,6 +24,24 @@ export function GrillePage() {
|
|||||||
[matrix.data],
|
[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.isLoading) return <Loading />;
|
||||||
if (matrix.error) return <ErrorNote error={matrix.error} />;
|
if (matrix.error) return <ErrorNote error={matrix.error} />;
|
||||||
if (!matrix.data) return null;
|
if (!matrix.data) return null;
|
||||||
@@ -167,6 +187,7 @@ export function GrillePage() {
|
|||||||
columns={shownColumns}
|
columns={shownColumns}
|
||||||
selectedShiftId={selectedShiftId}
|
selectedShiftId={selectedShiftId}
|
||||||
onSelect={setSelectedShiftId}
|
onSelect={setSelectedShiftId}
|
||||||
|
volunteerNamesByShift={volunteerNamesByShift}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -229,11 +250,13 @@ function PositionRow({
|
|||||||
columns,
|
columns,
|
||||||
selectedShiftId,
|
selectedShiftId,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
volunteerNamesByShift,
|
||||||
}: {
|
}: {
|
||||||
position: MatrixPosition;
|
position: MatrixPosition;
|
||||||
columns: LatticeColumn[];
|
columns: LatticeColumn[];
|
||||||
selectedShiftId: string | null;
|
selectedShiftId: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
|
volunteerNamesByShift: Map<string, string[]>;
|
||||||
}) {
|
}) {
|
||||||
const byColumn = new Map<string, MatrixShift>();
|
const byColumn = new Map<string, MatrixShift>();
|
||||||
for (const shift of position.shifts) {
|
for (const shift of position.shifts) {
|
||||||
@@ -272,25 +295,39 @@ function PositionRow({
|
|||||||
? '◧'
|
? '◧'
|
||||||
: '✓';
|
: '✓';
|
||||||
const selected = selectedShiftId === shift.shift_id;
|
const selected = selectedShiftId === shift.shift_id;
|
||||||
|
const names = volunteerNamesByShift.get(shift.shift_id) ?? [];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.key}
|
key={column.key}
|
||||||
onClick={() => onSelect(shift.shift_id)}
|
onClick={() => onSelect(shift.shift_id)}
|
||||||
className={cx(
|
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',
|
undefinedNeed && 'bg-sand-dark hover:bg-sand',
|
||||||
isCrit && 'bg-hatch-orange',
|
isCrit && 'bg-hatch-orange',
|
||||||
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
|
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
|
||||||
isInc && 'bg-yellow-soft hover:bg-yellow',
|
isInc && 'bg-yellow-soft hover:bg-yellow',
|
||||||
isOk && 'bg-paper hover:bg-green/10',
|
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-[13px] font-bold">{assigned}</span>
|
||||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</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')}>
|
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>
|
||||||
{glyph}
|
{glyph}
|
||||||
</span>
|
</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 ? (
|
{selected ? (
|
||||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user