Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
+3 -54
View File
@@ -6,33 +6,24 @@ import {
useAssignments,
useCreateAssignment,
useDeleteAssignment,
useEditionAvailability,
usePositions,
useShifts,
useSolverSuggestions,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type {
AvailabilityRead,
MatrixShift,
PositionCategory,
VolunteerRead,
} from '@/api/schema';
import type { 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';
@@ -63,7 +54,6 @@ export function AssignmentPanel({
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();
@@ -75,27 +65,6 @@ export function AssignmentPanel({
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],
@@ -166,8 +135,7 @@ export function AssignmentPanel({
}, [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
// 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>();
@@ -177,11 +145,6 @@ export function AssignmentPanel({
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,
@@ -189,7 +152,7 @@ export function AssignmentPanel({
);
}
return map;
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
}, [volunteers.data, detailsByVolunteer, hours]);
const needle = foldAccents(search.trim());
@@ -319,7 +282,6 @@ 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'}
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
conflictsWishedArtist={
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
@@ -334,18 +296,11 @@ 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,
wishesPosition,
conflictsWishedArtist,
companionMatches,
@@ -356,7 +311,6 @@ function CandidateRow({
shiftId: string;
hours: number;
slots: number;
coverage: Coverage;
wishesPosition: boolean;
conflictsWishedArtist: boolean;
companionMatches: number;
@@ -411,11 +365,6 @@ function CandidateRow({
</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')}