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:
co-authored by
Claude Sonnet 5
parent
803db5e9a9
commit
e47c29d35b
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user