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
484 lines
18 KiB
TypeScript
484 lines
18 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
|
|
import { ApiError } from '@/api';
|
|
import {
|
|
useArtists,
|
|
useAssignments,
|
|
useCreateAssignment,
|
|
useDeleteAssignment,
|
|
useEditionAvailability,
|
|
usePositions,
|
|
useShifts,
|
|
useSolverSuggestions,
|
|
useValidateAssignment,
|
|
useVolunteers,
|
|
} from '@/api/hooks';
|
|
import type {
|
|
AvailabilityRead,
|
|
MatrixShift,
|
|
PositionCategory,
|
|
VolunteerRead,
|
|
} from '@/api/schema';
|
|
import { Btn } from '@/components/ui';
|
|
import { Loading } from '@/components/state';
|
|
import {
|
|
artistPerformanceWindow,
|
|
availabilityCoverage,
|
|
buildEffectiveCompanionIds,
|
|
candidateChanceScore,
|
|
categoryLabel,
|
|
foldAccents,
|
|
hoursByVolunteer,
|
|
uncoveredMinutes,
|
|
violationText,
|
|
windowsOverlap,
|
|
type Coverage,
|
|
} from '@/lib/planning';
|
|
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
|
import { t } from '@/i18n';
|
|
|
|
interface Props {
|
|
positionId: string;
|
|
positionName: string;
|
|
positionLocation: string | null;
|
|
positionCategory: PositionCategory;
|
|
shift: MatrixShift;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const MAX_CANDIDATES = 10;
|
|
const MAX_SEARCH_RESULTS = 20;
|
|
|
|
export function AssignmentPanel({
|
|
positionId,
|
|
positionName,
|
|
positionLocation,
|
|
positionCategory,
|
|
shift,
|
|
onClose,
|
|
}: Props) {
|
|
const [search, setSearch] = useState('');
|
|
const volunteers = useVolunteers();
|
|
const allAssignments = useAssignments();
|
|
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
|
const shifts = useShifts();
|
|
const positions = usePositions();
|
|
const availability = useEditionAvailability();
|
|
const artists = useArtists();
|
|
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 windowsByVolunteer = useMemo(() => {
|
|
const byVol = new Map<string, AvailabilityRead[]>();
|
|
for (const w of availability.data ?? []) {
|
|
const list = byVol.get(w.volunteer_id) ?? [];
|
|
list.push(w);
|
|
byVol.set(w.volunteer_id, list);
|
|
}
|
|
return byVol;
|
|
}, [availability.data]);
|
|
|
|
const coverageByVolunteer = useMemo(() => {
|
|
const map = new Map<string, Coverage>();
|
|
for (const v of volunteers.data ?? []) {
|
|
map.set(
|
|
v.id,
|
|
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
|
);
|
|
}
|
|
return map;
|
|
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
|
|
|
|
const artistsById = useMemo(
|
|
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
|
|
[artists.data],
|
|
);
|
|
|
|
const shiftsById = useMemo(
|
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
|
[shifts.data],
|
|
);
|
|
const positionsById = useMemo(
|
|
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
|
[positions.data],
|
|
);
|
|
const hours = useMemo(
|
|
() => hoursByVolunteer(allAssignments.data ?? [], shiftsById),
|
|
[allAssignments.data, shiftsById],
|
|
);
|
|
const slotsByVolunteer = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const a of allAssignments.data ?? []) {
|
|
map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
|
|
}
|
|
return map;
|
|
}, [allAssignments.data]);
|
|
|
|
const resolvePosition = (shiftId: string): string | undefined => {
|
|
const s = shiftsById.get(shiftId);
|
|
const p = s ? positionsById.get(s.position_id) : undefined;
|
|
return p?.name;
|
|
};
|
|
|
|
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
|
|
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
|
|
|
|
const effectiveCompanionIds = useMemo(
|
|
() => buildEffectiveCompanionIds(volunteers.data ?? []),
|
|
[volunteers.data],
|
|
);
|
|
|
|
// Raisons du score, par bénévole : vœu de poste, conflit avec un artiste
|
|
// qu'il veut voir sur ce créneau (indépendant de sa disponibilité déclarée),
|
|
// compagnons souhaités déjà affectés à ce créneau.
|
|
const detailsByVolunteer = useMemo(() => {
|
|
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
|
const map = new Map<
|
|
string,
|
|
{ wishesPosition: boolean; conflictsWishedArtist: boolean; companionMatches: number }
|
|
>();
|
|
for (const v of volunteers.data ?? []) {
|
|
const conflictsWishedArtist = v.artist_ids.some((id) => {
|
|
const artist = artistsById.get(id);
|
|
return artist ? windowsOverlap(shift, artistPerformanceWindow(artist)) : false;
|
|
});
|
|
const effective = effectiveCompanionIds.get(v.id);
|
|
let companionMatches = 0;
|
|
if (effective) {
|
|
for (const id of assignedIds) {
|
|
if (effective.has(id)) companionMatches += 1;
|
|
}
|
|
}
|
|
map.set(v.id, {
|
|
wishesPosition: v.wished_position_ids.includes(positionId),
|
|
conflictsWishedArtist,
|
|
companionMatches,
|
|
});
|
|
}
|
|
return map;
|
|
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
|
|
|
|
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
|
|
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
|
|
// souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
|
|
// liste, pas juste « le moins chargé ».
|
|
const scoreByVolunteer = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
for (const v of volunteers.data ?? []) {
|
|
const details = detailsByVolunteer.get(v.id);
|
|
map.set(
|
|
v.id,
|
|
candidateChanceScore({
|
|
wishesPosition: details?.wishesPosition ?? false,
|
|
uncoveredMinutes: uncoveredMinutes(
|
|
windowsByVolunteer.get(v.id) ?? [],
|
|
shift.starts_at,
|
|
shift.ends_at,
|
|
),
|
|
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
|
|
companionMatches: details?.companionMatches ?? 0,
|
|
assignedHours: hours.get(v.id) ?? 0,
|
|
}),
|
|
);
|
|
}
|
|
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]));
|
|
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 />;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex-1">
|
|
<div className="font-display text-[16px] font-black tracking-tight">{positionName}</div>
|
|
<div className="font-mono text-[10px] text-yellow">
|
|
{categoryLabel(positionCategory)}
|
|
{positionLocation ? ` · ${positionLocation}` : ''}
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
|
|
aria-label={t('action.close')}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="mt-[9px] flex items-stretch gap-2">
|
|
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
|
|
{shortDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)}
|
|
</div>
|
|
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
|
|
{t('grille.panel.slotDuration', { hours: durationHours })}
|
|
</div>
|
|
</div>
|
|
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
|
|
<span>
|
|
{t('grille.panel.assigned')} <b className="text-[15px]">{shift.assigned}</b>
|
|
</span>
|
|
<span className="opacity-70">{t('grille.panel.min', { count: shift.min_staff })}</span>
|
|
<span className="opacity-70">{t('grille.panel.ideal', { count: shift.ideal_staff })}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-b-2 border-ink px-[13px] py-3">
|
|
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
|
|
{t('grille.panel.assignmentsTitle', { count: assigned.length })}
|
|
</div>
|
|
{assigned.length === 0 ? (
|
|
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
|
|
) : (
|
|
assigned.map((assignment) => {
|
|
const volunteer = volunteers.data?.find((v) => v.id === assignment.volunteer_id);
|
|
if (!volunteer) return null;
|
|
return (
|
|
<div
|
|
key={assignment.id}
|
|
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
|
|
>
|
|
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
|
|
{initials(volunteer)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
|
{volunteer.first_name} {volunteer.last_name}
|
|
<span className="block font-mono text-[9px] opacity-60">
|
|
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
|
</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => deleteAssignment.mutate(assignment.id)}
|
|
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
|
aria-label={t('action.remove')}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<div className="px-[13px] py-3">
|
|
<div className="mb-[9px] flex items-baseline gap-2">
|
|
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
|
|
{t('grille.panel.candidates')}
|
|
</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}
|
|
volunteer={volunteer}
|
|
shiftId={shift.shift_id}
|
|
hours={round(hours.get(volunteer.id) ?? 0)}
|
|
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
|
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
|
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
|
|
conflictsWishedArtist={
|
|
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
|
|
}
|
|
companionMatches={detailsByVolunteer.get(volunteer.id)?.companionMatches ?? 0}
|
|
suggested={suggestedIds.includes(volunteer.id)}
|
|
resolvePosition={resolvePosition}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const COVERAGE_STYLE: Record<Coverage, { cls: string; key: string }> = {
|
|
full: { cls: 'bg-green text-white', key: 'grille.panel.coverage.full' },
|
|
partial: { cls: 'bg-yellow-soft', key: 'grille.panel.coverage.partial' },
|
|
none: { cls: 'bg-sand-dark', key: 'grille.panel.coverage.none' },
|
|
};
|
|
|
|
function CandidateRow({
|
|
volunteer,
|
|
shiftId,
|
|
hours,
|
|
slots,
|
|
coverage,
|
|
wishesPosition,
|
|
conflictsWishedArtist,
|
|
companionMatches,
|
|
suggested,
|
|
resolvePosition,
|
|
}: {
|
|
volunteer: VolunteerRead;
|
|
shiftId: string;
|
|
hours: number;
|
|
slots: number;
|
|
coverage: Coverage;
|
|
wishesPosition: boolean;
|
|
conflictsWishedArtist: boolean;
|
|
companionMatches: number;
|
|
suggested: boolean;
|
|
resolvePosition: (shiftId: string) => string | undefined;
|
|
}) {
|
|
const validation = useValidateAssignment(volunteer.id, shiftId);
|
|
const createAssignment = useCreateAssignment();
|
|
|
|
const blocking = validation.data?.violations.find((v) => v.severity === 'BLOCKING') ?? null;
|
|
const warnings = validation.data?.violations.filter((v) => v.severity === 'WARNING') ?? [];
|
|
const isBlocked = validation.data?.blocking ?? false;
|
|
|
|
const assignError =
|
|
createAssignment.error instanceof ApiError ? createAssignment.error : null;
|
|
|
|
return (
|
|
<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>
|
|
</span>
|
|
{validation.isLoading ? (
|
|
<span className="h-4 w-4 flex-none animate-spin border-2 border-ink border-t-transparent" />
|
|
) : isBlocked ? (
|
|
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
|
|
{t('grille.panel.blocked')}
|
|
</span>
|
|
) : (
|
|
<Btn
|
|
disabled={createAssignment.isPending}
|
|
onClick={() =>
|
|
createAssignment.mutate({ volunteer_id: volunteer.id, shift_id: shiftId })
|
|
}
|
|
>
|
|
{t('grille.panel.assign')}
|
|
</Btn>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
|
<span
|
|
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
|
|
>
|
|
{t(COVERAGE_STYLE[coverage].key)}
|
|
</span>
|
|
{wishesPosition ? (
|
|
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
|
{t('grille.panel.wishesPosition')}
|
|
</span>
|
|
) : null}
|
|
{conflictsWishedArtist ? (
|
|
<span className="inline-block border-[1.5px] border-ink bg-orange px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
|
{t('grille.panel.artistConflict')}
|
|
</span>
|
|
) : null}
|
|
{companionMatches > 0 ? (
|
|
<span className="inline-block border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
|
{t('grille.panel.withCompanion', { count: companionMatches })}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* {blocking ? (
|
|
<div className="flex items-stretch border-t-2 border-ink">
|
|
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
|
|
⛔
|
|
</span>
|
|
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
|
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
|
|
</span>
|
|
</div>
|
|
) : null} */}
|
|
|
|
{blocking ? (
|
|
<div className="border-t-2 border-dashed border-ink bg-hatch-orange px-2 py-[6px]">
|
|
<div className="font-display text-[8.5px] font-extrabold uppercase tracking-widest text-white">
|
|
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{!isBlocked && warnings.length > 0 ? (
|
|
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
|
|
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
|
|
{t('grille.panel.warningsTitle')}
|
|
</div>
|
|
{warnings.map((warning, i) => (
|
|
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
|
|
· {violationText(warning, resolvePosition)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{assignError ? (
|
|
<div className="border-t-2 border-ink bg-orange px-2 py-[6px] font-display text-[10px] font-bold">
|
|
{t(assignError.code)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function initials(v: VolunteerRead): string {
|
|
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
|
|
}
|
|
|
|
function round(n: number): number {
|
|
return Math.round(n * 10) / 10;
|
|
}
|