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
@@ -36,6 +36,7 @@ export const queryKeys = {
|
|||||||
assignments: (params?: { volunteerId?: string; shiftId?: string }) =>
|
assignments: (params?: { volunteerId?: string; shiftId?: string }) =>
|
||||||
['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const,
|
['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const,
|
||||||
matrix: ['matrix', edition] as const,
|
matrix: ['matrix', edition] as const,
|
||||||
|
solver: ['solver', edition] as const,
|
||||||
validate: (volunteerId: string, shiftId: string) =>
|
validate: (volunteerId: string, shiftId: string) =>
|
||||||
['validate', edition, volunteerId, shiftId] as const,
|
['validate', edition, volunteerId, shiftId] as const,
|
||||||
};
|
};
|
||||||
@@ -115,6 +116,19 @@ export function useMatrix() {
|
|||||||
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dernières suggestions du solveur (lecture seule du cache posé par
|
||||||
|
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
|
||||||
|
*/
|
||||||
|
export function useSolverSuggestions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.solver,
|
||||||
|
queryFn: () => api.solver.run(edition),
|
||||||
|
enabled: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useValidateAssignment(
|
export function useValidateAssignment(
|
||||||
volunteerId: string,
|
volunteerId: string,
|
||||||
shiftId: string,
|
shiftId: string,
|
||||||
@@ -386,6 +400,18 @@ export function useDeleteAssignment() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance le solveur (CP-SAT) : calcule des suggestions, n'affecte jamais
|
||||||
|
* personne. Le résultat alimente le cache lu par `useSolverSuggestions`.
|
||||||
|
*/
|
||||||
|
export function useRunSolver() {
|
||||||
|
const client = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => api.solver.run(edition),
|
||||||
|
onSuccess: (data) => client.setQueryData(queryKeys.solver, data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSendPlanningNotification() {
|
export function useSendPlanningNotification() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: PlanningNotificationRequest) =>
|
mutationFn: (body: PlanningNotificationRequest) =>
|
||||||
|
|||||||
@@ -178,6 +178,10 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||||
},
|
},
|
||||||
|
solver: {
|
||||||
|
run: (editionId) =>
|
||||||
|
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
|
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
|
||||||
request(`${base(editionId)}/notifications/planning`, {
|
request(`${base(editionId)}/notifications/planning`, {
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ import type {
|
|||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
|
SolverRunResult,
|
||||||
|
SolverSuggestion,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
Violation,
|
Violation,
|
||||||
|
VolunteerRead,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
import { getAuthToken } from '../session';
|
import { getAuthToken } from '../session';
|
||||||
import { ApiError, type PlanningApi } from '../types';
|
import { ApiError, type PlanningApi } from '../types';
|
||||||
@@ -375,6 +378,139 @@ function buildMatrix(): MatrixResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Solveur (suggestions) — portage glouton du moteur CP-SAT du back-end //
|
||||||
|
// (`backend/app/solver/engine.py`). Approximation : traite les créneaux les //
|
||||||
|
// plus urgents en premier et choisit, pour chacun, les meilleurs candidats //
|
||||||
|
// libres — pas une optimisation jointe comme CP-SAT, mais un ordre de //
|
||||||
|
// grandeur cohérent pour la démonstration. Ne modifie jamais `state`. //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
const SOLVER_URGENT_BASE = 1000;
|
||||||
|
const SOLVER_NORMAL_BASE = 20;
|
||||||
|
const SOLVER_WISH_BONUS = 30;
|
||||||
|
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
|
||||||
|
const SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN = 2;
|
||||||
|
const SOLVER_REST_PENALTY = 60;
|
||||||
|
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
|
||||||
|
|
||||||
|
function volunteerExistingShifts(volunteerId: string): ShiftRead[] {
|
||||||
|
return state.assignments
|
||||||
|
.filter((a) => a.volunteer_id === volunteerId)
|
||||||
|
.map((a) => shiftById(a.shift_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function solverScore(
|
||||||
|
volunteer: VolunteerRead,
|
||||||
|
shift: ShiftRead,
|
||||||
|
assignedCount: number,
|
||||||
|
existing: ShiftRead[],
|
||||||
|
): number {
|
||||||
|
let score = assignedCount < shift.min_staff ? SOLVER_URGENT_BASE : SOLVER_NORMAL_BASE;
|
||||||
|
if (volunteer.wished_position_ids.includes(shift.position_id)) score += SOLVER_WISH_BONUS;
|
||||||
|
|
||||||
|
const windows = state.availability.get(volunteer.id) ?? [];
|
||||||
|
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
|
||||||
|
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
|
||||||
|
|
||||||
|
const dayKey = shift.starts_at.slice(0, 10);
|
||||||
|
let totalMin = naiveMinutes(shift.ends_at) - naiveMinutes(shift.starts_at);
|
||||||
|
for (const other of existing) {
|
||||||
|
if (other.starts_at.slice(0, 10) === dayKey) {
|
||||||
|
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score -= Math.max(0, totalMin - EIGHT_HOURS_MIN) * SOLVER_DAILY_OVERAGE_PENALTY_PER_MIN;
|
||||||
|
|
||||||
|
for (const other of existing) {
|
||||||
|
if (overlaps(shift, other)) continue;
|
||||||
|
let gap: number | null = null;
|
||||||
|
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
|
||||||
|
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
|
||||||
|
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
|
||||||
|
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
|
||||||
|
}
|
||||||
|
if (gap !== null && gap < EIGHT_HOURS_MIN) {
|
||||||
|
score -= SOLVER_REST_PENALTY;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMinutes = existing.reduce(
|
||||||
|
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
score -= Math.floor((SOLVER_FAIRNESS_PENALTY_PER_HOUR * existingMinutes) / 60);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSolver(): SolverRunResult {
|
||||||
|
const assignedCount = new Map<string, number>();
|
||||||
|
for (const a of state.assignments) {
|
||||||
|
assignedCount.set(a.shift_id, (assignedCount.get(a.shift_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const candidateShifts = state.shifts.filter(
|
||||||
|
(s) => (assignedCount.get(s.id) ?? 0) < s.ideal_staff,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingByVolunteer = new Map<string, ShiftRead[]>();
|
||||||
|
for (const v of state.volunteers) {
|
||||||
|
existingByVolunteer.set(v.id, volunteerExistingShifts(v.id));
|
||||||
|
}
|
||||||
|
// Suggestions déjà retenues dans ce calcul, pour ne jamais proposer deux
|
||||||
|
// fois un même bénévole sur des créneaux qui se chevauchent.
|
||||||
|
const takenByVolunteer = new Map<string, ShiftRead[]>();
|
||||||
|
|
||||||
|
const ordered = [...candidateShifts].sort((a, b) => {
|
||||||
|
const urgent = (s: ShiftRead) => ((assignedCount.get(s.id) ?? 0) < s.min_staff ? 0 : 1);
|
||||||
|
return urgent(a) - urgent(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
const suggestions: SolverSuggestion[] = [];
|
||||||
|
for (const shift of ordered) {
|
||||||
|
const gap = shift.ideal_staff - (assignedCount.get(shift.id) ?? 0);
|
||||||
|
if (gap <= 0) continue;
|
||||||
|
const picked = state.volunteers
|
||||||
|
.filter((v) => {
|
||||||
|
const busy = [
|
||||||
|
...(existingByVolunteer.get(v.id) ?? []),
|
||||||
|
...(takenByVolunteer.get(v.id) ?? []),
|
||||||
|
];
|
||||||
|
return !busy.some((other) => overlaps(shift, other));
|
||||||
|
})
|
||||||
|
.map((v) => ({
|
||||||
|
volunteer: v,
|
||||||
|
score: solverScore(
|
||||||
|
v,
|
||||||
|
shift,
|
||||||
|
assignedCount.get(shift.id) ?? 0,
|
||||||
|
existingByVolunteer.get(v.id) ?? [],
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((c) => c.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, gap);
|
||||||
|
|
||||||
|
if (picked.length > 0) {
|
||||||
|
suggestions.push({
|
||||||
|
shift_id: shift.id,
|
||||||
|
volunteer_ids: picked.map((c) => c.volunteer.id),
|
||||||
|
});
|
||||||
|
for (const c of picked) {
|
||||||
|
const list = takenByVolunteer.get(c.volunteer.id) ?? [];
|
||||||
|
list.push(shift);
|
||||||
|
takenByVolunteer.set(c.volunteer.id, list);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions,
|
||||||
|
shifts_considered: candidateShifts.length,
|
||||||
|
volunteers_considered: state.volunteers.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createMockApi(): PlanningApi {
|
export function createMockApi(): PlanningApi {
|
||||||
return {
|
return {
|
||||||
editions: {
|
editions: {
|
||||||
@@ -926,6 +1062,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
get: () => delay(buildMatrix()),
|
get: () => delay(buildMatrix()),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
solver: {
|
||||||
|
run: () => delay(runSolver()),
|
||||||
|
},
|
||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
sendPlanning: (_edition, body: PlanningNotificationRequest) => {
|
||||||
const ids = body.volunteer_ids ?? null;
|
const ids = body.volunteer_ids ?? null;
|
||||||
|
|||||||
@@ -346,6 +346,21 @@ export interface AssignmentValidateResult {
|
|||||||
violations: Violation[];
|
violations: Violation[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Solveur (suggestions, CP-SAT) — ne place jamais personne //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface SolverSuggestion {
|
||||||
|
shift_id: string;
|
||||||
|
/** Bénévoles suggérés pour ce créneau, meilleur en tête. */
|
||||||
|
volunteer_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolverRunResult {
|
||||||
|
suggestions: SolverSuggestion[];
|
||||||
|
shifts_considered: number;
|
||||||
|
volunteers_considered: number;
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// Matrice poste × créneau //
|
// Matrice poste × créneau //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
ShiftCreate,
|
ShiftCreate,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
|
SolverRunResult,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
Violation,
|
Violation,
|
||||||
@@ -118,6 +119,10 @@ export interface PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get(editionId: string): Promise<MatrixResponse>;
|
get(editionId: string): Promise<MatrixResponse>;
|
||||||
};
|
};
|
||||||
|
solver: {
|
||||||
|
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
||||||
|
run(editionId: string): Promise<SolverRunResult>;
|
||||||
|
};
|
||||||
notifications: {
|
notifications: {
|
||||||
/** Récapitulatif de planning par email (COORDINATION). */
|
/** Récapitulatif de planning par email (COORDINATION). */
|
||||||
sendPlanning(
|
sendPlanning(
|
||||||
|
|||||||
@@ -293,5 +293,9 @@
|
|||||||
"grille.panel.coverage.partial": "Partiellement dispo",
|
"grille.panel.coverage.partial": "Partiellement dispo",
|
||||||
"grille.panel.coverage.none": "Dispo non déclarée",
|
"grille.panel.coverage.none": "Dispo non déclarée",
|
||||||
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
|
"availability.date_outside_edition": "Cette date est hors des dates de l'édition.",
|
||||||
"availability.empty_window": "Une plage doit avoir un début et une fin différents."
|
"availability.empty_window": "Une plage doit avoir un début et une fin différents.",
|
||||||
|
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
|
||||||
|
"grille.solver.running": "CALCUL EN COURS…",
|
||||||
|
"grille.solver.result": "{count} suggestion(s)",
|
||||||
|
"grille.panel.suggested": "Suggéré"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useMatrix } from '@/api/hooks';
|
import { useMatrix, useRunSolver } from '@/api/hooks';
|
||||||
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
||||||
import { Chip } from '@/components/ui';
|
import { Chip } from '@/components/ui';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
@@ -12,6 +12,7 @@ import { cx } from '@/lib/cx';
|
|||||||
|
|
||||||
export function GrillePage() {
|
export function GrillePage() {
|
||||||
const matrix = useMatrix();
|
const matrix = useMatrix();
|
||||||
|
const runSolver = useRunSolver();
|
||||||
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);
|
||||||
@@ -81,6 +82,27 @@ export function GrillePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runSolver.mutate()}
|
||||||
|
disabled={runSolver.isPending}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-yellow px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{runSolver.isPending ? t('grille.solver.running') : t('grille.solver.run')}
|
||||||
|
</button>
|
||||||
|
{runSolver.data ? (
|
||||||
|
<span className="font-mono text-[9px] opacity-70">
|
||||||
|
{t('grille.solver.result', { count: runSolver.data.suggestions.length })}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{runSolver.isError ? (
|
||||||
|
<span className="font-mono text-[9px] font-bold text-orange-dark">
|
||||||
|
{t('error.generic')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useEditionAvailability,
|
useEditionAvailability,
|
||||||
usePositions,
|
usePositions,
|
||||||
useShifts,
|
useShifts,
|
||||||
|
useSolverSuggestions,
|
||||||
useValidateAssignment,
|
useValidateAssignment,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
@@ -54,8 +55,16 @@ export function AssignmentPanel({
|
|||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const availability = useEditionAvailability();
|
const availability = useEditionAvailability();
|
||||||
|
const solverSuggestions = useSolverSuggestions();
|
||||||
const deleteAssignment = useDeleteAssignment();
|
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 coverageByVolunteer = useMemo(() => {
|
||||||
const byVol = new Map<string, AvailabilityRead[]>();
|
const byVol = new Map<string, AvailabilityRead[]>();
|
||||||
for (const w of availability.data ?? []) {
|
for (const w of availability.data ?? []) {
|
||||||
@@ -104,15 +113,23 @@ export function AssignmentPanel({
|
|||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
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'];
|
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
|
||||||
return (volunteers.data ?? [])
|
return (volunteers.data ?? [])
|
||||||
.filter((v) => !assignedIds.has(v.id))
|
.filter((v) => !assignedIds.has(v.id))
|
||||||
// D'abord les bénévoles disponibles sur le créneau, puis les moins chargés.
|
// Le solveur d'abord (dans son ordre), puis les disponibles, puis les
|
||||||
.sort(
|
// moins chargés.
|
||||||
(a, b) => cov(a.id) - cov(b.id) || (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0),
|
.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);
|
.slice(0, MAX_CANDIDATES);
|
||||||
}, [volunteers.data, assigned, hours, coverageByVolunteer]);
|
}, [volunteers.data, assigned, hours, coverageByVolunteer, suggestedIds]);
|
||||||
|
|
||||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
@@ -208,6 +225,7 @@ export function AssignmentPanel({
|
|||||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||||
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
||||||
|
suggested={suggestedIds.includes(volunteer.id)}
|
||||||
resolvePosition={resolvePosition}
|
resolvePosition={resolvePosition}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -228,6 +246,7 @@ function CandidateRow({
|
|||||||
hours,
|
hours,
|
||||||
slots,
|
slots,
|
||||||
coverage,
|
coverage,
|
||||||
|
suggested,
|
||||||
resolvePosition,
|
resolvePosition,
|
||||||
}: {
|
}: {
|
||||||
volunteer: VolunteerRead;
|
volunteer: VolunteerRead;
|
||||||
@@ -235,6 +254,7 @@ function CandidateRow({
|
|||||||
hours: number;
|
hours: number;
|
||||||
slots: number;
|
slots: number;
|
||||||
coverage: Coverage;
|
coverage: Coverage;
|
||||||
|
suggested: boolean;
|
||||||
resolvePosition: (shiftId: string) => string | undefined;
|
resolvePosition: (shiftId: string) => string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const validation = useValidateAssignment(volunteer.id, shiftId);
|
const validation = useValidateAssignment(volunteer.id, shiftId);
|
||||||
@@ -248,13 +268,20 @@ function CandidateRow({
|
|||||||
createAssignment.error instanceof ApiError ? createAssignment.error : null;
|
createAssignment.error instanceof ApiError ? createAssignment.error : null;
|
||||||
|
|
||||||
return (
|
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]">
|
<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">
|
<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)}
|
{initials(volunteer)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||||
{volunteer.first_name} {volunteer.last_name}
|
{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">
|
<span className="block font-mono text-[9px] opacity-60">
|
||||||
{t('grille.panel.hoursAssigned', { hours, slots })}
|
{t('grille.panel.hoursAssigned', { hours, slots })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user