Front : suggestions du solveur en tête de liste dans la grille

- Surface API `solver.run(editionId)` (HTTP + mock) et hooks
  `useRunSolver` (mutation, alimente le cache) / `useSolverSuggestions`
  (lecture seule du cache, ne déclenche jamais de calcul).
- Grille des besoins : bouton « Suggestions (solveur) », affiche le
  nombre de suggestions calculées.
- Panneau d'affectation d'un créneau : les bénévoles suggérés par le
  solveur passent en tête de la liste des candidats (avant le tri par
  disponibilité / charge), avec un badge « Suggéré ». Ne pré-affecte
  jamais personne — la coordination choisit et confirme comme avant.
- Mock : portage glouton (par créneau, du plus urgent au moins urgent)
  du scoring du moteur back-end, pour une démonstration cohérente sans
  CP-SAT côté navigateur.

typecheck / lint / build / smoke au vert.

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-11 08:16:29 +02:00
co-authored by Claude Sonnet 5
parent 803db5e9a9
commit e47c29d35b
8 changed files with 251 additions and 8 deletions
+33 -6
View File
@@ -8,6 +8,7 @@ import {
useEditionAvailability,
usePositions,
useShifts,
useSolverSuggestions,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
@@ -54,8 +55,16 @@ export function AssignmentPanel({
const shifts = useShifts();
const positions = usePositions();
const availability = useEditionAvailability();
const solverSuggestions = useSolverSuggestions();
const deleteAssignment = useDeleteAssignment();
const suggestedIds = useMemo(() => {
const entry = solverSuggestions.data?.suggestions.find(
(s) => s.shift_id === shift.shift_id,
);
return entry?.volunteer_ids ?? [];
}, [solverSuggestions.data, shift.shift_id]);
const coverageByVolunteer = useMemo(() => {
const byVol = new Map<string, AvailabilityRead[]>();
for (const w of availability.data ?? []) {
@@ -104,15 +113,23 @@ export function AssignmentPanel({
const candidates = useMemo(() => {
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
return (volunteers.data ?? [])
.filter((v) => !assignedIds.has(v.id))
// D'abord les bénévoles disponibles sur le créneau, puis les moins chargés.
.sort(
(a, b) => cov(a.id) - cov(b.id) || (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0),
)
// Le solveur d'abord (dans son ordre), puis les disponibles, puis les
// moins chargés.
.sort((a, b) => {
const ra = suggestedRank.get(a.id) ?? Infinity;
const rb = suggestedRank.get(b.id) ?? Infinity;
return (
ra - rb ||
cov(a.id) - cov(b.id) ||
(hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0)
);
})
.slice(0, MAX_CANDIDATES);
}, [volunteers.data, assigned, hours, coverageByVolunteer]);
}, [volunteers.data, assigned, hours, coverageByVolunteer, suggestedIds]);
if (volunteers.isLoading || shiftAssignments.isLoading) {
return <Loading />;
@@ -208,6 +225,7 @@ export function AssignmentPanel({
hours={round(hours.get(volunteer.id) ?? 0)}
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
suggested={suggestedIds.includes(volunteer.id)}
resolvePosition={resolvePosition}
/>
))}
@@ -228,6 +246,7 @@ function CandidateRow({
hours,
slots,
coverage,
suggested,
resolvePosition,
}: {
volunteer: VolunteerRead;
@@ -235,6 +254,7 @@ function CandidateRow({
hours: number;
slots: number;
coverage: Coverage;
suggested: boolean;
resolvePosition: (shiftId: string) => string | undefined;
}) {
const validation = useValidateAssignment(volunteer.id, shiftId);
@@ -248,13 +268,20 @@ function CandidateRow({
createAssignment.error instanceof ApiError ? createAssignment.error : null;
return (
<div className="mb-[10px] border-2 border-ink bg-cream shadow-brutal">
<div
className={`mb-[10px] border-2 border-ink bg-cream shadow-brutal ${suggested ? 'ring-2 ring-ink ring-offset-1' : ''}`}
>
<div className="flex items-center gap-2 p-[7px]">
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
{initials(volunteer)}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.first_name} {volunteer.last_name}
{suggested ? (
<span className="ml-[6px] inline-block border-[1.5px] border-ink bg-blue px-[5px] py-[1px] align-middle font-display text-[8px] font-extrabold uppercase tracking-wide">
{t('grille.panel.suggested')}
</span>
) : null}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursAssigned', { hours, slots })}
</span>