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,10 @@
|
||||
// Accès défensif à `import.meta.env` : sous Vite il est peuplé, mais les
|
||||
// scripts Node (smoke, tests) chargent les modules sans, d'où le repli.
|
||||
const raw = ((import.meta as { env?: Record<string, string | undefined> }).env ?? {});
|
||||
|
||||
export const ENV = {
|
||||
apiMode: (raw.VITE_API_MODE ?? 'mock') as 'mock' | 'http',
|
||||
apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api',
|
||||
editionId: raw.VITE_EDITION_ID,
|
||||
demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '',
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
AssignmentRead,
|
||||
MatrixResponse,
|
||||
PositionCategory,
|
||||
ShiftRead,
|
||||
Violation,
|
||||
} from '@/api/schema';
|
||||
import { ACTIVE_ASSIGNMENT_STATUSES } from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
|
||||
|
||||
export function isActive(a: AssignmentRead): boolean {
|
||||
return ACTIVE_ASSIGNMENT_STATUSES.includes(a.status);
|
||||
}
|
||||
|
||||
export function categoryLabel(category: PositionCategory): string {
|
||||
return t(`position.category.${category.toLowerCase()}`);
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return t(`assignment.status.${status.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/** Colonne de la grille : une (date, heure de début) distincte. */
|
||||
export interface LatticeColumn {
|
||||
key: string; // `YYYY-MM-DD|HH:MM`
|
||||
date: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface LatticeDay {
|
||||
date: string;
|
||||
columns: LatticeColumn[];
|
||||
}
|
||||
|
||||
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
|
||||
export function buildLattice(starts: Array<{ starts_at: string }>): {
|
||||
days: LatticeDay[];
|
||||
columns: LatticeColumn[];
|
||||
} {
|
||||
const seen = new Map<string, LatticeColumn>();
|
||||
for (const s of starts) {
|
||||
const date = datePart(s.starts_at);
|
||||
const time = timePart(s.starts_at);
|
||||
const key = `${date}|${time}`;
|
||||
if (!seen.has(key)) seen.set(key, { key, date, time });
|
||||
}
|
||||
const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
const days: LatticeDay[] = [];
|
||||
for (const column of columns) {
|
||||
let day = days.find((d) => d.date === column.date);
|
||||
if (!day) {
|
||||
day = { date: column.date, columns: [] };
|
||||
days.push(day);
|
||||
}
|
||||
day.columns.push(column);
|
||||
}
|
||||
return { days, columns };
|
||||
}
|
||||
|
||||
export function latticeFromMatrix(matrix: MatrixResponse) {
|
||||
return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at }))));
|
||||
}
|
||||
|
||||
/** Heures actives cumulées par bénévole. */
|
||||
export function hoursByVolunteer(
|
||||
assignments: AssignmentRead[],
|
||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||
): Map<string, number> {
|
||||
const totals = new Map<string, number>();
|
||||
for (const a of assignments) {
|
||||
if (!isActive(a)) continue;
|
||||
const shift = shiftsById.get(a.shift_id);
|
||||
if (!shift) continue;
|
||||
totals.set(
|
||||
a.volunteer_id,
|
||||
(totals.get(a.volunteer_id) ?? 0) + hoursBetween(shift.starts_at, shift.ends_at),
|
||||
);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
|
||||
export function isOverloaded(
|
||||
volunteerId: string,
|
||||
assignments: AssignmentRead[],
|
||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||
): boolean {
|
||||
const shifts = assignments
|
||||
.filter((a) => a.volunteer_id === volunteerId && isActive(a))
|
||||
.map((a) => shiftsById.get(a.shift_id))
|
||||
.filter((s): s is ShiftRead => Boolean(s))
|
||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const s of shifts) {
|
||||
const day = datePart(s.starts_at);
|
||||
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
|
||||
}
|
||||
for (const hours of perDay.values()) if (hours > 8) return true;
|
||||
|
||||
for (let i = 1; i < shifts.length; i += 1) {
|
||||
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
|
||||
if (gap > 0 && gap < 8 * 60) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend une violation en texte lisible. Les `params` suivent exactement le
|
||||
* contrat du moteur back-end (`backend/app/validation/engine.py`).
|
||||
*/
|
||||
export function violationText(
|
||||
violation: Violation,
|
||||
resolvePosition?: (shiftId: string) => string | undefined,
|
||||
): string {
|
||||
const p = violation.params;
|
||||
switch (violation.code) {
|
||||
case 'SHIFT_OVERLAP': {
|
||||
const where = resolvePosition?.(String(p.other_shift_id));
|
||||
return where
|
||||
? t('validation.SHIFT_OVERLAP.at', { position: where })
|
||||
: t('validation.SHIFT_OVERLAP');
|
||||
}
|
||||
case 'OUTSIDE_AVAILABILITY':
|
||||
return p.status === 'UNAVAILABLE'
|
||||
? t('validation.OUTSIDE_AVAILABILITY.unavailable')
|
||||
: t('validation.OUTSIDE_AVAILABILITY');
|
||||
case 'DAILY_HOURS_EXCEEDED':
|
||||
return t('validation.DAILY_HOURS_EXCEEDED', {
|
||||
hours: formatMinutes(Number(p.total_minutes)),
|
||||
});
|
||||
case 'INSUFFICIENT_REST':
|
||||
return t('validation.INSUFFICIENT_REST', {
|
||||
rest: formatMinutes(Number(p.gap_minutes)),
|
||||
});
|
||||
case 'MISSING_REQUIRED_SKILL':
|
||||
return t('validation.MISSING_REQUIRED_SKILL', { skill: String(p.skill_code) });
|
||||
default:
|
||||
return violation.code;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMinutes(total: number): string {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Aides temps. Les datetimes de l'API sont **naïfs** (heure locale
|
||||
// Europe/Paris, ISO sans fuseau) : on les manipule comme des chaînes /
|
||||
// nombres, jamais via `Date` local du navigateur qui réintroduirait un fuseau.
|
||||
|
||||
const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/;
|
||||
|
||||
/** Minutes depuis l'epoch naïf (1970-01-01 00:00), sans fuseau. */
|
||||
export function naiveMinutes(iso: string): number {
|
||||
const m = ISO_RE.exec(iso);
|
||||
if (!m) throw new Error(`Datetime ISO invalide : ${iso}`);
|
||||
const [, y, mo, d, h, mi] = m.map(Number);
|
||||
return Date.UTC(y, mo - 1, d, h, mi) / 60000;
|
||||
}
|
||||
|
||||
/** Durée d'un créneau en heures (décimales). */
|
||||
export function hoursBetween(startIso: string, endIso: string): number {
|
||||
return (naiveMinutes(endIso) - naiveMinutes(startIso)) / 60;
|
||||
}
|
||||
|
||||
/** Partie date `YYYY-MM-DD`. */
|
||||
export function datePart(iso: string): string {
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
/** Heure de début `HH:MM` (utile pour composer les colonnes de la grille). */
|
||||
export function timePart(iso: string): string {
|
||||
return iso.slice(11, 16);
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.'];
|
||||
const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi'];
|
||||
|
||||
function weekdayIndex(datePartStr: string): number {
|
||||
const [y, m, d] = datePartStr.split('-').map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
||||
}
|
||||
|
||||
/** Ex. « MER 15 ». */
|
||||
export function shortDayLabel(iso: string): string {
|
||||
const dp = datePart(iso);
|
||||
return `${WEEKDAYS[weekdayIndex(dp)].slice(0, 3).toUpperCase()} ${Number(dp.slice(8, 10))}`;
|
||||
}
|
||||
|
||||
/** Ex. « Mercredi 15 ». */
|
||||
export function longDayLabel(iso: string): string {
|
||||
const dp = datePart(iso);
|
||||
return `${WEEKDAYS_LONG[weekdayIndex(dp)]} ${Number(dp.slice(8, 10))}`;
|
||||
}
|
||||
|
||||
/** Ex. « 17:00 → 22:00 ». */
|
||||
export function hourRangeLabel(startIso: string, endIso: string): string {
|
||||
return `${timePart(startIso)} → ${timePart(endIso)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user