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,7 +1,7 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { buildDiff } from '@/domain/publication';
|
||||
import { API_MODE } from '@/api';
|
||||
import { useAssignments, useVolunteers } from '@/api/hooks';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
@@ -15,19 +15,20 @@ const TABS = [
|
||||
] as const;
|
||||
|
||||
export function AppShell() {
|
||||
const { state } = usePlanning();
|
||||
const diff = buildDiff(state);
|
||||
const hasDraft = diff.length > 0;
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
|
||||
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
|
||||
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
|
||||
<div className="font-display text-[14px] font-black tracking-tight">
|
||||
{state.config.festivalName}
|
||||
{t('header.title')}
|
||||
</div>
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{t('header.meta', { count: state.volunteers.length })}
|
||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,15 +59,16 @@ export function AppShell() {
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
||||
{hasDraft ? (
|
||||
{API_MODE === 'mock' ? (
|
||||
<div className="border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider">
|
||||
{t('header.mockMode')}
|
||||
</div>
|
||||
) : null}
|
||||
{proposedCount > 0 ? (
|
||||
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow">
|
||||
{t('header.draft', { count: diff.length })}
|
||||
{t('header.proposed', { count: proposedCount })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-ink bg-green px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-white">
|
||||
{t('header.published')}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
|
||||
MB
|
||||
</div>
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
import { createContext, useContext, useMemo, useReducer, type ReactNode } from 'react';
|
||||
|
||||
import { DAY_PARTS, FESTIVAL_DAYS, availabilityKey, slotKey } from '@/domain/festival';
|
||||
import type {
|
||||
Assignment,
|
||||
EditionConfig,
|
||||
Position,
|
||||
RemovedAssignment,
|
||||
StaffingNeed,
|
||||
Volunteer,
|
||||
} from '@/domain/types';
|
||||
import { buildDemoDataset } from '@/mock/generate';
|
||||
|
||||
const DEFAULT_CONFIG: EditionConfig = {
|
||||
festivalName: "LES SONS D'AUTOMNE",
|
||||
datesLabel: '30–31 octobre 2026 (+ montage 29, démontage 1er nov.)',
|
||||
maxDailyHours: 8,
|
||||
minRestHours: 8,
|
||||
autoWarn: true,
|
||||
mailFrom: 'coordination@lessonsdautomne.fr',
|
||||
mailReplyTo: 'marie.bertin@lessonsdautomne.fr',
|
||||
mailSubject: 'Ton planning bénévole a été mis à jour',
|
||||
mailBody:
|
||||
"Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nMerci de confirmer chaque créneau depuis ton planning personnel.",
|
||||
reminderEnabled: true,
|
||||
reminderHours: 48,
|
||||
magicLinkDays: 30,
|
||||
smsEnabled: false,
|
||||
};
|
||||
|
||||
export interface PlanningState {
|
||||
positions: Position[];
|
||||
volunteers: Volunteer[];
|
||||
assignments: Assignment[];
|
||||
removed: RemovedAssignment[];
|
||||
config: EditionConfig;
|
||||
version: number;
|
||||
published: boolean;
|
||||
nextSeq: number;
|
||||
selectedSlot: string | null;
|
||||
presence: Record<string, 'present' | 'absent'>;
|
||||
importedCount: number;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'selectSlot'; slot: string | null }
|
||||
| { type: 'assign'; volunteerId: string }
|
||||
| { type: 'unassign'; assignmentId: string }
|
||||
| { type: 'publish' }
|
||||
| { type: 'confirmSlot'; assignmentId: string }
|
||||
| { type: 'setConfig'; patch: Partial<EditionConfig> }
|
||||
| { type: 'setNeed'; positionId: string; dayId: string; dayPartCode: string; which: 'min' | 'ideal'; delta: number }
|
||||
| { type: 'toggleSlot'; positionId: string; dayId: string; dayPartCode: string }
|
||||
| { type: 'editVolunteer'; id: string; field: 'fullName' | 'phone'; value: string }
|
||||
| { type: 'togglePreference'; id: string; category: Position['category'] }
|
||||
| { type: 'runImport' }
|
||||
| { type: 'setPresence'; assignmentId: string; value: 'present' | 'absent' };
|
||||
|
||||
function initState(): PlanningState {
|
||||
const demo = buildDemoDataset();
|
||||
return {
|
||||
positions: demo.positions,
|
||||
volunteers: demo.volunteers,
|
||||
assignments: demo.assignments,
|
||||
removed: demo.removed,
|
||||
config: DEFAULT_CONFIG,
|
||||
version: 6,
|
||||
published: false,
|
||||
nextSeq: demo.nextAssignmentSeq,
|
||||
selectedSlot: slotKey('PARN', 'ven', 'S'),
|
||||
presence: {},
|
||||
importedCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function withNeeds(position: Position, needs: StaffingNeed[]): Position {
|
||||
return { ...position, needs };
|
||||
}
|
||||
|
||||
function reducer(state: PlanningState, action: Action): PlanningState {
|
||||
switch (action.type) {
|
||||
case 'selectSlot':
|
||||
return { ...state, selectedSlot: action.slot };
|
||||
|
||||
case 'assign': {
|
||||
if (!state.selectedSlot) return state;
|
||||
const [positionId, dayId, dayPartCode] = state.selectedSlot.split('|');
|
||||
const assignment: Assignment = {
|
||||
id: `a${state.nextSeq}`,
|
||||
volunteerId: action.volunteerId,
|
||||
positionId,
|
||||
dayId,
|
||||
dayPartCode,
|
||||
status: 'PROPOSED',
|
||||
source: 'MANUAL',
|
||||
locked: false,
|
||||
pending: 'ADD',
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
assignments: [...state.assignments, assignment],
|
||||
nextSeq: state.nextSeq + 1,
|
||||
published: false,
|
||||
};
|
||||
}
|
||||
|
||||
case 'unassign': {
|
||||
const target = state.assignments.find((a) => a.id === action.assignmentId);
|
||||
if (!target) return state;
|
||||
const volunteer = state.volunteers.find((v) => v.id === target.volunteerId);
|
||||
const position = state.positions.find((p) => p.id === target.positionId);
|
||||
const day = FESTIVAL_DAYS.find((d) => d.id === target.dayId);
|
||||
const part = DAY_PARTS.find((d) => d.code === target.dayPartCode);
|
||||
const record: RemovedAssignment = {
|
||||
volunteerName: volunteer?.fullName ?? target.volunteerId,
|
||||
positionName: position?.name ?? target.positionId,
|
||||
zone: position?.zone ?? '',
|
||||
dayShortLabel: day?.shortLabel ?? target.dayId,
|
||||
dayPartLabel: part?.label ?? target.dayPartCode,
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
assignments: state.assignments.filter((a) => a.id !== action.assignmentId),
|
||||
removed: [...state.removed, record],
|
||||
published: false,
|
||||
};
|
||||
}
|
||||
|
||||
case 'publish':
|
||||
return {
|
||||
...state,
|
||||
assignments: state.assignments.map((a) => (a.pending ? { ...a, pending: null } : a)),
|
||||
removed: [],
|
||||
version: state.version + 1,
|
||||
published: true,
|
||||
};
|
||||
|
||||
case 'confirmSlot':
|
||||
return {
|
||||
...state,
|
||||
assignments: state.assignments.map((a) =>
|
||||
a.id === action.assignmentId ? { ...a, status: 'CONFIRMED' } : a,
|
||||
),
|
||||
};
|
||||
|
||||
case 'setConfig':
|
||||
return { ...state, config: { ...state.config, ...action.patch } };
|
||||
|
||||
case 'setNeed':
|
||||
return {
|
||||
...state,
|
||||
positions: state.positions.map((position) => {
|
||||
if (position.id !== action.positionId) return position;
|
||||
const needs = position.needs.map((need) => {
|
||||
if (need.dayId !== action.dayId || need.dayPartCode !== action.dayPartCode) return need;
|
||||
let { minStaff, idealStaff } = need;
|
||||
if (action.which === 'min') {
|
||||
minStaff = Math.max(0, Math.min(12, minStaff + action.delta));
|
||||
} else {
|
||||
idealStaff = Math.max(0, Math.min(14, idealStaff + action.delta));
|
||||
}
|
||||
if (idealStaff < minStaff) {
|
||||
if (action.which === 'min') idealStaff = minStaff;
|
||||
else minStaff = idealStaff;
|
||||
}
|
||||
return { ...need, minStaff, idealStaff };
|
||||
});
|
||||
return withNeeds(position, needs);
|
||||
}),
|
||||
};
|
||||
|
||||
case 'toggleSlot':
|
||||
return {
|
||||
...state,
|
||||
positions: state.positions.map((position) => {
|
||||
if (position.id !== action.positionId) return position;
|
||||
const exists = position.needs.some(
|
||||
(n) => n.dayId === action.dayId && n.dayPartCode === action.dayPartCode,
|
||||
);
|
||||
let needs: StaffingNeed[];
|
||||
if (exists) {
|
||||
needs = position.needs.filter(
|
||||
(n) => !(n.dayId === action.dayId && n.dayPartCode === action.dayPartCode),
|
||||
);
|
||||
} else {
|
||||
needs = [
|
||||
...position.needs,
|
||||
{ dayId: action.dayId, dayPartCode: action.dayPartCode, minStaff: 2, idealStaff: 3 },
|
||||
];
|
||||
}
|
||||
const order = DAY_PARTS.map((d) => d.code);
|
||||
needs = [...needs].sort((a, b) => {
|
||||
if (a.dayId !== b.dayId) {
|
||||
return (
|
||||
FESTIVAL_DAYS.findIndex((d) => d.id === a.dayId) -
|
||||
FESTIVAL_DAYS.findIndex((d) => d.id === b.dayId)
|
||||
);
|
||||
}
|
||||
return order.indexOf(a.dayPartCode) - order.indexOf(b.dayPartCode);
|
||||
});
|
||||
return withNeeds(position, needs);
|
||||
}),
|
||||
};
|
||||
|
||||
case 'editVolunteer':
|
||||
return {
|
||||
...state,
|
||||
volunteers: state.volunteers.map((v) => {
|
||||
if (v.id !== action.id) return v;
|
||||
if (action.field === 'phone') return { ...v, phone: action.value };
|
||||
const parts = action.value.trim().split(/\s+/);
|
||||
const firstName = parts[0] ?? '';
|
||||
const lastName = parts.slice(1).join(' ');
|
||||
return {
|
||||
...v,
|
||||
fullName: action.value,
|
||||
firstName,
|
||||
lastName,
|
||||
initials: (firstName[0] ?? '') + (lastName[0] ?? ''),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
case 'togglePreference':
|
||||
return {
|
||||
...state,
|
||||
volunteers: state.volunteers.map((v) => {
|
||||
if (v.id !== action.id) return v;
|
||||
const has = v.preferredCategories.includes(action.category);
|
||||
return {
|
||||
...v,
|
||||
preferredCategories: has
|
||||
? v.preferredCategories.filter((c) => c !== action.category)
|
||||
: [...v.preferredCategories, action.category],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
case 'runImport': {
|
||||
const base = state.volunteers.length;
|
||||
const names: Array<[string, string]> = [
|
||||
['Océane', 'Thibault'],
|
||||
['Samir', 'Amrani'],
|
||||
['Lucie', 'Charpentier'],
|
||||
['Noé', 'Delaunay'],
|
||||
];
|
||||
const added: Volunteer[] = names.map(([firstName, lastName], i) => {
|
||||
const availability = new Set<string>();
|
||||
FESTIVAL_DAYS.forEach((day) =>
|
||||
DAY_PARTS.forEach((part) => {
|
||||
if ((i + day.id.length + part.startHour) % 3) {
|
||||
availability.add(availabilityKey(day.id, part.code));
|
||||
}
|
||||
}),
|
||||
);
|
||||
const categories = ['BAR', 'TICKETING', 'WELCOME', 'PARKING', 'CLEANING', 'CATERING', 'SETUP', 'STAGE'] as const;
|
||||
return {
|
||||
id: `v${base + i}`,
|
||||
firstName,
|
||||
lastName,
|
||||
fullName: `${firstName} ${lastName}`,
|
||||
initials: firstName[0] + lastName[0],
|
||||
phone: `06 74 12 0${i} ${30 + i}`,
|
||||
availability,
|
||||
preferredCategories: [categories[i % categories.length]],
|
||||
imported: true,
|
||||
neverAssigned: true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
volunteers: [...state.volunteers, ...added],
|
||||
importedCount: state.importedCount + added.length,
|
||||
};
|
||||
}
|
||||
|
||||
case 'setPresence':
|
||||
return {
|
||||
...state,
|
||||
presence: { ...state.presence, [action.assignmentId]: action.value },
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface PlanningContextValue {
|
||||
state: PlanningState;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}
|
||||
|
||||
const PlanningContext = createContext<PlanningContextValue | null>(null);
|
||||
|
||||
export function PlanningProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, undefined, initState);
|
||||
const value = useMemo(() => ({ state, dispatch }), [state]);
|
||||
return <PlanningContext.Provider value={value}>{children}</PlanningContext.Provider>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- hook colocalisé avec son provider
|
||||
export function usePlanning(): PlanningContextValue {
|
||||
const ctx = useContext(PlanningContext);
|
||||
if (!ctx) throw new Error('usePlanning doit être utilisé dans <PlanningProvider>');
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user