Front : retrait des compétences, disponibilités en fenêtres horaires

Aligne le frontend sur le back-end (étape 14).

- Types : suppression de `skill_codes` / `required_skill_codes` /
  `AvailabilityStatus` / `MISSING_REQUIRED_SKILL` ; ajout de
  `AvailabilityRead` / `AvailabilitySet` et de la surface API
  `availability` (list / replace / listEdition) + hooks associés.
- Fiche bénévole : bloc « compétences » remplacé par un éditeur de
  disponibilités (plages date + heures libres, `PUT` en bloc).
- Assistant d'import : plus de colonnes compétences ni disponibilités
  (mapping réduit à l'identité) ; rapport simplifié.
- Grille des besoins : chaque candidat porte un badge de couverture
  (disponible / partiel / non déclaré) calculé sur ses fenêtres, et la
  liste est triée « disponibles d'abord ». Nouvel util
  `availabilityCoverage` / `uncoveredMinutes` dans `lib/planning`,
  portage du moteur back-end.
- Mock : `state.availability` passe à des fenêtres `AvailabilityRead[]`,
  validation `OUTSIDE_AVAILABILITY` recalculée sur l'union des fenêtres,
  jeu de démo et import CSV adaptés.

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:
AdamThomas
2026-09-10 23:01:59 +02:00
co-authored by Claude Sonnet 5
parent be51770b51
commit 36d108b47d
11 changed files with 487 additions and 439 deletions
+56 -16
View File
@@ -5,18 +5,32 @@ import {
useAssignments,
useCreateAssignment,
useDeleteAssignment,
useEditionAvailability,
usePositions,
useShifts,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import type {
AvailabilityRead,
MatrixShift,
PositionCategory,
VolunteerRead,
} from '@/api/schema';
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import { categoryLabel, hoursByVolunteer, violationText } from '@/lib/planning';
import {
availabilityCoverage,
categoryLabel,
hoursByVolunteer,
violationText,
type Coverage,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
const COVERAGE_ORDER: Record<Coverage, number> = { full: 0, partial: 1, none: 2 };
interface Props {
positionName: string;
positionLocation: string | null;
@@ -39,8 +53,26 @@ export function AssignmentPanel({
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
const shifts = useShifts();
const positions = usePositions();
const availability = useEditionAvailability();
const deleteAssignment = useDeleteAssignment();
const coverageByVolunteer = 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);
}
const map = new Map<string, Coverage>();
for (const v of volunteers.data ?? []) {
map.set(
v.id,
availabilityCoverage(byVol.get(v.id) ?? [], shift.starts_at, shift.ends_at),
);
}
return map;
}, [availability.data, volunteers.data, shift.starts_at, shift.ends_at]);
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
@@ -72,11 +104,15 @@ export function AssignmentPanel({
const candidates = useMemo(() => {
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
return (volunteers.data ?? [])
.filter((v) => !assignedIds.has(v.id))
.sort((a, b) => (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0))
// 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),
)
.slice(0, MAX_CANDIDATES);
}, [volunteers.data, assigned, hours]);
}, [volunteers.data, assigned, hours, coverageByVolunteer]);
if (volunteers.isLoading || shiftAssignments.isLoading) {
return <Loading />;
@@ -171,6 +207,7 @@ export function AssignmentPanel({
shiftId={shift.shift_id}
hours={round(hours.get(volunteer.id) ?? 0)}
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
resolvePosition={resolvePosition}
/>
))}
@@ -179,17 +216,25 @@ export function AssignmentPanel({
);
}
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,
resolvePosition,
}: {
volunteer: VolunteerRead;
shiftId: string;
hours: number;
slots: number;
coverage: Coverage;
resolvePosition: (shiftId: string) => string | undefined;
}) {
const validation = useValidateAssignment(volunteer.id, shiftId);
@@ -232,18 +277,13 @@ function CandidateRow({
)}
</div>
{volunteer.skill_codes.length > 0 ? (
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
{volunteer.skill_codes.map((code) => (
<span
key={code}
className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-mono text-[9px] font-semibold"
>
{code}
</span>
))}
</div>
) : null}
<div className="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>
</div>
{blocking ? (
<div className="flex items-stretch border-t-2 border-ink">