Panneau d'affectation : barre de recherche pour retrouver un bénévole
La liste des candidats se limitait aux 10 mieux classés par le score de
chance — un bénévole précis pouvait ne jamais y apparaître. Un champ de
recherche (nom/prénom, insensible aux accents/casse) permet maintenant de le
retrouver et de l'affecter directement, sans être limité au classement.
- Recherche vide : comportement inchangé (top 10 par score de chance).
- Recherche non vide : jusqu'à 20 bénévoles non affectés dont le nom
correspond, triés par le même score ; message dédié si aucun ne correspond.
- `foldAccents` (accents/casse) déplacé de `ConfigVolunteers.tsx` vers
`lib/planning.ts` pour être partagé avec le panneau d'affectation.
- `GrillePage.tsx` : `key={shift.shift_id}` sur `AssignmentPanel` pour que la
recherche (et tout futur état local) reparte à zéro en changeant de
créneau, plutôt que de persister sur le suivant.
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
11bc8cbe36
commit
efa652fffb
@@ -92,7 +92,8 @@
|
||||
"grille.panel.noneAssigned": "Personne n'est encore affecté sur ce créneau.",
|
||||
"grille.panel.hoursTotal": "{hours} H AU TOTAL",
|
||||
"grille.panel.candidates": "CANDIDATS",
|
||||
"grille.panel.candidatesHint": "LES MOINS CHARGÉS D'ABORD",
|
||||
"grille.panel.searchVolunteer": "Rechercher un bénévole…",
|
||||
"grille.panel.searchNoMatch": "Aucun bénévole ne correspond à cette recherche.",
|
||||
"grille.panel.hoursAssigned": "{hours} H · {slots} CRÉNEAUX",
|
||||
"grille.panel.assign": "AFFECTER",
|
||||
"grille.panel.blocked": "BLOQUÉ",
|
||||
|
||||
@@ -16,6 +16,14 @@ export function categoryLabel(category: PositionCategory): string {
|
||||
return t(`position.category.${category.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/** Normalise pour une recherche insensible aux accents et à la casse. */
|
||||
export function foldAccents(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Disponibilités : couverture d'un créneau par les fenêtres déclarées //
|
||||
// (portage de `backend/app/validation/engine.py`). //
|
||||
|
||||
@@ -175,6 +175,7 @@ export function GrillePage() {
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
{selected ? (
|
||||
<AssignmentPanel
|
||||
key={selected.shift.shift_id}
|
||||
positionId={selected.position.id}
|
||||
positionName={selected.position.name}
|
||||
positionLocation={selected.position.location}
|
||||
@@ -253,7 +254,7 @@ function PositionRow({
|
||||
{columns.map((column) => {
|
||||
const shift = byColumn.get(column.key);
|
||||
if (!shift) {
|
||||
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
|
||||
return <div key={column.key} className="h-9 border-b border-r border-ink" />;
|
||||
}
|
||||
const assigned = shift.assigned;
|
||||
const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0;
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from '@/api/schema';
|
||||
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { foldAccents } from '@/lib/planning';
|
||||
import { t } from '@/i18n';
|
||||
import { shortDayLabel } from '@/lib/time';
|
||||
import { cx } from '@/lib/cx';
|
||||
@@ -151,13 +152,6 @@ function VolunteerAccessSection({
|
||||
);
|
||||
}
|
||||
|
||||
function foldAccents(value: string): string {
|
||||
return value
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Sélecteur de vœux avec recherche (bénévoles : liste potentiellement
|
||||
* longue) — options déjà triées par ordre alphabétique par l'appelant. */
|
||||
function WishPicker({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiError } from '@/api';
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
buildEffectiveCompanionIds,
|
||||
candidateChanceScore,
|
||||
categoryLabel,
|
||||
foldAccents,
|
||||
hoursByVolunteer,
|
||||
uncoveredMinutes,
|
||||
violationText,
|
||||
@@ -46,6 +47,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const MAX_CANDIDATES = 10;
|
||||
const MAX_SEARCH_RESULTS = 20;
|
||||
|
||||
export function AssignmentPanel({
|
||||
positionId,
|
||||
@@ -55,6 +57,7 @@ export function AssignmentPanel({
|
||||
shift,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const volunteers = useVolunteers();
|
||||
const allAssignments = useAssignments();
|
||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||
@@ -188,20 +191,29 @@ export function AssignmentPanel({
|
||||
return map;
|
||||
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
|
||||
|
||||
const needle = foldAccents(search.trim());
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||
const suggestedRank = new Map(suggestedIds.map((id, i) => [id, i]));
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => !assignedIds.has(v.id))
|
||||
// Le solveur d'abord (dans son ordre, s'il a tourné), puis par score de
|
||||
// chance décroissant (vœu de poste, disponibilité, charge).
|
||||
.sort((a, b) => {
|
||||
const ra = suggestedRank.get(a.id) ?? Infinity;
|
||||
const rb = suggestedRank.get(b.id) ?? Infinity;
|
||||
return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0);
|
||||
})
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds]);
|
||||
const notAssigned = (volunteers.data ?? []).filter((v) => !assignedIds.has(v.id));
|
||||
// Score de chance décroissant (vœu de poste, disponibilité, compagnons,
|
||||
// charge) — le solveur d'abord (dans son ordre, s'il a tourné), sauf
|
||||
// pendant une recherche : on veut alors trouver un bénévole précis, pas
|
||||
// se fier au classement.
|
||||
const byChance = (a: VolunteerRead, b: VolunteerRead) => {
|
||||
const ra = suggestedRank.get(a.id) ?? Infinity;
|
||||
const rb = suggestedRank.get(b.id) ?? Infinity;
|
||||
return ra - rb || (scoreByVolunteer.get(b.id) ?? 0) - (scoreByVolunteer.get(a.id) ?? 0);
|
||||
};
|
||||
if (needle) {
|
||||
return notAssigned
|
||||
.filter((v) => foldAccents(`${v.first_name} ${v.last_name}`).includes(needle))
|
||||
.sort(byChance)
|
||||
.slice(0, MAX_SEARCH_RESULTS);
|
||||
}
|
||||
return notAssigned.sort(byChance).slice(0, MAX_CANDIDATES);
|
||||
}, [volunteers.data, assigned, scoreByVolunteer, suggestedIds, needle]);
|
||||
|
||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||
return <Loading />;
|
||||
@@ -287,8 +299,19 @@ export function AssignmentPanel({
|
||||
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.candidates')}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.panel.candidatesHint')}</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('grille.panel.searchVolunteer')}
|
||||
className="mb-[9px] w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||
/>
|
||||
{needle && candidates.length === 0 ? (
|
||||
<div className="mb-[6px] font-mono text-[10.5px] opacity-60">
|
||||
{t('grille.panel.searchNoMatch')}
|
||||
</div>
|
||||
) : null}
|
||||
{candidates.map((volunteer) => (
|
||||
<CandidateRow
|
||||
key={volunteer.id}
|
||||
|
||||
Reference in New Issue
Block a user