import { useMemo, useState } from 'react'; import { ApiError } from '@/api'; import { useArtists, useAssignments, useCreateAssignment, useDeleteAssignment, usePositions, useShifts, useSolverSuggestions, useValidateAssignment, useVolunteers, } from '@/api/hooks'; import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema'; import { Btn } from '@/components/ui'; import { Loading } from '@/components/state'; import { artistPerformanceWindow, buildEffectiveCompanionIds, candidateChanceScore, categoryLabel, foldAccents, hoursByVolunteer, violationText, windowsOverlap, } 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 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 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(); 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, // 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(); for (const v of volunteers.data ?? []) { const details = detailsByVolunteer.get(v.id); map.set( v.id, candidateChanceScore({ wishesPosition: details?.wishesPosition ?? false, conflictsWishedArtist: details?.conflictsWishedArtist ?? false, companionMatches: details?.companionMatches ?? 0, assignedHours: hours.get(v.id) ?? 0, }), ); } return map; }, [volunteers.data, detailsByVolunteer, hours]); 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 ; } return (
{positionName}
{categoryLabel(positionCategory)} {positionLocation ? ` · ${positionLocation}` : ''}
{shortDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)}
{t('grille.panel.slotDuration', { hours: durationHours })}
{t('grille.panel.assigned')} {shift.assigned} {t('grille.panel.min', { count: shift.min_staff })} {t('grille.panel.ideal', { count: shift.ideal_staff })}
{t('grille.panel.assignmentsTitle', { count: assigned.length })}
{assigned.length === 0 ? (
{t('grille.panel.noneAssigned')}
) : ( assigned.map((assignment) => { const volunteer = volunteers.data?.find((v) => v.id === assignment.volunteer_id); if (!volunteer) return null; return (
{initials(volunteer)} {volunteer.first_name} {volunteer.last_name} {t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
); }) )}
{t('grille.panel.candidates')}
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 ? (
{t('grille.panel.searchNoMatch')}
) : null} {candidates.map((volunteer) => ( ))}
); } function CandidateRow({ volunteer, shiftId, hours, slots, wishesPosition, conflictsWishedArtist, companionMatches, suggested, resolvePosition, }: { volunteer: VolunteerRead; shiftId: string; hours: number; slots: number; 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 (
{initials(volunteer)} {volunteer.first_name} {volunteer.last_name} {suggested ? ( {t('grille.panel.suggested')} ) : null} {t('grille.panel.hoursAssigned', { hours, slots })} {validation.isLoading ? ( ) : isBlocked ? ( {t('grille.panel.blocked')} ) : ( createAssignment.mutate({ volunteer_id: volunteer.id, shift_id: shiftId }) } > {t('grille.panel.assign')} )}
{wishesPosition ? ( {t('grille.panel.wishesPosition')} ) : null} {conflictsWishedArtist ? ( {t('grille.panel.artistConflict')} ) : null} {companionMatches > 0 ? ( {t('grille.panel.withCompanion', { count: companionMatches })} ) : null}
{/* {blocking ? (
⛔ {t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
) : null} */} {blocking ? (
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
) : null} {!isBlocked && warnings.length > 0 ? (
{t('grille.panel.warningsTitle')}
{warnings.map((warning, i) => (
· {violationText(warning, resolvePosition)}
))}
) : null} {assignError ? (
{t(assignError.code)}
) : null}
); } 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; }