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:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent ac469f0cd1
commit c9dda65361
38 changed files with 2930 additions and 2414 deletions
-42
View File
@@ -1,42 +0,0 @@
// Client HTTP minimal. En dev, Vite proxifie `/api` vers le backend (:8000).
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(`API ${status}`);
this.name = 'ApiError';
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
credentials: 'include',
});
const isJson = res.headers.get('content-type')?.includes('application/json');
const body = isJson ? await res.json() : await res.text();
if (!res.ok) {
throw new ApiError(res.status, body);
}
return body as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'POST', body: data === undefined ? undefined : JSON.stringify(data) }),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PATCH', body: data === undefined ? undefined : JSON.stringify(data) }),
put: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PUT', body: data === undefined ? undefined : JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
+213
View File
@@ -0,0 +1,213 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from '@tanstack/react-query';
import { EDITION_ID, api } from './index';
import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
PositionCreate,
PositionUpdate,
ShiftCreate,
ShiftUpdate,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
const edition = EDITION_ID;
export const queryKeys = {
volunteers: ['volunteers', edition] as const,
positions: ['positions', edition] as const,
shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const,
assignments: (params?: { volunteerId?: string; shiftId?: string }) =>
['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const,
matrix: ['matrix', edition] as const,
validate: (volunteerId: string, shiftId: string) =>
['validate', edition, volunteerId, shiftId] as const,
};
// --------------------------------------------------------------------------- //
// Lectures //
// --------------------------------------------------------------------------- //
export function useVolunteers() {
return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) });
}
export function usePositions() {
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
}
export function useShifts(positionId?: string) {
return useQuery({
queryKey: queryKeys.shifts(positionId),
queryFn: () => api.shifts.list(edition, positionId ? { positionId } : undefined),
});
}
export function useAssignments(params?: { volunteerId?: string; shiftId?: string }) {
return useQuery({
queryKey: queryKeys.assignments(params),
queryFn: () => api.assignments.list(edition, params),
});
}
export function useMatrix() {
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
}
export function useValidateAssignment(
volunteerId: string,
shiftId: string,
options?: Partial<UseQueryOptions<AssignmentValidateResult>>,
) {
return useQuery({
queryKey: queryKeys.validate(volunteerId, shiftId),
queryFn: () => api.assignments.validate(edition, { volunteer_id: volunteerId, shift_id: shiftId }),
staleTime: 60_000,
...options,
});
}
// --------------------------------------------------------------------------- //
// Écritures //
// --------------------------------------------------------------------------- //
function useInvalidate() {
const client = useQueryClient();
return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
planning: () => {
client.invalidateQueries({ queryKey: ['assignments', edition] });
client.invalidateQueries({ queryKey: ['matrix', edition] });
client.invalidateQueries({ queryKey: ['validate', edition] });
},
};
}
export function useCreateVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: VolunteerCreate) => api.volunteers.create(edition, body),
onSuccess: invalidate.volunteers,
});
}
export function useUpdateVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: VolunteerUpdate }) =>
api.volunteers.update(edition, id, body),
onSuccess: invalidate.volunteers,
});
}
export function useDeleteVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.volunteers.remove(edition, id),
onSuccess: () => {
invalidate.volunteers();
invalidate.planning();
},
});
}
export function useCreatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: PositionCreate) => api.positions.create(edition, body),
onSuccess: () => {
invalidate.positions();
invalidate.planning();
},
});
}
export function useUpdatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: PositionUpdate }) =>
api.positions.update(edition, id, body),
onSuccess: () => {
invalidate.positions();
invalidate.planning();
},
});
}
export function useDeletePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.positions.remove(edition, id),
onSuccess: () => {
invalidate.positions();
invalidate.shifts();
invalidate.planning();
},
});
}
export function useCreateShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: ShiftCreate) => api.shifts.create(edition, body),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useUpdateShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ShiftUpdate }) =>
api.shifts.update(edition, id, body),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useDeleteShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.shifts.remove(edition, id),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useCreateAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: AssignmentCreate) => api.assignments.create(edition, body),
onSuccess: invalidate.planning,
});
}
export function useUpdateAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AssignmentUpdate }) =>
api.assignments.update(edition, id, body),
onSuccess: invalidate.planning,
});
}
export function useDeleteAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.assignments.remove(edition, id),
onSuccess: invalidate.planning,
});
}
+113
View File
@@ -0,0 +1,113 @@
import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
PositionCreate,
PositionUpdate,
ShiftCreate,
ShiftUpdate,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
import { ApiError, type PlanningApi } from './types';
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
credentials: 'include',
});
if (res.status === 204) return undefined as T;
const isJson = res.headers.get('content-type')?.includes('application/json');
const body: unknown = isJson ? await res.json() : await res.text();
if (!res.ok) {
const detail =
body && typeof body === 'object' && 'detail' in body
? (body as { detail: unknown }).detail
: body;
const code =
detail && typeof detail === 'object' && 'code' in detail
? String((detail as { code: unknown }).code)
: 'error.generic';
const violations =
detail && typeof detail === 'object' && 'violations' in detail
? ((detail as { violations: [] }).violations ?? [])
: [];
throw new ApiError(res.status, code, violations);
}
return body as T;
}
const query = (params: Record<string, string | undefined>) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null) search.set(key, value);
}
const str = search.toString();
return str ? `?${str}` : '';
};
const base = (editionId: string) => `/editions/${editionId}`;
return {
volunteers: {
list: (editionId) => request(`${base(editionId)}/volunteers`),
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
create: (editionId, body: VolunteerCreate) =>
request(`${base(editionId)}/volunteers`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: VolunteerUpdate) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
},
positions: {
list: (editionId) => request(`${base(editionId)}/positions`),
get: (editionId, id) => request(`${base(editionId)}/positions/${id}`),
create: (editionId, body: PositionCreate) =>
request(`${base(editionId)}/positions`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: PositionUpdate) =>
request(`${base(editionId)}/positions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/positions/${id}`, { method: 'DELETE' }),
},
shifts: {
list: (editionId, params) =>
request(`${base(editionId)}/shifts${query({ position_id: params?.positionId })}`),
get: (editionId, id) => request(`${base(editionId)}/shifts/${id}`),
create: (editionId, body: ShiftCreate) =>
request(`${base(editionId)}/shifts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: ShiftUpdate) =>
request(`${base(editionId)}/shifts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => request(`${base(editionId)}/shifts/${id}`, { method: 'DELETE' }),
},
assignments: {
list: (editionId, params) =>
request(
`${base(editionId)}/assignments${query({
volunteer_id: params?.volunteerId,
shift_id: params?.shiftId,
})}`,
),
get: (editionId, id) => request(`${base(editionId)}/assignments/${id}`),
create: (editionId, body: AssignmentCreate) =>
request(`${base(editionId)}/assignments`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AssignmentUpdate) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
};
}
+19
View File
@@ -0,0 +1,19 @@
import { ENV } from '@/lib/env';
import { createHttpApi } from './http-client';
import { MOCK_EDITION_ID } from './mock/dataset';
import { createMockApi } from './mock/mock-client';
import type { PlanningApi } from './types';
// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http`
// (back-end réel). En mode http, `VITE_EDITION_ID` doit désigner une édition.
export const API_MODE = ENV.apiMode;
export const EDITION_ID: string = ENV.editionId ?? MOCK_EDITION_ID;
export const api: PlanningApi =
API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi();
export { ApiError } from './types';
export type { PlanningApi } from './types';
export * from './schema';
+293
View File
@@ -0,0 +1,293 @@
import { hoursBetween } from '@/lib/time';
import type {
AssignmentRead,
AssignmentStatus,
PositionCategory,
PositionRead,
ShiftRead,
VolunteerRead,
} from '../schema';
// Jeu de démonstration déterministe, mis en forme comme les réponses de l'API.
// Sert quand `VITE_API_MODE=mock` (défaut tant que le back-end n'a pas de seed).
export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001';
export interface MockState {
volunteers: VolunteerRead[];
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
availability: Map<string, Set<string>>;
seq: number;
}
const DAY_DATES = ['2026-10-15', '2026-10-16', '2026-10-17', '2026-10-18'];
const PART_BOUNDS: Array<[string, number, number]> = [
['M', 8, 12],
['A', 12, 17],
['S', 17, 22],
['N', 22, 26], // 26 => 02 h le lendemain
];
type RawSlots = Record<string, Array<[string, number, number]>>;
interface RawPosition {
id: string;
category: PositionCategory;
name: string;
location: string;
requiredSkills?: string[];
slots: RawSlots;
}
const RAW_POSITIONS: RawPosition[] = [
{ id: 'BAR', category: 'BAR', name: 'Bar — Grande scène', location: 'Chapiteau nord', slots: { '2026-10-16': [['A', 3, 4], ['S', 4, 6], ['N', 3, 5]], '2026-10-17': [['A', 3, 4], ['S', 5, 6], ['N', 4, 5]], '2026-10-18': [['M', 2, 3]] } },
{ id: 'BARV', category: 'BAR', name: 'Bar — Village assos', location: 'Village est', slots: { '2026-10-16': [['A', 2, 3], ['S', 3, 4]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 3]], '2026-10-18': [['M', 2, 2]] } },
{ id: 'BILA', category: 'TICKETING', name: 'Billetterie — Entrée A', location: 'Portail est', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 4, 5], ['S', 3, 4]], '2026-10-17': [['A', 4, 5], ['S', 3, 4]] } },
{ id: 'BILB', category: 'TICKETING', name: 'Billetterie — Entrée B', location: 'Portail ouest', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 2, 3]] } },
{ id: 'ACCB', category: 'WELCOME', name: 'Accueil — Bénévoles', location: 'Base bénévoles', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-18': [['M', 2, 2]] } },
{ id: 'ACCA', category: 'WELCOME', name: 'Accueil — Artistes', location: 'Loges', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } },
{ id: 'PARN', category: 'PARKING', name: 'Parking — Nord', location: 'Route de Bréal', slots: { '2026-10-16': [['A', 3, 4], ['S', 3, 4], ['N', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 4, 5], ['N', 2, 3]] } },
{ id: 'PARS', category: 'PARKING', name: 'Parking — Sud', location: 'Chemin des Prés', slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 2]] } },
{ id: 'PROS', category: 'CLEANING', name: 'Propreté — Site', location: 'Ensemble du site', slots: { '2026-10-16': [['S', 2, 3], ['N', 3, 4]], '2026-10-17': [['M', 3, 4], ['S', 2, 3], ['N', 3, 5]], '2026-10-18': [['M', 4, 6], ['A', 4, 6]] } },
{ id: 'PROA', category: 'CLEANING', name: 'Propreté — Sanitaires', location: 'Blocs 1 à 4', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['M', 2, 2], ['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } },
{ id: 'CATB', category: 'CATERING', name: 'Catering — Bénévoles', location: 'Tente cuisine', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-17': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-18': [['M', 2, 3], ['A', 2, 2]] } },
{ id: 'CATA', category: 'CATERING', name: 'Catering — Artistes', location: 'Backstage', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } },
{ id: 'MONS', category: 'SETUP', name: 'Montage — Scène', location: 'Grande scène', requiredSkills: ['CARISTE'], slots: { '2026-10-15': [['M', 6, 8], ['A', 6, 8], ['S', 4, 5]], '2026-10-18': [['A', 6, 8], ['S', 4, 6]] } },
{ id: 'MONI', category: 'SETUP', name: 'Montage — Signalétique', location: 'Site entier', slots: { '2026-10-15': [['M', 3, 4], ['A', 3, 4]], '2026-10-18': [['A', 3, 4]] } },
{ id: 'REG', category: 'STAGE', name: 'Régie — Régie centrale', location: 'Cabane régie', requiredSkills: ['REGIE'], slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } },
];
const FIRST_NAMES = [
'Camille', 'Yanis', 'Léa', 'Mathis', 'Soraya', 'Thomas', 'Awa', 'Julien', 'Chloé', 'Nadia',
'Hugo', 'Inès', 'Baptiste', 'Manon', 'Karim', 'Élodie', 'Rémi', 'Sarah', 'Antoine', 'Fanny',
'Malik', 'Clara', 'Victor', 'Jeanne',
];
const LAST_NAMES = [
'Fournier', 'Berger', 'Moreau', 'Lambert', 'Benali', 'Rey', 'Diallo', 'Chevalier', 'Marchand',
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
'Renard', 'Dupuy',
];
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
const NOW = '2026-10-10T09:00:00';
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
const OVERLOADED = [3, 9];
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function iso(dateStr: string, hour: number): string {
if (hour >= 24) {
const [y, m, d] = dateStr.split('-').map(Number);
const next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
return `${next}T${String(hour - 24).padStart(2, '0')}:00:00`;
}
return `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
}
function rollStatus(roll: number): AssignmentStatus {
if (roll < 0.72) return 'CONFIRMED';
if (roll < 0.95) return 'PROPOSED';
return 'DECLINED';
}
function demoEmail(firstName: string, lastName: string): string {
const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
return `${n(firstName)}.${n(lastName)}@mail.fr`;
}
export function buildMockState(): MockState {
const rnd = mulberry32(20260715);
const edition_id = MOCK_EDITION_ID;
const positions: PositionRead[] = RAW_POSITIONS.map((p) => ({
id: p.id,
edition_id,
name: p.name,
location: p.location,
category: p.category,
lead_account_id: null,
created_at: NOW,
updated_at: NOW,
}));
const shifts: ShiftRead[] = [];
let shiftSeq = 1;
for (const raw of RAW_POSITIONS) {
for (const [dateStr, slots] of Object.entries(raw.slots)) {
for (const [code, min_staff, ideal_staff] of slots) {
const bounds = PART_BOUNDS.find((b) => b[0] === code)!;
shifts.push({
id: `s${shiftSeq}`,
edition_id,
position_id: raw.id,
starts_at: iso(dateStr, bounds[1]),
ends_at: iso(dateStr, bounds[2]),
min_staff,
ideal_staff,
required_skill_codes: raw.requiredSkills ?? [],
created_at: NOW,
updated_at: NOW,
});
shiftSeq += 1;
}
}
}
const volunteers: VolunteerRead[] = [];
const availability = new Map<string, Set<string>>();
for (let i = 0; i < 64; i += 1) {
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const id = `v${i}`;
const avail = new Set<string>();
for (const dateStr of DAY_DATES) {
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
for (const [, startHour] of PART_BOUNDS) {
if (rnd() < (edge ? 0.4 : 0.6)) {
avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`);
}
}
}
availability.set(id, avail);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : [];
volunteers.push({
id,
edition_id,
first_name,
last_name,
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
tshirt_size: (['S', 'M', 'L', 'XL'] as const)[Math.floor(rnd() * 4)],
diet: rnd() < 0.15 ? 'végétarien' : null,
comment: null,
skill_codes,
created_at: NOW,
updated_at: NOW,
});
}
const me = volunteers[0];
availability.set(
me.id,
new Set(
DAY_DATES.flatMap((dateStr) =>
PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`),
),
),
);
NEVER_ASSIGNED.forEach((i) => {
volunteers[i].comment = 'Inscrit, pas encore affecté';
});
const assignments: AssignmentRead[] = [];
const busy = new Set<string>();
const dayHours = new Map<string, number>();
const totalHours = new Map<string, number>();
let seq = 1;
let slotIndex = 0;
for (const shift of shifts) {
slotIndex += 1;
let target = shift.ideal_staff;
if (slotIndex % 7 === 0) target = Math.max(1, shift.min_staff - 1);
else if (slotIndex % 5 === 0) target = shift.min_staff;
else if (slotIndex % 3 === 0) target = Math.max(shift.min_staff, shift.ideal_staff - 1);
const duration = hoursBetween(shift.starts_at, shift.ends_at);
const dateStr = shift.starts_at.slice(0, 10);
const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`;
const pool = volunteers
.filter(
(v, idx) =>
!NEVER_ASSIGNED.has(idx) &&
!busy.has(`${v.id}|${shift.id}`) &&
(dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration <= 8,
)
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
const available = availability.get(v.id)?.has(availKey) ? 100 : 0;
const skilled =
shift.required_skill_codes.length > 0 &&
shift.required_skill_codes.every((c) => v.skill_codes.includes(c))
? 40
: 0;
return available + skilled - (totalHours.get(v.id) ?? 0) * 3;
}
for (const v of pool.slice(0, target)) {
const status = rollStatus(rnd());
assignments.push({
id: `a${seq}`,
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
status,
source: 'SOLVER',
locked: false,
created_at: NOW,
updated_at: NOW,
});
seq += 1;
if (status !== 'DECLINED') {
busy.add(`${v.id}|${shift.id}`);
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
totalHours.set(v.id, (totalHours.get(v.id) ?? 0) + duration);
}
}
}
assignments
.filter((a) => a.volunteer_id === me.id)
.forEach((a, i) => {
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
});
OVERLOADED.forEach((index) => {
const v = volunteers[index];
const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17');
const shift = friday.find(
(s) =>
!assignments.some(
(a) =>
a.volunteer_id === v.id &&
shiftsOverlap(shifts.find((x) => x.id === a.shift_id), s),
),
);
if (!shift) return;
assignments.push({
id: `a${seq}`,
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
status: 'CONFIRMED',
source: 'MANUAL',
locked: false,
created_at: NOW,
updated_at: NOW,
});
seq += 1;
});
return { volunteers, positions, shifts, assignments, availability, seq };
}
function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
if (!a) return false;
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
+427
View File
@@ -0,0 +1,427 @@
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
AssignmentRead,
AssignmentValidateResult,
MatrixResponse,
ShiftRead,
Violation,
} from '../schema';
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockState } from './dataset';
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
// comportement (chargements, invalidations) proche du back-end réel.
const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60;
let state: MockState = buildMockState();
/** Réinitialise le jeu de démonstration (utile en test). */
export function resetMockState(): void {
state = buildMockState();
}
const delay = <T>(value: T): Promise<T> =>
new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS));
const notFound = (): never => {
throw new ApiError(404, 'error.not_found');
};
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
function shiftById(id: string): ShiftRead {
const shift = state.shifts.find((s) => s.id === id);
if (!shift) return notFound();
return shift;
}
function overlaps(a: ShiftRead, b: ShiftRead): boolean {
return a.starts_at < b.ends_at && b.starts_at < a.ends_at;
}
/** Portage frontend du moteur (`backend/app/validation/engine.py`). */
function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignmentId?: string): Violation[] {
const volunteer = state.volunteers.find((v) => v.id === volunteerId);
if (!volunteer) return notFound();
const existing = state.assignments
.filter(
(a) =>
a.volunteer_id === volunteerId &&
a.id !== excludeAssignmentId &&
a.shift_id !== candidate.id &&
ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
)
.map((a) => shiftById(a.shift_id));
const violations: Violation[] = [];
// SHIFT_OVERLAP (bloquante)
for (const other of existing) {
if (overlaps(candidate, other)) {
violations.push({
code: 'SHIFT_OVERLAP',
severity: 'BLOCKING',
params: {
other_shift_id: other.id,
other_starts_at: other.starts_at,
other_ends_at: other.ends_at,
},
});
}
}
// OUTSIDE_AVAILABILITY (avertissement) — le mock ne connaît que AVAILABLE / non déclaré.
const declared = state.availability.get(volunteerId) ?? new Set<string>();
const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`;
if (!declared.has(availKey)) {
violations.push({
code: 'OUTSIDE_AVAILABILITY',
severity: 'WARNING',
params: {
date: candidate.starts_at.slice(0, 10),
day_part_code: candidate.starts_at.slice(11, 16),
status: 'UNDECLARED',
},
});
}
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
const dayKey = candidate.starts_at.slice(0, 10);
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
for (const other of existing) {
if (other.starts_at.slice(0, 10) === dayKey) {
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
}
}
if (totalMin > EIGHT_HOURS_MIN) {
violations.push({
code: 'DAILY_HOURS_EXCEEDED',
severity: 'WARNING',
params: { date: dayKey, total_minutes: totalMin },
});
}
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
for (const other of existing) {
if (overlaps(candidate, other)) continue;
let gap: number;
let direction: 'BEFORE' | 'AFTER';
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
direction = 'BEFORE';
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
direction = 'AFTER';
} else {
continue;
}
if (gap < EIGHT_HOURS_MIN) {
violations.push({
code: 'INSUFFICIENT_REST',
severity: 'WARNING',
params: { other_shift_id: other.id, direction, gap_minutes: gap },
});
}
}
// MISSING_REQUIRED_SKILL (avertissement)
for (const code of [...candidate.required_skill_codes].sort()) {
if (!volunteer.skill_codes.includes(code)) {
violations.push({
code: 'MISSING_REQUIRED_SKILL',
severity: 'WARNING',
params: { skill_code: code },
});
}
}
return violations;
}
function hasBlocking(violations: Violation[]): boolean {
return violations.some((v) => v.severity === 'BLOCKING');
}
function buildMatrix(): MatrixResponse {
const counts = new Map<string, { confirmed: number; proposed: number }>();
for (const a of state.assignments) {
const entry = counts.get(a.shift_id) ?? { confirmed: 0, proposed: 0 };
if (a.status === 'CONFIRMED') entry.confirmed += 1;
if (a.status === 'PROPOSED') entry.proposed += 1;
counts.set(a.shift_id, entry);
}
return {
edition_id: state.positions[0]?.edition_id ?? '',
positions: [...state.positions]
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
.map((position) => ({
id: position.id,
name: position.name,
category: position.category,
location: position.location,
shifts: state.shifts
.filter((s) => s.position_id === position.id)
.sort((a, b) => a.starts_at.localeCompare(b.starts_at))
.map((s) => {
const c = counts.get(s.id) ?? { confirmed: 0, proposed: 0 };
return {
shift_id: s.id,
starts_at: s.starts_at,
ends_at: s.ends_at,
min_staff: s.min_staff,
ideal_staff: s.ideal_staff,
confirmed: c.confirmed,
proposed: c.proposed,
};
}),
})),
};
}
export function createMockApi(): PlanningApi {
return {
volunteers: {
list: () =>
delay(
[...state.volunteers].sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') ||
a.first_name.localeCompare(b.first_name, 'fr'),
),
),
get: (_edition, id) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
return delay(v);
},
create: (_edition, body) => {
const now = touch();
const volunteer = {
id: `v${state.seq++}`,
edition_id: state.positions[0].edition_id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email ?? null,
phone: body.phone ?? null,
tshirt_size: body.tshirt_size ?? null,
diet: body.diet ?? null,
comment: body.comment ?? null,
skill_codes: body.skill_codes ?? [],
created_at: now,
updated_at: now,
};
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, new Set());
return delay(volunteer);
},
update: (_edition, id, body) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
Object.assign(v, {
...('first_name' in body ? { first_name: body.first_name } : {}),
...('last_name' in body ? { last_name: body.last_name } : {}),
...('email' in body ? { email: body.email ?? null } : {}),
...('phone' in body ? { phone: body.phone ?? null } : {}),
...('tshirt_size' in body ? { tshirt_size: body.tshirt_size ?? null } : {}),
...('diet' in body ? { diet: body.diet ?? null } : {}),
...('comment' in body ? { comment: body.comment ?? null } : {}),
...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}),
updated_at: touch(),
});
return delay(v);
},
remove: (_edition, id) => {
state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id);
return delay(undefined);
},
},
positions: {
list: () =>
delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))),
get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => {
const now = touch();
const position = {
id: `p${state.seq++}`,
edition_id: state.positions[0].edition_id,
name: body.name,
location: body.location ?? null,
category: body.category,
lead_account_id: body.lead_account_id ?? null,
created_at: now,
updated_at: now,
};
state.positions.push(position);
return delay(position);
},
update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound();
Object.assign(p, {
...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}),
...('category' in body ? { category: body.category } : {}),
...('lead_account_id' in body ? { lead_account_id: body.lead_account_id ?? null } : {}),
updated_at: touch(),
});
return delay(p);
},
remove: (_edition, id) => {
const shiftIds = new Set(state.shifts.filter((s) => s.position_id === id).map((s) => s.id));
state.positions = state.positions.filter((x) => x.id !== id);
state.shifts = state.shifts.filter((s) => s.position_id !== id);
state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_id));
return delay(undefined);
},
},
shifts: {
list: (_edition, params) =>
delay(
[...state.shifts]
.filter((s) => !params?.positionId || s.position_id === params.positionId)
.sort((a, b) => a.starts_at.localeCompare(b.starts_at)),
),
get: (_edition, id) => delay(shiftById(id)),
create: (_edition, body) => {
if (naiveMinutes(body.ends_at) <= naiveMinutes(body.starts_at)) {
throw new ApiError(422, 'shift.ends_not_after_starts');
}
if (!state.positions.some((p) => p.id === body.position_id)) {
throw new ApiError(422, 'shift.position_wrong_edition');
}
const now = touch();
const shift: ShiftRead = {
id: `s${state.seq++}`,
edition_id: state.positions[0].edition_id,
position_id: body.position_id,
starts_at: body.starts_at,
ends_at: body.ends_at,
min_staff: body.min_staff ?? 0,
ideal_staff: body.ideal_staff ?? 0,
required_skill_codes: body.required_skill_codes ?? [],
created_at: now,
updated_at: now,
};
state.shifts.push(shift);
return delay(shift);
},
update: (_edition, id, body) => {
const s = shiftById(id);
Object.assign(s, {
...('position_id' in body ? { position_id: body.position_id } : {}),
...('starts_at' in body ? { starts_at: body.starts_at } : {}),
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}),
...(body.required_skill_codes != null
? { required_skill_codes: body.required_skill_codes }
: {}),
updated_at: touch(),
});
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {
throw new ApiError(422, 'shift.ends_not_after_starts');
}
return delay(s);
},
remove: (_edition, id) => {
state.shifts = state.shifts.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.shift_id !== id);
return delay(undefined);
},
},
assignments: {
list: (_edition, params) =>
delay(
state.assignments.filter(
(a) =>
(!params?.volunteerId || a.volunteer_id === params.volunteerId) &&
(!params?.shiftId || a.shift_id === params.shiftId),
),
),
get: (_edition, id) => delay(state.assignments.find((x) => x.id === id) ?? notFound()),
create: (_edition, body) => {
const shift = shiftById(body.shift_id);
if (!state.volunteers.some((v) => v.id === body.volunteer_id)) {
throw new ApiError(422, 'assignment.wrong_edition');
}
if (
state.assignments.some(
(a) => a.volunteer_id === body.volunteer_id && a.shift_id === body.shift_id,
)
) {
throw new ApiError(409, 'assignment.duplicate');
}
const status = body.status ?? 'PROPOSED';
let warnings: Violation[] = [];
if (ACTIVE_ASSIGNMENT_STATUSES.includes(status)) {
warnings = runValidation(body.volunteer_id, shift);
if (hasBlocking(warnings)) {
throw new ApiError(422, 'error.validation_failed', warnings);
}
}
const now = touch();
const assignment: AssignmentRead = {
id: `a${state.seq++}`,
edition_id: shift.edition_id,
volunteer_id: body.volunteer_id,
shift_id: body.shift_id,
status,
source: body.source ?? 'MANUAL',
locked: body.locked ?? false,
created_at: now,
updated_at: now,
};
state.assignments.push(assignment);
return delay({ assignment, warnings });
},
update: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
Object.assign(a, {
...('status' in body ? { status: body.status } : {}),
...('source' in body ? { source: body.source } : {}),
...('locked' in body ? { locked: body.locked } : {}),
updated_at: touch(),
});
let warnings: Violation[] = [];
if (ACTIVE_ASSIGNMENT_STATUSES.includes(a.status)) {
warnings = runValidation(a.volunteer_id, shiftById(a.shift_id), a.id);
if (hasBlocking(warnings)) {
throw new ApiError(422, 'error.validation_failed', warnings);
}
}
return delay({ assignment: a, warnings });
},
remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id);
return delay(undefined);
},
validate: (_edition, body): Promise<AssignmentValidateResult> => {
const violations = runValidation(body.volunteer_id, shiftById(body.shift_id));
return delay({ blocking: hasBlocking(violations), violations });
},
},
matrix: {
get: () => delay(buildMatrix()),
},
};
}
/** Heures actives d'un bénévole (utilitaire mock, exposé pour les tests). */
export function mockVolunteerHours(volunteerId: string): number {
return state.assignments
.filter(
(a) => a.volunteer_id === volunteerId && ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
)
.reduce((sum, a) => {
const s = state.shifts.find((x) => x.id === a.shift_id);
return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum;
}, 0);
}
+210
View File
@@ -0,0 +1,210 @@
// Types miroir des schémas de l'API back-end (`backend/app/api/schemas.py`).
// Toute évolution du contrat doit être répercutée ici.
export type PositionCategory =
| 'BAR'
| 'TICKETING'
| 'WELCOME'
| 'PARKING'
| 'CLEANING'
| 'CATERING'
| 'SETUP'
| 'STAGE'
| 'OTHER';
export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED';
export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
export type ViolationCode =
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
| 'DAILY_HOURS_EXCEEDED'
| 'INSUFFICIENT_REST'
| 'MISSING_REQUIRED_SKILL';
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
export interface Violation {
code: ViolationCode;
severity: ViolationSeverity;
params: Record<string, string | number>;
}
// --------------------------------------------------------------------------- //
// Volunteer //
// --------------------------------------------------------------------------- //
export interface VolunteerRead {
id: string;
edition_id: string;
first_name: string;
last_name: string;
email: string | null;
phone: string | null;
tshirt_size: TshirtSize | null;
diet: string | null;
comment: string | null;
skill_codes: string[];
created_at: string;
updated_at: string;
}
export interface VolunteerCreate {
first_name: string;
last_name: string;
email?: string | null;
phone?: string | null;
tshirt_size?: TshirtSize | null;
diet?: string | null;
comment?: string | null;
skill_codes?: string[];
}
export interface VolunteerUpdate {
first_name?: string;
last_name?: string;
email?: string | null;
phone?: string | null;
tshirt_size?: TshirtSize | null;
diet?: string | null;
comment?: string | null;
/** null / absent = inchangé ; [] = vider les compétences. */
skill_codes?: string[] | null;
}
// --------------------------------------------------------------------------- //
// Position //
// --------------------------------------------------------------------------- //
export interface PositionRead {
id: string;
edition_id: string;
name: string;
location: string | null;
category: PositionCategory;
lead_account_id: string | null;
created_at: string;
updated_at: string;
}
export interface PositionCreate {
name: string;
location?: string | null;
category: PositionCategory;
lead_account_id?: string | null;
}
export interface PositionUpdate {
name?: string;
location?: string | null;
category?: PositionCategory;
lead_account_id?: string | null;
}
// --------------------------------------------------------------------------- //
// Shift //
// --------------------------------------------------------------------------- //
export interface ShiftRead {
id: string;
edition_id: string;
position_id: string;
/** Datetime naïf, heure locale Europe/Paris (ISO sans fuseau). */
starts_at: string;
ends_at: string;
min_staff: number;
ideal_staff: number;
required_skill_codes: string[];
created_at: string;
updated_at: string;
}
export interface ShiftCreate {
position_id: string;
starts_at: string;
ends_at: string;
min_staff?: number;
ideal_staff?: number;
required_skill_codes?: string[];
}
export interface ShiftUpdate {
position_id?: string;
starts_at?: string;
ends_at?: string;
min_staff?: number;
ideal_staff?: number;
required_skill_codes?: string[] | null;
}
// --------------------------------------------------------------------------- //
// Assignment //
// --------------------------------------------------------------------------- //
export interface AssignmentRead {
id: string;
edition_id: string;
volunteer_id: string;
shift_id: string;
status: AssignmentStatus;
source: AssignmentSource;
locked: boolean;
created_at: string;
updated_at: string;
}
export interface AssignmentCreate {
volunteer_id: string;
shift_id: string;
status?: AssignmentStatus;
source?: AssignmentSource;
locked?: boolean;
}
export interface AssignmentUpdate {
status?: AssignmentStatus;
source?: AssignmentSource;
locked?: boolean;
}
export interface AssignmentWriteResult {
assignment: AssignmentRead;
warnings: Violation[];
}
export interface AssignmentValidateRequest {
volunteer_id: string;
shift_id: string;
}
export interface AssignmentValidateResult {
blocking: boolean;
violations: Violation[];
}
// --------------------------------------------------------------------------- //
// Matrice poste × créneau //
// --------------------------------------------------------------------------- //
export interface MatrixShift {
shift_id: string;
starts_at: string;
ends_at: string;
min_staff: number;
ideal_staff: number;
confirmed: number;
proposed: number;
}
export interface MatrixPosition {
id: string;
name: string;
category: PositionCategory;
location: string | null;
shifts: MatrixShift[];
}
export interface MatrixResponse {
edition_id: string;
positions: MatrixPosition[];
}
+76
View File
@@ -0,0 +1,76 @@
import type {
AssignmentCreate,
AssignmentRead,
AssignmentUpdate,
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
MatrixResponse,
PositionCreate,
PositionRead,
PositionUpdate,
ShiftCreate,
ShiftRead,
ShiftUpdate,
Violation,
VolunteerCreate,
VolunteerRead,
VolunteerUpdate,
} from './schema';
/**
* Surface consommée par l'application. Deux implémentations : HTTP (back-end
* réel) et mémoire (jeu de démonstration). Les écrans ne dépendent que d'ici.
*/
export interface PlanningApi {
volunteers: {
list(editionId: string): Promise<VolunteerRead[]>;
get(editionId: string, id: string): Promise<VolunteerRead>;
create(editionId: string, body: VolunteerCreate): Promise<VolunteerRead>;
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
remove(editionId: string, id: string): Promise<void>;
};
positions: {
list(editionId: string): Promise<PositionRead[]>;
get(editionId: string, id: string): Promise<PositionRead>;
create(editionId: string, body: PositionCreate): Promise<PositionRead>;
update(editionId: string, id: string, body: PositionUpdate): Promise<PositionRead>;
remove(editionId: string, id: string): Promise<void>;
};
shifts: {
list(editionId: string, params?: { positionId?: string }): Promise<ShiftRead[]>;
get(editionId: string, id: string): Promise<ShiftRead>;
create(editionId: string, body: ShiftCreate): Promise<ShiftRead>;
update(editionId: string, id: string, body: ShiftUpdate): Promise<ShiftRead>;
remove(editionId: string, id: string): Promise<void>;
};
assignments: {
list(
editionId: string,
params?: { volunteerId?: string; shiftId?: string },
): Promise<AssignmentRead[]>;
get(editionId: string, id: string): Promise<AssignmentRead>;
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
remove(editionId: string, id: string): Promise<void>;
validate(
editionId: string,
body: AssignmentValidateRequest,
): Promise<AssignmentValidateResult>;
};
matrix: {
get(editionId: string): Promise<MatrixResponse>;
};
}
/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
public readonly violations: Violation[] = [],
) {
super(code);
this.name = 'ApiError';
}
}