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:
co-authored by
Claude Sonnet 5
parent
be51770b51
commit
36d108b47d
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user