Frontend : intégration du design Claude Design (6 écrans)
Portage du projet « Bénévoles Festival.dc.html » en implémentation statique pilotée par un jeu de démonstration déterministe (portage du générateur du design : 15 postes, 64 bénévoles, ~209 affectations). - Système visuel néo-brutaliste : palette + primitives Tailwind, polices Archivo / DM Mono, primitives dans components/ui.tsx. - Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel de publication. - État applicatif : PlanningProvider (reducer) — affecter / désaffecter / publier / confirmer / éditer besoins et bénévoles / import simulé / pointage. - Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés, alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole, 05 Mobile référent, 06 Configuration (4 sous-onglets). - i18n : aucun texte d'interface en dur, locales/fr.json. - Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans (npm run smoke). 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
5154a3c61b
commit
ac469f0cd1
@@ -1,18 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
// Coquille de mise en page. Le design définira la navigation, l'en-tête et
|
||||
// la palette ; ce composant sera remplacé lors de l'intégration du design.
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||
<header className="border-b border-slate-200 bg-white px-6 py-4">
|
||||
<h1 className="text-lg font-semibold">{t('app.title')}</h1>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { buildDiff } from '@/domain/publication';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const TABS = [
|
||||
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
|
||||
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
|
||||
{ to: '/publication', num: '03', labelKey: 'tab.publication' },
|
||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||
] as const;
|
||||
|
||||
export function AppShell() {
|
||||
const { state } = usePlanning();
|
||||
const diff = buildDiff(state);
|
||||
const hasDraft = diff.length > 0;
|
||||
|
||||
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}
|
||||
</div>
|
||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||
{t('header.meta', { count: state.volunteers.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex overflow-x-auto">
|
||||
{TABS.map((tab) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
|
||||
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
||||
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
||||
{hasDraft ? (
|
||||
<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 })}
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||
|
||||
import { AppLayout } from './AppLayout';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { AppShell } from './AppShell';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
import { PublicationPage } from '@/pages/PublicationPage';
|
||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||
|
||||
// Squelette de routage. Les écrans réels sont dérivés du design
|
||||
// (« Bénévoles Festival.dc.html ») et ajoutés au fil des étapes.
|
||||
export const router = createBrowserRouter([
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <AppLayout />,
|
||||
children: [{ index: true, element: <DashboardPage /> }],
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/grille" replace /> },
|
||||
{ path: 'grille', element: <GrillePage /> },
|
||||
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
||||
{ path: 'publication', element: <PublicationPage /> },
|
||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||
{ path: 'configuration', element: <ConfigPage /> },
|
||||
{ path: '*', element: <Navigate to="/grille" replace /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user