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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user