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,45 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||
import { isActive } from '@/lib/planning';
|
||||
import { ENV } from '@/lib/env';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
|
||||
|
||||
export function MobileVolunteerPage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const volunteers = useVolunteers();
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
|
||||
const me = useMemo(() => {
|
||||
const list = volunteers.data ?? [];
|
||||
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
|
||||
}, [volunteers.data]);
|
||||
|
||||
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
|
||||
const updateAssignment = useUpdateAssignment();
|
||||
|
||||
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 me = state.volunteers[0];
|
||||
const slots = (index.byVolunteer.get(me.id) ?? [])
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
|
||||
)
|
||||
.map((assignment) => {
|
||||
const position = state.positions.find((p) => p.id === assignment.positionId)!;
|
||||
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
|
||||
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
day: festivalDay(assignment.dayId).longLabel,
|
||||
hours: dayPart(assignment.dayPartCode).label,
|
||||
position: position.name.toUpperCase(),
|
||||
zone: position.zone,
|
||||
location: position.location,
|
||||
leadName: lead.fullName,
|
||||
leadPhone: lead.phone,
|
||||
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
|
||||
confirmed: assignment.status === 'CONFIRMED',
|
||||
};
|
||||
});
|
||||
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = volunteers.error ?? shifts.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!me) return null;
|
||||
|
||||
const toConfirm = slots.filter((s) => !s.confirmed).length;
|
||||
const totalHours = index.totalHours.get(me.id) ?? 0;
|
||||
const slots = (assignments.data ?? [])
|
||||
.filter(isActive)
|
||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
|
||||
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
|
||||
Boolean(entry.shift),
|
||||
)
|
||||
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
|
||||
|
||||
const totalHours = Math.round(
|
||||
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
|
||||
) / 10;
|
||||
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||
@@ -47,7 +56,7 @@ export function MobileVolunteerPage() {
|
||||
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
|
||||
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
|
||||
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
@@ -66,63 +75,52 @@ export function MobileVolunteerPage() {
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
||||
{slots.map((slot) => (
|
||||
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">{slot.day}</span>
|
||||
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
|
||||
{slot.hours}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
|
||||
{slot.position}
|
||||
{slots.map(({ assignment, shift }) => {
|
||||
const position = positionsById.get(shift.position_id);
|
||||
return (
|
||||
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">
|
||||
{longDayLabel(shift.starts_at)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
|
||||
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
|
||||
<div className="font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: slot.location })}
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||
{(position?.name ?? '').toUpperCase()}
|
||||
</div>
|
||||
<div className="font-mono text-[12px]">
|
||||
{t('mobileVol.lead', { name: slot.leadName })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[11px] flex gap-[9px]">
|
||||
<a
|
||||
href={`tel:${slot.leadPhoneRaw}`}
|
||||
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
|
||||
>
|
||||
{t('mobileVol.call', { phone: slot.leadPhone })}
|
||||
</a>
|
||||
{position?.location ? (
|
||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: position.location })}
|
||||
</div>
|
||||
) : null}
|
||||
{assignment.status === 'CONFIRMED' ? (
|
||||
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
|
||||
{t('mobileVol.confirmed')}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={updateAssignment.isPending}
|
||||
onClick={() =>
|
||||
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
|
||||
}
|
||||
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
|
||||
>
|
||||
{t('mobileVol.confirm')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{slot.confirmed ? (
|
||||
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
|
||||
{t('mobileVol.confirmed')}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
|
||||
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
|
||||
>
|
||||
{t('mobileVol.confirm')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slots.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileVol.noSlots')}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
|
||||
<div className="font-display text-[12px] font-black uppercase tracking-widest">
|
||||
{t('mobileVol.orgMessageTitle')}
|
||||
</div>
|
||||
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
|
||||
{t('mobileVol.orgMessageBody')}
|
||||
</p>
|
||||
<div className="mt-2 font-mono text-[10px] opacity-70">
|
||||
{t('mobileVol.orgMessageMeta')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user