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,40 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { slotKey } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||
import { hourRangeLabel } from '@/lib/time';
|
||||
import { isActive, statusLabel } from '@/lib/planning';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const LEAD_SLOT = slotKey('BAR', 'ven', 'S');
|
||||
type Presence = Record<string, 'present' | 'absent'>;
|
||||
|
||||
export function MobileLeadPage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const positions = usePositions();
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
// Poste de démonstration : le premier ; créneau le plus fourni.
|
||||
const position = (positions.data ?? [])[0];
|
||||
const positionShifts = useMemo(
|
||||
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
|
||||
[shifts.data, position?.id],
|
||||
);
|
||||
const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0];
|
||||
const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined);
|
||||
|
||||
const volunteersById = useMemo(
|
||||
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
||||
[volunteers.data],
|
||||
);
|
||||
|
||||
const position = state.positions.find((p) => p.id === 'BAR')!;
|
||||
const leadDemo = state.volunteers[1];
|
||||
if (positions.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!position || !shift) return null;
|
||||
|
||||
const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => {
|
||||
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!;
|
||||
const presence = state.presence[assignment.id];
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
name: volunteer.fullName,
|
||||
initials: volunteer.initials,
|
||||
phone: volunteer.phone,
|
||||
status:
|
||||
assignment.status === 'CONFIRMED'
|
||||
? t('mobileLead.statusConfirmed')
|
||||
: t('mobileLead.statusProposed'),
|
||||
present: presence === 'present',
|
||||
absent: presence === 'absent',
|
||||
};
|
||||
});
|
||||
const team = (shiftAssignments.data ?? [])
|
||||
.filter(isActive)
|
||||
.map((a) => {
|
||||
const volunteer = volunteersById.get(a.volunteer_id);
|
||||
return {
|
||||
id: a.id,
|
||||
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
|
||||
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
|
||||
phone: volunteer?.phone ?? '',
|
||||
status: statusLabel(a.status),
|
||||
present: presence[a.id] === 'present',
|
||||
absent: presence[a.id] === 'absent',
|
||||
};
|
||||
});
|
||||
|
||||
const presentCount = team.filter((m) => m.present).length;
|
||||
const absentCount = team.filter((m) => m.absent).length;
|
||||
@@ -43,18 +57,18 @@ export function MobileLeadPage() {
|
||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||
<PhoneFrame time={t('mobileLead.statusTime')}>
|
||||
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">
|
||||
{t('mobileLead.kicker', { name: leadDemo.fullName })}
|
||||
</div>
|
||||
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
|
||||
{`${position.name} — ${position.zone}`.toUpperCase()}
|
||||
</div>
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||
{position.zone} · {position.location}
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex items-stretch gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
|
||||
{t('mobileLead.inProgress', {
|
||||
hours: hourRangeLabel(shift.starts_at, shift.ends_at),
|
||||
})}
|
||||
</div>
|
||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileLead.present', { present: presentCount, total: team.length })}
|
||||
@@ -81,7 +95,7 @@ export function MobileLeadPage() {
|
||||
{t('mobileLead.teamTitle')}
|
||||
</div>
|
||||
{team.map((member) => (
|
||||
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
|
||||
{member.initials}
|
||||
@@ -108,9 +122,7 @@ export function MobileLeadPage() {
|
||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
|
||||
}
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
|
||||
className={cx(
|
||||
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
|
||||
)}
|
||||
@@ -119,9 +131,7 @@ export function MobileLeadPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
|
||||
}
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
|
||||
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
|
||||
>
|
||||
{t('mobileLead.markAbsent')}
|
||||
@@ -129,9 +139,11 @@ export function MobileLeadPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.nextSlot')}
|
||||
</div>
|
||||
{team.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.emptyTeam')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user