Frontend : consommation de l'API CRUD (matrice, affectations, validation)
Remplace le magasin d'état fabriqué par une vraie couche API typée, alignée sur le back-end (Étape 5) : - src/api/ : schema.ts (miroir de backend/app/api/schemas.py), client HTTP, implémentation mock en mémoire (même surface + portage du moteur de validation), sélection mock/http via VITE_API_MODE, hooks React Query (lectures + mutations avec invalidation). - Grille des besoins ← GET /matrix ; treillis de colonnes dérivé des créneaux réels (plus de plages figées). - Panneau d'affectation ← GET /assignments?shift_id, POST /assignments/validate par candidat (alertes bloquantes / avertissements du moteur), POST / PATCH / DELETE /assignments. - Vue par bénévole ← volunteers + shifts + assignments (surcharge / oubliés calculés côté client). - Confirmations (ex-« Publication ») ← affectations PROPOSED ; publication versionnée et emails signalés hors périmètre. - Mobiles ← assignments filtrées ; confirmation = PATCH ; pointage référent local (hors périmètre). - Configuration ← positions & shifts CRUD (min/idéal, ajout/retrait), volunteers CRUD dont suppression RGPD ; messages = maquette locale. - i18n mis à jour, aucun texte d'interface en dur. - Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (API mock + rendu headless des 6 écrans avec données préchargées). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ac469f0cd1
commit
c9dda65361
@@ -1,109 +1,103 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { ApiError } from '@/api';
|
||||
import {
|
||||
useAssignments,
|
||||
useCreateAssignment,
|
||||
useDeleteAssignment,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useUpdateAssignment,
|
||||
useValidateAssignment,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
|
||||
import { Badge, Btn } from '@/components/ui';
|
||||
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
|
||||
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
|
||||
import type { PlanningIndex } from '@/domain/selectors';
|
||||
import { Loading } from '@/components/state';
|
||||
import { categoryLabel, hoursByVolunteer, isActive, violationText } from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
function violationText(violation: Violation): string {
|
||||
return t(`validation.${violation.code}`, violation.params);
|
||||
interface Props {
|
||||
positionName: string;
|
||||
positionLocation: string | null;
|
||||
positionCategory: PositionCategory;
|
||||
shift: MatrixShift;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [positionId, dayId, dayPartCode] = slot.split('|');
|
||||
const MAX_CANDIDATES = 10;
|
||||
|
||||
const positionsById = useMemo(
|
||||
() => new Map(state.positions.map((p) => [p.id, p])),
|
||||
[state.positions],
|
||||
export function AssignmentPanel({
|
||||
positionName,
|
||||
positionLocation,
|
||||
positionCategory,
|
||||
shift,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const volunteers = useVolunteers();
|
||||
const allAssignments = useAssignments();
|
||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const deleteAssignment = useDeleteAssignment();
|
||||
const updateAssignment = useUpdateAssignment();
|
||||
|
||||
const shiftsById = useMemo(
|
||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||
[shifts.data],
|
||||
);
|
||||
const position = positionsById.get(positionId);
|
||||
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
|
||||
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 ?? []) {
|
||||
if (isActive(a)) map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [allAssignments.data]);
|
||||
|
||||
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
|
||||
const durationHours = dayPartHours(dayPartCode);
|
||||
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 candidates = useMemo(() => {
|
||||
if (!position || !need) return [];
|
||||
return state.volunteers
|
||||
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
|
||||
.map((volunteer) => {
|
||||
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
|
||||
const violations = evaluateAssignment({
|
||||
volunteer,
|
||||
target: { position, dayId, dayPartCode },
|
||||
currentAssignments,
|
||||
positionsById,
|
||||
thresholds: {
|
||||
maxDailyHours: state.config.maxDailyHours,
|
||||
minRestHours: state.config.minRestHours,
|
||||
},
|
||||
});
|
||||
const blocked = hasBlocking(violations);
|
||||
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
|
||||
const warnings = violations.filter((v) => v.severity === 'WARNING');
|
||||
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
|
||||
const wanted = volunteer.preferredCategories.includes(position.category);
|
||||
const hours = index.totalHours.get(volunteer.id) ?? 0;
|
||||
const score =
|
||||
(available ? 100 : 0) +
|
||||
(wanted ? 40 : 0) -
|
||||
warnings.length * 12 -
|
||||
hours * 2 -
|
||||
(blocked ? 1000 : 0);
|
||||
return {
|
||||
volunteer,
|
||||
blocked,
|
||||
blocking,
|
||||
warnings,
|
||||
available,
|
||||
wanted,
|
||||
hours,
|
||||
slots: currentAssignments.length,
|
||||
score,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 9);
|
||||
}, [
|
||||
position,
|
||||
need,
|
||||
state.volunteers,
|
||||
state.config.maxDailyHours,
|
||||
state.config.minRestHours,
|
||||
assignedList,
|
||||
index,
|
||||
positionsById,
|
||||
dayId,
|
||||
dayPartCode,
|
||||
]);
|
||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => !assignedIds.has(v.id))
|
||||
.sort((a, b) => (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0))
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}, [volunteers.data, assigned, hours]);
|
||||
|
||||
if (!position || !need) {
|
||||
return (
|
||||
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
|
||||
{t('grille.panel.empty.body')}
|
||||
</div>
|
||||
);
|
||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const day = festivalDay(dayId);
|
||||
const part = dayPart(dayPartCode);
|
||||
|
||||
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">{position.name}</div>
|
||||
<div className="font-display text-[16px] font-black tracking-tight">{positionName}</div>
|
||||
<div className="font-mono text-[10px] text-yellow">
|
||||
{position.zone} · {position.location}
|
||||
{categoryLabel(positionCategory)}
|
||||
{positionLocation ? ` · ${positionLocation}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
|
||||
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')}
|
||||
>
|
||||
@@ -112,31 +106,34 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
</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">
|
||||
{day.shortLabel} · {part.label}
|
||||
{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 gap-[14px] font-mono text-[10px]">
|
||||
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
|
||||
<span>
|
||||
{t('grille.panel.assigned')}{' '}
|
||||
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
|
||||
{t('grille.panel.confirmed')}{' '}
|
||||
<b className="text-[15px]">{shift.confirmed}</b>
|
||||
</span>
|
||||
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
|
||||
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
|
||||
<span className="opacity-70">
|
||||
{t('grille.panel.proposedCount', { count: shift.proposed })}
|
||||
</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: assignedList.length })}
|
||||
{t('grille.panel.assignmentsTitle', { count: assigned.length })}
|
||||
</div>
|
||||
{assignedList.length === 0 ? (
|
||||
{assigned.length === 0 ? (
|
||||
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
|
||||
) : (
|
||||
assignedList.map((assignment) => {
|
||||
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
|
||||
assigned.map((assignment) => {
|
||||
const volunteer = volunteers.data?.find((v) => v.id === assignment.volunteer_id);
|
||||
if (!volunteer) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -144,24 +141,30 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
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">
|
||||
{volunteer.initials}
|
||||
{initials(volunteer)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
||||
{volunteer.fullName}
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursTotal', {
|
||||
hours: index.totalHours.get(volunteer.id) ?? 0,
|
||||
})}
|
||||
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
||||
</span>
|
||||
</span>
|
||||
{assignment.status === 'CONFIRMED' ? (
|
||||
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
|
||||
) : (
|
||||
<Badge tone="proposed">{t('assignment.status.proposed')}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
|
||||
}
|
||||
className="border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide hover:bg-green hover:text-white"
|
||||
>
|
||||
{t('assignment.status.proposed')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
|
||||
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')}
|
||||
>
|
||||
@@ -178,74 +181,126 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.candidates')}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.candidatesHint')}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.panel.candidatesHint')}</span>
|
||||
</div>
|
||||
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
|
||||
<div
|
||||
{candidates.map((volunteer) => (
|
||||
<CandidateRow
|
||||
key={volunteer.id}
|
||||
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
|
||||
>
|
||||
<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">
|
||||
{volunteer.initials}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||
{volunteer.fullName}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursAssigned', { hours, slots })}
|
||||
</span>
|
||||
</span>
|
||||
{blocked ? (
|
||||
<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 onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
|
||||
{t('grille.panel.assign')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
{(available || wanted) && (
|
||||
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||
{available ? (
|
||||
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||
{t('grille.panel.availableTag')}
|
||||
</span>
|
||||
) : null}
|
||||
{wanted ? (
|
||||
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||
{t('grille.panel.wantedTag')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{blocked && 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) })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!blocked && 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)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
volunteer={volunteer}
|
||||
shiftId={shift.shift_id}
|
||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||
resolvePosition={resolvePosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateRow({
|
||||
volunteer,
|
||||
shiftId,
|
||||
hours,
|
||||
slots,
|
||||
resolvePosition,
|
||||
}: {
|
||||
volunteer: VolunteerRead;
|
||||
shiftId: string;
|
||||
hours: number;
|
||||
slots: number;
|
||||
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">
|
||||
<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}
|
||||
<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>
|
||||
|
||||
{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}
|
||||
|
||||
{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}
|
||||
|
||||
{!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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user