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';
}
}
+16 -14
View File
@@ -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>
-306
View File
@@ -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;
}
+29
View File
@@ -0,0 +1,29 @@
import { ApiError } from '@/api';
import { t } from '@/i18n';
export function Loading() {
return (
<div className="flex items-center gap-3 p-6 font-mono text-[11px] uppercase tracking-widest opacity-60">
<span className="h-4 w-4 animate-spin border-2 border-ink border-t-transparent" />
{t('app.loading')}
</div>
);
}
export function ErrorNote({ error }: { error: unknown }) {
const code =
error instanceof ApiError
? error.code
: error instanceof Error
? error.message
: 'error.generic';
const known = code.startsWith('error.') || code.includes('.');
return (
<div className="m-4 max-w-[640px] border-[3px] border-ink bg-orange p-4 font-display text-[13px] font-bold shadow-brutal-md">
{t('app.error.generic')}
<span className="mt-1 block font-mono text-[10px] font-normal opacity-80">
{known ? t(code) : code}
</span>
</div>
);
}
-69
View File
@@ -1,69 +0,0 @@
import type { DayPart, FestivalDay, PositionCategory } from './types';
// Découpage de journée du jeu de démonstration : 4 plages, la dernière passe
// minuit (22 h → 02 h). Repris tel quel du design.
export const DAY_PARTS: DayPart[] = [
{ code: 'M', label: '08–12', startHour: 8, endHour: 12, order: 0 },
{ code: 'A', label: '12–17', startHour: 12, endHour: 17, order: 1 },
{ code: 'S', label: '17–22', startHour: 17, endHour: 22, order: 2 },
{ code: 'N', label: '22–02', startHour: 22, endHour: 26, order: 3 },
];
export const FESTIVAL_DAYS: FestivalDay[] = [
{ id: 'mer', index: 0, shortLabel: 'MER 15', tag: 'MONTAGE', longLabel: 'Mercredi 15' },
{ id: 'jeu', index: 1, shortLabel: 'JEU 16', tag: 'JOUR 1', longLabel: 'Jeudi 16' },
{ id: 'ven', index: 2, shortLabel: 'VEN 17', tag: 'JOUR 2', longLabel: 'Vendredi 17' },
{ id: 'sam', index: 3, shortLabel: 'SAM 18', tag: 'DÉMONTAGE', longLabel: 'Samedi 18' },
];
/** Ordre d'affichage des catégories de poste dans le jeu de démonstration. */
export const DEMO_CATEGORIES: PositionCategory[] = [
'BAR',
'TICKETING',
'WELCOME',
'PARKING',
'CLEANING',
'CATERING',
'SETUP',
'STAGE',
];
export function dayPart(code: string): DayPart {
const found = DAY_PARTS.find((d) => d.code === code);
if (!found) throw new Error(`Plage inconnue : ${code}`);
return found;
}
export function festivalDay(id: string): FestivalDay {
const found = FESTIVAL_DAYS.find((d) => d.id === id);
if (!found) throw new Error(`Journée inconnue : ${id}`);
return found;
}
/** Durée d'une plage en heures. */
export function dayPartHours(code: string): number {
const d = dayPart(code);
return d.endHour - d.startHour;
}
/**
* Fenêtre absolue d'une (journée, plage) en heures depuis le début du festival,
* pour comparer chevauchements et repos. Reprend `abs()` du design.
*/
export function absoluteWindow(dayId: string, dayPartCode: string): { start: number; end: number } {
const day = festivalDay(dayId);
const part = dayPart(dayPartCode);
return { start: day.index * 24 + part.startHour, end: day.index * 24 + part.endHour };
}
export function categoryLabelKey(category: PositionCategory): string {
return `position.category.${category.toLowerCase()}`;
}
export function slotKey(positionId: string, dayId: string, dayPartCode: string): string {
return `${positionId}|${dayId}|${dayPartCode}`;
}
export function availabilityKey(dayId: string, dayPartCode: string): string {
return `${dayId}.${dayPartCode}`;
}
-50
View File
@@ -1,50 +0,0 @@
import type { PlanningState } from '@/app/planning-store';
import { DAY_PARTS, FESTIVAL_DAYS } from './festival';
export interface DiffEntry {
kind: 'ADD' | 'REMOVE';
volunteerName: string;
positionName: string;
zone: string;
dayShortLabel: string;
dayPartLabel: string;
}
/**
* Différentiel entre le brouillon et la dernière version publiée : ajouts non
* publiés (`pending`) et retraits mémorisés. Trié par nom de bénévole.
*/
export function buildDiff(state: PlanningState): DiffEntry[] {
const added: DiffEntry[] = state.assignments
.filter((a) => a.pending)
.map((a) => {
const position = state.positions.find((p) => p.id === a.positionId);
const volunteer = state.volunteers.find((v) => v.id === a.volunteerId);
const day = FESTIVAL_DAYS.find((d) => d.id === a.dayId);
const part = DAY_PARTS.find((d) => d.code === a.dayPartCode);
return {
kind: 'ADD' as const,
volunteerName: volunteer?.fullName ?? a.volunteerId,
positionName: position?.name ?? a.positionId,
zone: position?.zone ?? '',
dayShortLabel: day?.shortLabel ?? a.dayId,
dayPartLabel: part?.label ?? a.dayPartCode,
};
});
const removed: DiffEntry[] = state.removed.map((r) => ({
kind: 'REMOVE' as const,
volunteerName: r.volunteerName,
positionName: r.positionName,
zone: r.zone,
dayShortLabel: r.dayShortLabel,
dayPartLabel: r.dayPartLabel,
}));
return [...added, ...removed].sort((a, b) => a.volunteerName.localeCompare(b.volunteerName, 'fr'));
}
export function countConcernedVolunteers(diff: DiffEntry[]): number {
return new Set(diff.map((d) => d.volunteerName)).size;
}
-105
View File
@@ -1,105 +0,0 @@
import { absoluteWindow, dayPartHours, festivalDay } from './festival';
import type { Assignment, Position, Volunteer } from './types';
// Portage frontend du moteur de validation (CLAUDE.md §3). Même contrat que le
// backend : chaque violation est un code + des paramètres structurés, jamais une
// phrase. La traduction se fait via i18n (`validation.<CODE>`).
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
export interface Violation {
code: 'SHIFT_OVERLAP' | 'OUTSIDE_AVAILABILITY' | 'DAILY_HOURS_EXCEEDED' | 'INSUFFICIENT_REST' | 'MISSING_REQUIRED_SKILL';
severity: ViolationSeverity;
params: Record<string, string | number>;
}
/** Seuils paramétrables (proviennent de l'édition). */
export interface RuleThresholds {
maxDailyHours: number;
minRestHours: number;
}
interface EvaluateInput {
volunteer: Volunteer;
/** Poste + (journée, plage) visés par l'affectation envisagée. */
target: { position: Position; dayId: string; dayPartCode: string };
/** Affectations actives du bénévole (PROPOSED + CONFIRMED), hors cible. */
currentAssignments: Assignment[];
positionsById: Map<string, Position>;
thresholds: RuleThresholds;
}
/**
* Évalue l'affectation d'un bénévole sur un créneau. Renvoie la liste des
* violations : une seule `BLOCKING` suffit à interdire l'enregistrement.
*/
export function evaluateAssignment(input: EvaluateInput): Violation[] {
const { volunteer, target, currentAssignments, positionsById, thresholds } = input;
const { dayId, dayPartCode } = target;
const violations: Violation[] = [];
const targetHours = dayPartHours(dayPartCode);
const targetWindow = absoluteWindow(dayId, dayPartCode);
// SHIFT_OVERLAP — deux créneaux qui se chevauchent (créneaux jointifs exclus).
for (const other of currentAssignments) {
const otherWindow = absoluteWindow(other.dayId, other.dayPartCode);
const overlaps = otherWindow.start < targetWindow.end && targetWindow.start < otherWindow.end;
if (overlaps) {
const otherPosition = positionsById.get(other.positionId);
violations.push({
code: 'SHIFT_OVERLAP',
severity: 'BLOCKING',
params: {
position: otherPosition ? `${otherPosition.name} ${otherPosition.zone}` : other.positionId,
},
});
break;
}
}
// OUTSIDE_AVAILABILITY — plage non déclarée disponible.
if (!volunteer.availability.has(`${dayId}.${dayPartCode}`)) {
violations.push({ code: 'OUTSIDE_AVAILABILITY', severity: 'WARNING', params: {} });
}
// DAILY_HOURS_EXCEEDED — > seuil d'heures cumulées sur la journée.
const sameDayHours =
currentAssignments
.filter((a) => a.dayId === dayId)
.reduce((sum, a) => sum + dayPartHours(a.dayPartCode), 0) + targetHours;
if (sameDayHours > thresholds.maxDailyHours) {
violations.push({
code: 'DAILY_HOURS_EXCEEDED',
severity: 'WARNING',
params: {
max: thresholds.maxDailyHours,
total: sameDayHours,
day: festivalDay(dayId).longLabel.toLowerCase(),
},
});
}
// INSUFFICIENT_REST — < seuil d'heures entre deux créneaux (strict).
let minRest = Number.POSITIVE_INFINITY;
for (const other of currentAssignments) {
const otherWindow = absoluteWindow(other.dayId, other.dayPartCode);
let gap = 0;
if (otherWindow.start >= targetWindow.end) gap = otherWindow.start - targetWindow.end;
else if (targetWindow.start >= otherWindow.end) gap = targetWindow.start - otherWindow.end;
if (gap < minRest) minRest = gap;
}
if (minRest > 0 && minRest < thresholds.minRestHours) {
violations.push({
code: 'INSUFFICIENT_REST',
severity: 'WARNING',
params: { min: thresholds.minRestHours, rest: minRest },
});
}
return violations;
}
export function hasBlocking(violations: Violation[]): boolean {
return violations.some((v) => v.severity === 'BLOCKING');
}
-71
View File
@@ -1,71 +0,0 @@
import { absoluteWindow, dayPartHours, slotKey } from './festival';
import type { Assignment, EditionConfig, Volunteer } from './types';
/** Statuts pris en compte pour chevauchement / cumul / repos (CLAUDE.md §3). */
export function isActive(assignment: Assignment): boolean {
return assignment.status === 'PROPOSED' || assignment.status === 'CONFIRMED';
}
export interface PlanningIndex {
/** Affectations actives par créneau (clé `position|day|daypart`). */
bySlot: Map<string, Assignment[]>;
/** Affectations actives par bénévole. */
byVolunteer: Map<string, Assignment[]>;
/** Heures actives cumulées par bénévole. */
totalHours: Map<string, number>;
/** Heures actives par (bénévole, journée) — clé `volunteerId|dayId`. */
dailyHours: Map<string, number>;
}
export function indexPlanning(assignments: Assignment[], volunteers: Volunteer[]): PlanningIndex {
const bySlot = new Map<string, Assignment[]>();
const byVolunteer = new Map<string, Assignment[]>();
const totalHours = new Map<string, number>();
const dailyHours = new Map<string, number>();
volunteers.forEach((v) => {
byVolunteer.set(v.id, []);
totalHours.set(v.id, 0);
});
assignments.forEach((assignment) => {
if (!isActive(assignment)) return;
const key = slotKey(assignment.positionId, assignment.dayId, assignment.dayPartCode);
const slotList = bySlot.get(key) ?? [];
slotList.push(assignment);
bySlot.set(key, slotList);
const volList = byVolunteer.get(assignment.volunteerId) ?? [];
volList.push(assignment);
byVolunteer.set(assignment.volunteerId, volList);
const hours = dayPartHours(assignment.dayPartCode);
totalHours.set(assignment.volunteerId, (totalHours.get(assignment.volunteerId) ?? 0) + hours);
const dayKey = `${assignment.volunteerId}|${assignment.dayId}`;
dailyHours.set(dayKey, (dailyHours.get(dayKey) ?? 0) + hours);
});
return { bySlot, byVolunteer, totalHours, dailyHours };
}
/**
* Un bénévole est « en surcharge » s'il dépasse le max journalier ou s'il a
* moins de repos minimum entre deux créneaux (mêmes seuils que le moteur).
*/
export function isOverloaded(
volunteer: Volunteer,
index: PlanningIndex,
config: Pick<EditionConfig, 'maxDailyHours' | 'minRestHours'>,
): boolean {
for (const [key, hours] of index.dailyHours) {
if (key.startsWith(`${volunteer.id}|`) && hours > config.maxDailyHours) return true;
}
const windows = (index.byVolunteer.get(volunteer.id) ?? [])
.map((a) => absoluteWindow(a.dayId, a.dayPartCode))
.sort((a, b) => a.start - b.start);
for (let i = 1; i < windows.length; i += 1) {
const gap = windows[i].start - windows[i - 1].end;
if (gap > 0 && gap < config.minRestHours) return true;
}
return false;
}
-117
View File
@@ -1,117 +0,0 @@
// Modèle de domaine côté frontend. Vocabulaire aligné sur CLAUDE.md :
// code et types en anglais. Les libellés d'interface passent par i18n ; les
// libellés *de données* (nom d'un poste, d'une plage, d'une journée) sont
// portés par l'entité elle-même car ils viendront de l'API / de l'édition.
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 Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
/** Tri-état d'une disponibilité déclarée. */
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
/** Découpage d'une journée, propre à l'édition (paramétrable). */
export interface DayPart {
/** Code court stable (M/A/S/N dans le jeu de démonstration). */
code: string;
label: string;
/** Heure locale de début (0–23). */
startHour: number;
/** Heure de fin ; > 24 signifie « passe minuit » (ex. 26 = 02 h). */
endHour: number;
order: number;
}
/** Une journée du festival (montage et démontage inclus). */
export interface FestivalDay {
id: string;
index: number;
shortLabel: string;
tag: string;
longLabel: string;
}
/** Besoin d'effectif sur un poste pour une (journée, plage). */
export interface StaffingNeed {
dayId: string;
dayPartCode: string;
minStaff: number;
idealStaff: number;
}
export interface Position {
id: string;
name: string;
zone: string;
location: string;
category: PositionCategory;
needs: StaffingNeed[];
}
export interface Volunteer {
id: string;
firstName: string;
lastName: string;
fullName: string;
initials: string;
phone: string;
/** Clés « dayId.dayPartCode » déclarées disponibles. */
availability: Set<string>;
preferredCategories: PositionCategory[];
/** Inscrit mais jamais affecté sur les 4 jours. */
neverAssigned?: boolean;
/** Ajouté via un import (encore à affecter). */
imported?: boolean;
}
export interface Assignment {
id: string;
volunteerId: string;
positionId: string;
dayId: string;
dayPartCode: string;
status: AssignmentStatus;
source: AssignmentSource;
locked: boolean;
/** Modification non encore publiée (« ajout » dans le différentiel). */
pending: 'ADD' | null;
}
/** Retrait non publié, mémorisé pour le différentiel de publication. */
export interface RemovedAssignment {
volunteerName: string;
positionName: string;
zone: string;
dayShortLabel: string;
dayPartLabel: string;
}
/** Paramètres d'édition modifiables depuis l'écran Configuration. */
export interface EditionConfig {
festivalName: string;
datesLabel: string;
maxDailyHours: number;
minRestHours: number;
autoWarn: boolean;
mailFrom: string;
mailReplyTo: string;
mailSubject: string;
mailBody: string;
reminderEnabled: boolean;
reminderHours: number;
magicLinkDays: number;
smsEnabled: boolean;
}
+97 -107
View File
@@ -3,19 +3,30 @@
"legend.title": "LÉGENDE",
"action.close": "Fermer",
"action.remove": "Retirer",
"action.add": "Ajouter",
"unit.hours": "{count} h",
"unit.days": "{count} j",
"time.now": "à l’instant",
"time.yesterday": "hier 18:42",
"app.loading": "Chargement…",
"app.error.generic": "Une erreur est survenue",
"error.generic": "Erreur inattendue",
"error.not_found": "Ressource introuvable",
"error.forbidden": "Accès refusé",
"error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué (règle bloquante)",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau",
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà",
"shift.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.draft": "BROUILLON · {count} MODIF.",
"header.published": "✓ TOUT EST PUBLIÉ",
"header.mockMode": "DONNÉES DE DÉMO",
"header.proposed": "{count} PROPOSITION(S)",
"tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole",
"tab.publication": "Publication",
"tab.publication": "Confirmations",
"tab.mobileVolunteer": "Bénévole · mobile",
"tab.mobileLead": "Référent · mobile",
"tab.config": "Configuration",
@@ -35,38 +46,42 @@
"position.category.stage": "Régie",
"position.category.other": "Autre",
"validation.SHIFT_OVERLAP": "Déjà affecté sur {position} au même créneau",
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
"validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment",
"validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.",
"validation.OUTSIDE_AVAILABILITY": "Hors disponibilités déclarées",
"validation.DAILY_HOURS_EXCEEDED": "Plus de {max} h le {day} ({total} h)",
"validation.INSUFFICIENT_REST": "Moins de {min} h entre deux créneaux ({rest} h)",
"validation.OUTSIDE_AVAILABILITY.unavailable": "Déclaré indisponible sur ce créneau",
"validation.OUTSIDE_AVAILABILITY.rule": "Une demi-journée du créneau est indisponible ou sans déclaration.",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
"validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})",
"validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante : {skill}",
"validation.MISSING_REQUIRED_SKILL.rule": "Le bénévole n'a pas une compétence requise par le créneau.",
"grille.filter.day": "JOUR",
"grille.filter.position": "POSTE",
"grille.legend.title": "LÉGENDE",
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
"grille.legend.incomplete": "INCOMPLET",
"grille.legend.complete": "COMPLET",
"grille.legend.unpublished": "NON PUBLIÉ",
"grille.positionsCount": "{count} POSTES",
"grille.assignedRequired": "AFFECTÉS / REQUIS",
"grille.footnote": "Colonnes = plages de la journée (08–12 / 12–17 / 17–22 / 22–02). Cliquer une cellule ouvre le panneau d’affectation à droite.",
"grille.assignedRequired": "CONFIRMÉS / IDÉAL",
"grille.footnote": "Chaque cellule est un créneau. Nombre = confirmés, + = proposés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.",
"grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.",
"grille.panel.empty.body": "Le panneau s’ouvre ici, sans quitter la grille : candidats triés par pertinence, alertes bloquantes et avertissements.",
"grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.",
"grille.panel.slotDuration": "CRÉNEAU DE {hours} H",
"grille.panel.assigned": "AFFECTÉS",
"grille.panel.confirmed": "CONFIRMÉS",
"grille.panel.proposedCount": "PROPOSÉS {count}",
"grille.panel.min": "MINIMUM {count}",
"grille.panel.ideal": "IDÉAL {count}",
"grille.panel.assignmentsTitle": "AFFECTATIONS ({count})",
"grille.panel.noneAssigned": "Personne n’est encore affecté sur ce créneau.",
"grille.panel.noneAssigned": "Personne n'est encore affecté sur ce créneau.",
"grille.panel.hoursTotal": "{hours} H AU TOTAL",
"grille.panel.candidates": "CANDIDATS",
"grille.panel.candidatesHint": "TRIÉS PAR PERTINENCE",
"grille.panel.hoursAssigned": "{hours} H DÉJÀ AFFECTÉES · {slots} CRÉNEAUX",
"grille.panel.candidatesHint": "LES MOINS CHARGÉS D'ABORD",
"grille.panel.hoursAssigned": "{hours} H · {slots} CRÉNEAUX",
"grille.panel.assign": "AFFECTER",
"grille.panel.blocked": "BLOQUÉ",
"grille.panel.availableTag": "✓ Disponible sur ce créneau",
"grille.panel.wantedTag": "★ A demandé ce poste",
"grille.panel.blockingPrefix": "BLOQUANT — {reason}",
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
@@ -76,36 +91,26 @@
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.confirmed": "AFFECTATION CONFIRMÉE",
"volunteers.legend.proposed": "AFFECTATION PROPOSÉE",
"volunteers.legend.availability": "DISPONIBILITÉ DÉCLARÉE (NON AFFECTÉ)",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.name": "NOM",
"volunteers.cumulative": "CUMUL",
"volunteers.badge.overloaded": "▲ SURCHARGE",
"volunteers.badge.unassigned": "○ OUBLIÉ",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l’ensemble de l’équipe.",
"volunteers.footnote": "Surcharge = plus de {maxHours} h sur une même journée ou moins de {restHours} h entre deux créneaux. Oublié = aucune affectation sur les 4 jours.",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation active.",
"publication.stat.changes": "MODIFICATIONS NON PUBLIÉES",
"publication.stat.proposed": "AFFECTATIONS PROPOSÉES",
"publication.stat.concerned": "BÉNÉVOLES CONCERNÉS",
"publication.stat.version": "VERSION PUBLIÉE · {when}",
"publication.draftTitle": "BROUILLON EN COURS D’ÉDITION",
"publication.diffWith": "DIFFÉRENTIEL AVEC LA v{version}",
"publication.add": "+ AJOUT",
"publication.remove": "− RETRAIT",
"publication.noDiff": "Le brouillon est identique à la version publiée. Chaque bénévole a reçu son planning à jour.",
"publication.mailPreviewTitle": "APERÇU DU MESSAGE ENVOYÉ",
"publication.mailFrom": "DE : {from}",
"publication.stat.confirmed": "AFFECTATIONS CONFIRMÉES",
"publication.toConfirmTitle": "EN ATTENTE DE CONFIRMATION",
"publication.allConfirmed": "Aucune affectation en attente : tout est confirmé.",
"publication.mailPreviewTitle": "APERÇU DU MESSAGE (MAQUETTE)",
"publication.mailTo": "À : {count} bénévoles concernés",
"publication.mailSubjectPrefix": "Objet — {subject}",
"publication.mailGreeting": "Salut {name},",
"publication.mailBody": "Ton planning pour le festival vient d’être mis à jour. Tu as désormais {slots} créneaux, soit {hours} heures.",
"publication.mailBody2": "Merci de confirmer chaque créneau depuis ton planning personnel : ton référent de poste s’appuie dessus pour le pointage sur le site.",
"publication.mailSubject": "Objet — Ton planning bénévole a été mis à jour",
"publication.mailBody": "Ton planning pour le festival vient d'être mis à jour. Merci de confirmer chaque créneau depuis ton planning personnel.",
"publication.mailCta": "VOIR MON PLANNING →",
"publication.magicLinkNote": "Lien magique valable {days} jours, aucun mot de passe.",
"publication.publishButton": "PUBLIER ET ENVOYER À {count} BÉNÉVOLES",
"publication.publishNote": "La publication fige le brouillon en version v{version}. Les affectations restent « proposées » jusqu’à confirmation par chaque bénévole.",
"publication.publishedState": "✓ PUBLIÉ — VERSION v{version} ENVOYÉE {when}",
"publication.outOfScope": "La publication versionnée et l'envoi d'emails / SMS sont hors périmètre du premier incrément (CLAUDE.md §4). Cet écran liste les affectations « proposées » que les bénévoles n'ont pas encore confirmées.",
"design.noteTitle": "CHOIX DE CONCEPTION",
@@ -116,108 +121,93 @@
"mobileVol.hours": "{count} HEURES",
"mobileVol.toConfirm": "⚠ {count} CRÉNEAU(X) À CONFIRMER",
"mobileVol.location": "📍 {location}",
"mobileVol.lead": "RÉFÉRENT · {name}",
"mobileVol.call": "📞 {phone}",
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
"mobileVol.confirm": "JE CONFIRME",
"mobileVol.orgMessageTitle": "MESSAGE DE L’ORGANISATION",
"mobileVol.orgMessageBody": "Le briefing bénévoles a lieu jeudi 11h sous le chapiteau nord. Récupération des t-shirts et des badges à l’accueil bénévoles dès mercredi 14h.",
"mobileVol.orgMessageMeta": "COORDINATION · MARIE B. · HIER 18:42",
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair, aucune ombre portée sur du texte. Cibles tactiles à 52–62 px de haut. Une seule action par créneau, hiérarchie en trois niveaux : jour + horaire, poste, référent. Le téléphone du référent est un lien d’appel direct.",
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.",
"mobileLead.statusTime": "18:07",
"mobileLead.kicker": "RÉFÉRENT DE POSTE · {name}",
"mobileLead.shiftHours": "17:00 → 22:00",
"mobileLead.inProgress": "EN COURS · {hours}",
"mobileLead.present": "{present}/{total} PRÉSENTS",
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
"mobileLead.inProgress": "CRÉNEAU · {hours}",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.absenceAlert": "⚠ {count} ABSENCE(S) SUR LE CRÉNEAU",
"mobileLead.requestBackup": "DEMANDER UN RENFORT À LA COORDINATION",
"mobileLead.teamTitle": "POINTAGE DE L’ÉQUIPE",
"mobileLead.statusConfirmed": "Confirmée",
"mobileLead.statusProposed": "Proposée · non confirmée",
"mobileLead.teamTitle": "POINTAGE DE L'ÉQUIPE",
"mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT",
"mobileLead.markAbsent": "ABSENT",
"mobileLead.nextSlot": "Créneau suivant · 22:00 → 02:00 · 4 bénévoles attendus",
"mobileLead.designNote": "Le référent ne voit que son poste et le créneau en cours — aucune navigation, aucun filtre. Le pointage est un geste unique par personne, deux boutons pleine largeur de 56 px. Toute absence remonte immédiatement en haut de l’écran avec une action unique vers la coordination.",
"mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.",
"mobileLead.designNote": "Le référent ne voit que son poste et un créneau. Le pointage est un geste unique par personne. Il est local à cette maquette : le pointage sur site est hors périmètre (CLAUDE.md §4).",
"config.admin": "ADMINISTRATION",
"config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "GRILLE DES BESOINS",
"config.tab.grid": "POSTES & CRÉNEAUX",
"config.tab.volunteers": "BÉNÉVOLES & IMPORT",
"config.tab.mail": "MESSAGES & ENVOIS",
"config.tab.rules": "RÈGLES DU FESTIVAL",
"config.tab.mail": "MESSAGES (MAQUETTE)",
"config.tab.rules": "RÈGLES & ÉDITION",
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
"config.grid.slotsCount": "{count} créneaux",
"config.grid.addPosition": "+ AJOUTER UN POSTE",
"config.grid.minTotal": "BESOIN MINIMUM · TOUT LE FESTIVAL",
"config.grid.idealTotal": "BESOIN IDÉAL",
"config.grid.addShift": "+ AJOUTER UN CRÉNEAU (12–17)",
"config.grid.newPositionName": "Nouveau poste",
"config.grid.minTotal": "BESOIN MINIMUM · TOTAL",
"config.grid.idealTotal": "BESOIN IDÉAL · TOTAL",
"config.grid.min": "MIN",
"config.grid.ideal": "IDÉAL",
"config.grid.closed": "POSTE FERMÉ",
"config.grid.open": "+ OUVRIR",
"config.grid.note": "Toute modification est répercutée immédiatement sur la grille des besoins (onglet 01) : baisser un effectif idéal peut faire passer une cellule de « incomplet » à « complet ».",
"config.grid.noShifts": "Aucun créneau sur ce poste. Ajoutez-en un pour qu'il apparaisse dans la grille.",
"config.grid.note": "Les modifications d'effectif (min / idéal) et les créneaux sont enregistrés immédiatement via l'API et se répercutent sur la grille des besoins (onglet 01).",
"config.vol.importTitle": "IMPORTER DES INSCRIPTIONS",
"config.vol.importHint": "Glissez un export CSV du formulaire · colonnes reconnues : nom, email, téléphone, postes souhaités, disponibilités",
"config.vol.importButton": "SIMULER UN IMPORT",
"config.vol.importHint": "L'import de fichier CSV / Excel se fait via l'endpoint dédié. Ce bouton crée 4 bénévoles d'exemple via l'API.",
"config.vol.importButton": "CRÉER 4 EXEMPLES",
"config.vol.profiles": "PROFILS ENREGISTRÉS",
"config.vol.imported": "IMPORTÉS · À AFFECTER",
"config.vol.colName": "NOM",
"config.vol.colPhone": "TÉLÉPHONE",
"config.vol.colWanted": "POSTES SOUHAITÉS",
"config.vol.colAvailability": "DISPONIBILITÉS",
"config.vol.new": "NOUVEAU",
"config.vol.slotsDeclared": "{count} plages",
"config.vol.colEmail": "EMAIL",
"config.vol.colSkills": "COMPÉTENCES",
"config.vol.ficheKicker": "FICHE BÉNÉVOLE",
"config.vol.fullName": "NOM COMPLET",
"config.vol.firstName": "PRÉNOM",
"config.vol.lastName": "NOM",
"config.vol.phone": "TÉLÉPHONE",
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
"config.vol.wanted": "POSTES SOUHAITÉS",
"config.vol.hoursAssigned": "DÉJÀ AFFECTÉES",
"config.vol.availabilityDeclared": "DISPONIBILITÉS DÉCLARÉES",
"config.vol.availabilityValue": "{count} plages déclarées sur 16",
"config.vol.note": "Les disponibilités proviennent du formulaire d’inscription : la coordination peut les corriger mais jamais les confondre avec une affectation.",
"config.vol.tshirt": "TAILLE DE T-SHIRT",
"config.vol.skills": "COMPÉTENCES",
"config.vol.noSkills": "Aucune compétence déclarée",
"config.vol.skillPlaceholder": "Code (ex. CAISSE)",
"config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).",
"config.mail.draftBanner": "Écran maquette : l'envoi d'emails / SMS est hors périmètre. Ces réglages ne sont pas enregistrés.",
"config.mail.templateTitle": "MODÈLE DE MESSAGE DE PUBLICATION",
"config.mail.from": "EXPÉDITEUR",
"config.mail.replyTo": "RÉPONDRE À",
"config.mail.subject": "OBJET",
"config.mail.body": "CORPS DU MESSAGE",
"config.mail.variables": "VARIABLES DISPONIBLES",
"config.mail.sendsTitle": "ENVOIS ET RELANCES",
"config.mail.reminderToggle": "Relance automatique des créneaux non confirmés",
"config.mail.on": "ACTIVÉE",
"config.mail.off": "DÉSACTIVÉE",
"config.mail.reminderDelay": "Délai avant relance",
"config.mail.magicLinkValidity": "Validité du lien magique",
"config.mail.smsToggle": "Doubler par SMS le jour J",
"config.mail.smsCost": "Coût estimé : 0,05 € par bénévole",
"config.mail.onM": "ACTIVÉ",
"config.mail.offM": "DÉSACTIVÉ",
"config.mail.previewTitle": "APERÇU AVEC DONNÉES RÉELLES",
"config.mail.previewTitle": "APERÇU",
"config.mail.previewFrom": "DE : {from}",
"config.mail.cta": "VOIR MON PLANNING →",
"config.rules.editionTitle": "ÉDITION",
"config.rules.festivalName": "NOM DU FESTIVAL",
"config.rules.dates": "DATES COUVERTES",
"config.rules.datesNote": "Les colonnes de la grille sont générées à partir de ces dates : 4 journées × 4 plages (08–12 / 12–17 / 17–22 / 22–02).",
"config.rules.thresholdsTitle": "SEUILS D’ALERTE",
"config.rules.maxDaily": "Maximum d’heures par journée",
"config.rules.maxDailyNote": "Avertissement non bloquant lors de l’affectation",
"config.rules.editionTitle": "ÉDITION & CONNEXION",
"config.rules.editionId": "Identifiant d'édition",
"config.rules.apiMode": "Mode API",
"config.rules.volunteersCount": "Bénévoles",
"config.rules.positionsCount": "Postes",
"config.rules.shiftsCount": "Créneaux",
"config.rules.editionNote": "Il n'y a pas encore d'endpoint de configuration d'édition : nom, dates et rétention se règlent en base. En mode HTTP, VITE_EDITION_ID désigne l'édition.",
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.maxDaily": "Maximum d'heures par journée",
"config.rules.minRest": "Repos minimum entre deux créneaux",
"config.rules.minRestNote": "Les créneaux enchaînés dans la même journée ne comptent pas",
"config.rules.overCount": "{count} bénévoles dépassent les seuils actuels — visibles dans la vue par bénévole.",
"config.rules.overlapNote": "Le double emploi au même créneau reste toujours bloquant : ce seuil n’est pas paramétrable.",
"config.rules.rolesTitle": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination · 3 personnes",
"config.rules.roleCoordinatorDesc": "Accès complet, publication, configuration",
"config.rules.roleLead": "Référents de poste · 15 personnes",
"config.rules.roleLeadDesc": "Leur poste, leur créneau, pointage seulement",
"config.rules.roleVolunteer": "Bénévoles · {count} personnes",
"config.rules.roleVolunteerDesc": "Lien magique par email, sans mot de passe",
"config.rules.designateLead": "DÉSIGNER UN RÉFÉRENT DE POSTE"
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination",
"config.rules.roleCoordinatorDesc": "Accès complet, construit et publie les plannings",
"config.rules.roleLead": "Référent de poste",
"config.rules.roleLeadDesc": "Lecture sur l'édition, écriture sur ses postes",
"config.rules.roleVolunteer": "Bénévole",
"config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email"
}
+10
View File
@@ -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 ?? '',
};
+149
View File
@@ -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')}`;
}
+53
View File
@@ -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)}`;
}
+9 -3
View File
@@ -1,17 +1,23 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { PlanningProvider } from '@/app/planning-store';
import { routes } from '@/app/router';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 15_000, refetchOnWindowFocus: false, retry: 1 },
},
});
const router = createBrowserRouter(routes);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PlanningProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</PlanningProvider>
</QueryClientProvider>
</StrictMode>,
);
-205
View File
@@ -1,205 +0,0 @@
import {
DAY_PARTS,
DEMO_CATEGORIES,
FESTIVAL_DAYS,
availabilityKey,
dayPartHours,
} from '@/domain/festival';
import type { Assignment, AssignmentStatus, Position, Volunteer } from '@/domain/types';
import { buildPositions } from './positions';
export interface DemoDataset {
positions: Position[];
volunteers: Volunteer[];
assignments: Assignment[];
removed: { volunteerName: string; positionName: string; zone: string; dayShortLabel: string; dayPartLabel: string }[];
nextAssignmentSeq: number;
}
// Générateur pseudo-aléatoire déterministe (mulberry32), repris du design pour
// obtenir exactement le même jeu de démonstration à chaque chargement.
function mulberry32(seed: number): () => number {
let a = seed;
return function () {
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;
};
}
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 VOLUNTEER_COUNT = 64;
const NEVER_ASSIGNED_INDEXES = [7, 21, 34, 48, 55];
const OVERLOADED_INDEXES = [3, 9];
function statusFromRoll(roll: number): AssignmentStatus {
if (roll < 0.72) return 'CONFIRMED';
if (roll < 0.95) return 'PROPOSED';
return 'DECLINED';
}
export function buildDemoDataset(): DemoDataset {
const positions = buildPositions();
const rnd = mulberry32(20260715);
const volunteers: Volunteer[] = [];
for (let i = 0; i < VOLUNTEER_COUNT; i += 1) {
const firstName = FIRST_NAMES[i % FIRST_NAMES.length];
const lastName = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const availability = new Set<string>();
FESTIVAL_DAYS.forEach((day) => {
DAY_PARTS.forEach((part) => {
const isEdgeDay = day.index === 0 || day.index === 3;
if (rnd() < (isEdgeDay ? 0.4 : 0.6)) {
availability.add(availabilityKey(day.id, part.code));
}
});
});
const preferredCategories = [DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]];
if (rnd() < 0.45) {
preferredCategories.push(DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]);
}
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const phone = `06 ${pair()} ${pair()} ${pair()} ${pair()}`;
volunteers.push({
id: `v${i}`,
firstName,
lastName,
fullName: `${firstName} ${lastName}`,
initials: firstName[0] + lastName[0],
phone,
availability,
preferredCategories,
});
}
// Bénévole de démonstration (« moi ») : disponible partout, demande Bar + Accueil.
const me = volunteers[0];
me.preferredCategories = ['BAR', 'WELCOME'];
FESTIVAL_DAYS.forEach((day) =>
DAY_PARTS.forEach((part) => me.availability.add(availabilityKey(day.id, part.code))),
);
// Référent de démonstration.
volunteers[1].preferredCategories = ['BAR'];
// Bénévoles inscrits mais jamais affectés (« oubliés »).
NEVER_ASSIGNED_INDEXES.forEach((i) => {
volunteers[i].neverAssigned = true;
});
const assignments: Assignment[] = [];
const busy: Record<string, boolean> = {};
const dailyHours: Record<string, number> = {};
const totalHours: Record<string, number> = {};
let seq = 1;
let slotIndex = 0;
positions.forEach((position) => {
FESTIVAL_DAYS.forEach((day) => {
position.needs
.filter((need) => need.dayId === day.id)
.forEach((need) => {
slotIndex += 1;
let target = need.idealStaff;
if (slotIndex % 7 === 0) target = Math.max(1, need.minStaff - 1);
else if (slotIndex % 5 === 0) target = need.minStaff;
else if (slotIndex % 3 === 0) target = Math.max(need.minStaff, need.idealStaff - 1);
const hours = dayPartHours(need.dayPartCode);
const pool = volunteers
.filter(
(v) =>
!v.neverAssigned &&
!busy[`${v.id}|${day.id}|${need.dayPartCode}`] &&
(dailyHours[`${v.id}|${day.id}`] ?? 0) + hours <= 8,
)
.sort((a, b) => score(b) - score(a));
function score(v: Volunteer): number {
const available = v.availability.has(availabilityKey(day.id, need.dayPartCode)) ? 100 : 0;
const wanted = v.preferredCategories.includes(position.category) ? 40 : 0;
return available + wanted - (totalHours[v.id] ?? 0) * 3;
}
pool.slice(0, target).forEach((v) => {
const status = statusFromRoll(rnd());
assignments.push({
id: `a${seq}`,
volunteerId: v.id,
positionId: position.id,
dayId: day.id,
dayPartCode: need.dayPartCode,
status,
source: 'SOLVER',
locked: false,
pending: null,
});
seq += 1;
if (status !== 'DECLINED') {
busy[`${v.id}|${day.id}|${need.dayPartCode}`] = true;
dailyHours[`${v.id}|${day.id}`] = (dailyHours[`${v.id}|${day.id}`] ?? 0) + hours;
totalHours[v.id] = (totalHours[v.id] ?? 0) + hours;
}
});
});
});
});
// « Moi » : 1 créneau confirmé puis des propositions à confirmer.
assignments
.filter((a) => a.volunteerId === me.id)
.forEach((a, i) => {
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
});
// Deux surcharges volontaires : plus de 8 h sur le vendredi.
OVERLOADED_INDEXES.forEach((index) => {
const volunteer = volunteers[index];
const dayId = 'ven';
const taken = assignments
.filter((a) => a.volunteerId === volunteer.id && a.dayId === dayId)
.map((a) => a.dayPartCode);
const freeCode = ['A', 'S', 'N', 'M'].find((code) => !taken.includes(code));
if (!freeCode) return;
const position = positions.find((p) =>
p.needs.some((n) => n.dayId === dayId && n.dayPartCode === freeCode),
);
if (!position) return;
assignments.push({
id: `a${seq}`,
volunteerId: volunteer.id,
positionId: position.id,
dayId,
dayPartCode: freeCode,
status: 'CONFIRMED',
source: 'MANUAL',
locked: false,
pending: null,
});
seq += 1;
});
// Modifications non encore publiées.
assignments.forEach((a, i) => {
if (i % 29 === 7) a.pending = 'ADD';
});
const removed = [
{ volunteerName: 'Sarah Prévost', positionName: 'Parking', zone: 'Nord', dayShortLabel: 'VEN 17', dayPartLabel: '17–22' },
{ volunteerName: 'Karim Benali', positionName: 'Propreté', zone: 'Site', dayShortLabel: 'SAM 18', dayPartLabel: '12–17' },
];
return { positions, volunteers, assignments, removed, nextAssignmentSeq: seq };
}
-98
View File
@@ -1,98 +0,0 @@
import type { Position, PositionCategory, StaffingNeed } from '@/domain/types';
// Jeu de démonstration des postes, repris du design Claude Design.
// `raw` = [codePlage, minStaff, idealStaff] par journée.
type RawSlots = Record<string, Array<[string, number, number]>>;
interface RawPosition {
id: string;
category: PositionCategory;
name: string;
zone: string;
location: string;
slots: RawSlots;
}
const RAW_POSITIONS: RawPosition[] = [
{
id: 'BAR', category: 'BAR', name: 'Bar', zone: 'Grande scène', location: 'Chapiteau nord',
slots: { jeu: [['A', 3, 4], ['S', 4, 6], ['N', 3, 5]], ven: [['A', 3, 4], ['S', 5, 6], ['N', 4, 5]], sam: [['M', 2, 3]] },
},
{
id: 'BARV', category: 'BAR', name: 'Bar', zone: 'Village assos', location: 'Village est',
slots: { jeu: [['A', 2, 3], ['S', 3, 4]], ven: [['A', 2, 3], ['S', 3, 4], ['N', 2, 3]], sam: [['M', 2, 2]] },
},
{
id: 'BILA', category: 'TICKETING', name: 'Billetterie', zone: 'Entrée A', location: 'Portail est',
slots: { jeu: [['A', 4, 5], ['S', 3, 4]], ven: [['A', 4, 5], ['S', 3, 4]] },
},
{
id: 'BILB', category: 'TICKETING', name: 'Billetterie', zone: 'Entrée B', location: 'Portail ouest',
slots: { jeu: [['A', 2, 3], ['S', 2, 3]], ven: [['A', 3, 4], ['S', 2, 3]] },
},
{
id: 'ACCB', category: 'WELCOME', name: 'Accueil', zone: 'Bénévoles', location: 'Base bénévoles',
slots: { mer: [['M', 2, 3], ['A', 2, 3]], jeu: [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], ven: [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], sam: [['M', 2, 2]] },
},
{
id: 'ACCA', category: 'WELCOME', name: 'Accueil', zone: 'Artistes', location: 'Loges',
slots: { jeu: [['A', 2, 2], ['S', 2, 3]], ven: [['A', 2, 2], ['S', 2, 3]] },
},
{
id: 'PARN', category: 'PARKING', name: 'Parking', zone: 'Nord', location: 'Route de Bréal',
slots: { jeu: [['A', 3, 4], ['S', 3, 4], ['N', 2, 3]], ven: [['A', 3, 4], ['S', 4, 5], ['N', 2, 3]] },
},
{
id: 'PARS', category: 'PARKING', name: 'Parking', zone: 'Sud', location: 'Chemin des Prés',
slots: { jeu: [['A', 2, 3], ['S', 2, 3]], ven: [['A', 2, 3], ['S', 3, 4], ['N', 2, 2]] },
},
{
id: 'PROS', category: 'CLEANING', name: 'Propreté', zone: 'Site', location: 'Ensemble du site',
slots: { jeu: [['S', 2, 3], ['N', 3, 4]], ven: [['M', 3, 4], ['S', 2, 3], ['N', 3, 5]], sam: [['M', 4, 6], ['A', 4, 6]] },
},
{
id: 'PROA', category: 'CLEANING', name: 'Propreté', zone: 'Sanitaires', location: 'Blocs 1 à 4',
slots: { jeu: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], ven: [['M', 2, 2], ['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] },
},
{
id: 'CATB', category: 'CATERING', name: 'Catering', zone: 'Bénévoles', location: 'Tente cuisine',
slots: { mer: [['M', 2, 3], ['A', 2, 3]], jeu: [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], ven: [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], sam: [['M', 2, 3], ['A', 2, 2]] },
},
{
id: 'CATA', category: 'CATERING', name: 'Catering', zone: 'Artistes', location: 'Backstage',
slots: { jeu: [['A', 2, 2], ['S', 2, 3]], ven: [['A', 2, 2], ['S', 2, 3]] },
},
{
id: 'MONS', category: 'SETUP', name: 'Montage', zone: 'Scène', location: 'Grande scène',
slots: { mer: [['M', 6, 8], ['A', 6, 8], ['S', 4, 5]], sam: [['A', 6, 8], ['S', 4, 6]] },
},
{
id: 'MONI', category: 'SETUP', name: 'Montage', zone: 'Signalétique', location: 'Site entier',
slots: { mer: [['M', 3, 4], ['A', 3, 4]], sam: [['A', 3, 4]] },
},
{
id: 'REG', category: 'STAGE', name: 'Régie', zone: 'Régie centrale', location: 'Cabane régie',
slots: { jeu: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], ven: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] },
},
];
function toNeeds(slots: RawSlots): StaffingNeed[] {
const needs: StaffingNeed[] = [];
for (const [dayId, list] of Object.entries(slots)) {
for (const [dayPartCode, minStaff, idealStaff] of list) {
needs.push({ dayId, dayPartCode, minStaff, idealStaff });
}
}
return needs;
}
export function buildPositions(): Position[] {
return RAW_POSITIONS.map((p) => ({
id: p.id,
name: p.name,
zone: p.zone,
location: p.location,
category: p.category,
needs: toNeeds(p.slots),
}));
}
+115 -131
View File
@@ -1,54 +1,57 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { useMatrix } from '@/api/hooks';
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
import { Chip } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
import {
DAY_PARTS,
DEMO_CATEGORIES,
FESTIVAL_DAYS,
categoryLabelKey,
slotKey,
} from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import type { PositionCategory } from '@/domain/types';
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type DayFilter = 'all' | string;
type CategoryFilter = 'all' | PositionCategory;
export function GrillePage() {
const { state, dispatch } = usePlanning();
const [dayFilter, setDayFilter] = useState<DayFilter>('all');
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const matrix = useMatrix();
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const lattice = useMemo(
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
[matrix.data],
);
const shownDays =
dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter);
if (matrix.isLoading) return <Loading />;
if (matrix.error) return <ErrorNote error={matrix.error} />;
if (!matrix.data) return null;
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter);
const shownColumns = shownDays.flatMap((d) => d.columns);
const shownPositions =
categoryFilter === 'all'
? state.positions
: state.positions.filter((p) => p.category === categoryFilter);
? matrix.data.positions
: matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`;
const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`;
const selected = selectedShiftId
? matrix.data.positions
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
: null;
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Barre de filtres + légende */}
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')}
</Chip>
{FESTIVAL_DAYS.map((day) => (
<Chip key={day.id} active={dayFilter === day.id} onClick={() => setDayFilter(day.id)}>
{day.shortLabel}
{lattice.days.map((day) => (
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
{shortDayLabel(`${day.date}T12:00`)}
</Chip>
))}
</div>
@@ -58,82 +61,68 @@ export function GrillePage() {
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
{t('filter.all')}
</Chip>
{DEMO_CATEGORIES.map((category) => (
{categories.map((category) => (
<Chip
key={category}
active={categoryFilter === category}
onClick={() => setCategoryFilter(category)}
>
{t(categoryLabelKey(category)).toUpperCase()}
{categoryLabel(category).toUpperCase()}
</Chip>
))}
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('grille.legend.title')}</span>
<LegendItem swatchClass="bg-hatch-orange" glyph="▲">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-hatch-orange" glyph="▲">
{t('grille.legend.critical')}
</LegendItem>
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
<LegendItem swatch="bg-yellow-soft" glyph="◧">
{t('grille.legend.incomplete')}
</LegendItem>
<LegendItem swatchClass="bg-paper" glyph="✓">
<LegendItem swatch="bg-paper" glyph="✓">
{t('grille.legend.complete')}
</LegendItem>
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span className="h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
{t('grille.legend.unpublished')}
</span>
</div>
</div>
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div
className="grid w-max border-b-[3px] border-ink"
style={{ gridTemplateColumns }}
>
{/* Ligne 1 : coin + entêtes de journée */}
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div className="sticky left-0 top-0 z-[6] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('grille.positionsCount', { count: state.positions.length })}
{t('grille.positionsCount', { count: matrix.data.positions.length })}
</div>
{shownDays.map((day) => (
<div
key={day.id}
key={day.date}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: 'span 4' }}
style={{ gridColumn: `span ${day.columns.length}` }}
>
<span className="font-display text-[12px] font-black tracking-tight">
{day.shortLabel}
{shortDayLabel(`${day.date}T12:00`)}
</span>
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
</div>
))}
{/* Ligne 2 : légende de colonne + libellés de plage */}
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
{t('grille.assignedRequired')}
</div>
{shownDays.map((day) =>
DAY_PARTS.map((part) => (
<div
key={`${day.id}-${part.code}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{part.label}
</div>
)),
)}
{shownColumns.map((column) => (
<div
key={column.key}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{column.time}
</div>
))}
{/* Lignes de postes */}
{shownPositions.map((position) => (
<PositionRow
key={position.id}
position={position}
shownDays={shownDays}
bySlot={index.bySlot}
selectedSlot={state.selectedSlot}
onSelect={(slot) => dispatch({ type: 'selectSlot', slot })}
columns={shownColumns}
selectedShiftId={selectedShiftId}
onSelect={setSelectedShiftId}
/>
))}
</div>
@@ -143,8 +132,14 @@ export function GrillePage() {
</div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{state.selectedSlot ? (
<AssignmentPanel slot={state.selectedSlot} index={index} />
{selected ? (
<AssignmentPanel
positionName={selected.position.name}
positionLocation={selected.position.location}
positionCategory={selected.position.category}
shift={selected.shift}
onClose={() => setSelectedShiftId(null)}
/>
) : (
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
@@ -163,11 +158,11 @@ export function GrillePage() {
}
function LegendItem({
swatchClass,
swatch,
glyph,
children,
}: {
swatchClass: string;
swatch: string;
glyph: string;
children: React.ReactNode;
}) {
@@ -176,7 +171,7 @@ function LegendItem({
<span
className={cx(
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
swatchClass,
swatch,
)}
>
{glyph}
@@ -188,75 +183,64 @@ function LegendItem({
function PositionRow({
position,
shownDays,
bySlot,
selectedSlot,
columns,
selectedShiftId,
onSelect,
}: {
position: import('@/domain/types').Position;
shownDays: typeof FESTIVAL_DAYS;
bySlot: Map<string, import('@/domain/types').Assignment[]>;
selectedSlot: string | null;
onSelect: (slot: string) => void;
position: MatrixPosition;
columns: LatticeColumn[];
selectedShiftId: string | null;
onSelect: (id: string) => void;
}) {
const byColumn = new Map<string, MatrixShift>();
for (const shift of position.shifts) {
byColumn.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, shift);
}
return (
<>
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
<span className="font-display text-[12px] font-bold tracking-tight">{position.name}</span>
<span className="font-mono text-[8.5px] opacity-55">{position.zone}</span>
<span className="truncate font-display text-[12px] font-bold tracking-tight">
{position.name}
</span>
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
</div>
{shownDays.map((day) =>
DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
const key = slotKey(position.id, day.id, part.code);
if (!need) {
return (
<div
key={key}
className="h-9 border-b border-r border-ink bg-hatch-sand"
/>
);
}
const list = bySlot.get(key) ?? [];
const count = list.length;
const isCrit = count < need.minStaff;
const isInc = count >= need.minStaff && count < need.idealStaff;
const pending = list.some((a) => a.pending);
const selected = selectedSlot === key;
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
return (
<div
key={key}
onClick={() => onSelect(key)}
className={cx(
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
isCrit && 'bg-hatch-orange',
isInc && 'bg-yellow-soft hover:bg-yellow',
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
)}
>
<span className="font-mono text-[14px] font-bold">{count}</span>
<span className="font-mono text-[10px] opacity-60">/{need.idealStaff}</span>
<span
className={cx(
'font-mono text-[10px] font-bold',
!isCrit && !isInc && 'text-green',
)}
>
{glyph}
</span>
{pending ? (
<span className="absolute right-0 top-0 h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
) : null}
{selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null}
</div>
);
}),
)}
{columns.map((column) => {
const shift = byColumn.get(column.key);
if (!shift) {
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
}
const assigned = shift.confirmed + shift.proposed;
const isCrit = assigned < shift.min_staff;
const isInc = !isCrit && assigned < shift.ideal_staff;
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
const selected = selectedShiftId === shift.shift_id;
return (
<div
key={column.key}
onClick={() => onSelect(shift.shift_id)}
className={cx(
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
isCrit && 'bg-hatch-orange',
isInc && 'bg-yellow-soft hover:bg-yellow',
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
)}
title={`${shift.confirmed} confirmé(s) · ${shift.proposed} proposé(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
>
<span className="font-mono text-[13px] font-bold">{shift.confirmed}</span>
{shift.proposed > 0 ? (
<span className="font-mono text-[9px] opacity-60">+{shift.proposed}</span>
) : null}
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
{glyph}
</span>
{selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null}
</div>
);
})}
</>
);
}
+58 -46
View File
@@ -1,40 +1,54 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { slotKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time';
import { isActive, statusLabel } from '@/lib/planning';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const LEAD_SLOT = slotKey('BAR', 'ven', 'S');
type Presence = Record<string, 'present' | 'absent'>;
export function MobileLeadPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const positions = usePositions();
const shifts = useShifts();
const volunteers = useVolunteers();
const [presence, setPresence] = useState<Presence>({});
// Poste de démonstration : le premier ; créneau le plus fourni.
const position = (positions.data ?? [])[0];
const positionShifts = useMemo(
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
[shifts.data, position?.id],
);
const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0];
const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
);
const position = state.positions.find((p) => p.id === 'BAR')!;
const leadDemo = state.volunteers[1];
if (positions.isLoading || shifts.isLoading) return <Loading />;
const error = positions.error ?? shifts.error ?? volunteers.error;
if (error) return <ErrorNote error={error} />;
if (!position || !shift) return null;
const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!;
const presence = state.presence[assignment.id];
return {
assignmentId: assignment.id,
name: volunteer.fullName,
initials: volunteer.initials,
phone: volunteer.phone,
status:
assignment.status === 'CONFIRMED'
? t('mobileLead.statusConfirmed')
: t('mobileLead.statusProposed'),
present: presence === 'present',
absent: presence === 'absent',
};
});
const team = (shiftAssignments.data ?? [])
.filter(isActive)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
status: statusLabel(a.status),
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
@@ -43,18 +57,18 @@ export function MobileLeadPage() {
<div className="flex flex-wrap items-start gap-[26px] p-6">
<PhoneFrame time={t('mobileLead.statusTime')}>
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">
{t('mobileLead.kicker', { name: leadDemo.fullName })}
</div>
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
{`${position.name} — ${position.zone}`.toUpperCase()}
</div>
<div className="font-display text-[13px] font-semibold opacity-80">
{position.zone} · {position.location}
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
) : null}
<div className="mt-3 flex items-stretch gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
{t('mobileLead.inProgress', {
hours: hourRangeLabel(shift.starts_at, shift.ends_at),
})}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
@@ -81,7 +95,7 @@ export function MobileLeadPage() {
{t('mobileLead.teamTitle')}
</div>
{team.map((member) => (
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md">
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials}
@@ -108,9 +122,7 @@ export function MobileLeadPage() {
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
}
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
className={cx(
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
)}
@@ -119,9 +131,7 @@ export function MobileLeadPage() {
</button>
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
}
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
@@ -129,9 +139,11 @@ export function MobileLeadPage() {
</div>
</div>
))}
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.nextSlot')}
</div>
{team.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</PhoneFrame>
+84 -86
View File
@@ -1,45 +1,54 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning';
import { ENV } from '@/lib/env';
import { t } from '@/i18n';
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
export function MobileVolunteerPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const volunteers = useVolunteers();
const shifts = useShifts();
const positions = usePositions();
const me = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
}, [volunteers.data]);
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const me = state.volunteers[0];
const slots = (index.byVolunteer.get(me.id) ?? [])
.slice()
.sort(
(a, b) =>
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
)
.map((assignment) => {
const position = state.positions.find((p) => p.id === assignment.positionId)!;
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
return {
assignmentId: assignment.id,
day: festivalDay(assignment.dayId).longLabel,
hours: dayPart(assignment.dayPartCode).label,
position: position.name.toUpperCase(),
zone: position.zone,
location: position.location,
leadName: lead.fullName,
leadPhone: lead.phone,
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
confirmed: assignment.status === 'CONFIRMED',
};
});
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error;
if (error) return <ErrorNote error={error} />;
if (!me) return null;
const toConfirm = slots.filter((s) => !s.confirmed).length;
const totalHours = index.totalHours.get(me.id) ?? 0;
const slots = (assignments.data ?? [])
.filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
Boolean(entry.shift),
)
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
const totalHours = Math.round(
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
@@ -47,7 +56,7 @@ export function MobileVolunteerPage() {
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
@@ -66,63 +75,52 @@ export function MobileVolunteerPage() {
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{slots.map((slot) => (
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">{slot.day}</span>
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
{slot.hours}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
{slot.position}
{slots.map(({ assignment, shift }) => {
const position = positionsById.get(shift.position_id);
return (
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
<div className="font-mono text-[12px]">
{t('mobileVol.location', { location: slot.location })}
<div className="p-[11px]">
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{(position?.name ?? '').toUpperCase()}
</div>
<div className="font-mono text-[12px]">
{t('mobileVol.lead', { name: slot.leadName })}
</div>
</div>
<div className="mt-[11px] flex gap-[9px]">
<a
href={`tel:${slot.leadPhoneRaw}`}
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
>
{t('mobileVol.call', { phone: slot.leadPhone })}
</a>
{position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
{assignment.status === 'CONFIRMED' ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
disabled={updateAssignment.isPending}
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
{slot.confirmed ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
);
})}
{slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')}
</div>
))}
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileVol.orgMessageTitle')}
</div>
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
{t('mobileVol.orgMessageBody')}
</p>
<div className="mt-2 font-mono text-[10px] opacity-70">
{t('mobileVol.orgMessageMeta')}
</div>
</div>
) : null}
</div>
</PhoneFrame>
+56 -76
View File
@@ -1,77 +1,86 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader, StatCard } from '@/components/ui';
import { buildDiff, countConcernedVolunteers } from '@/domain/publication';
import { indexPlanning } from '@/domain/selectors';
import { ErrorNote, Loading } from '@/components/state';
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function PublicationPage() {
const { state, dispatch } = usePlanning();
const assignments = useAssignments();
const volunteers = useVolunteers();
const shifts = useShifts();
const positions = usePositions();
const diff = useMemo(() => buildDiff(state), [state]);
const concerned = countConcernedVolunteers(diff);
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
);
const me = state.volunteers[0];
const sampleVolunteer =
diff.length > 0
? state.volunteers.find((v) => v.fullName === diff[0].volunteerName) ?? me
: me;
const sampleFirstName = sampleVolunteer.firstName;
const sampleSlots = (index.byVolunteer.get(sampleVolunteer.id) ?? []).length;
const sampleHours = index.totalHours.get(sampleVolunteer.id) ?? 0;
const when = state.published ? t('time.now') : t('time.yesterday');
const rows = useMemo(() => {
return (assignments.data ?? [])
.filter((a) => a.status === 'PROPOSED')
.map((a) => {
const shift = shiftsById.get(a.shift_id);
const position = shift ? positionsById.get(shift.position_id) : undefined;
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
volunteerName: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
positionName: position?.name ?? '',
label: shift ? `${shortDayLabel(shift.starts_at)} · ${hourRangeLabel(shift.starts_at, shift.ends_at)}` : '',
};
})
.sort((a, b) => a.volunteerName.localeCompare(b.volunteerName, 'fr'));
}, [assignments.data, shiftsById, positionsById, volunteersById]);
if (assignments.isLoading || shifts.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const confirmed = (assignments.data ?? []).filter((a) => a.status === 'CONFIRMED').length;
const concerned = new Set(rows.map((r) => r.volunteerName)).size;
return (
<div className="grid items-start gap-[18px] p-[18px] lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div>
<div className="mb-[14px] grid grid-cols-3 gap-[10px]">
<StatCard value={diff.length} label={t('publication.stat.changes')} tone="yellow" />
<StatCard value={rows.length} label={t('publication.stat.proposed')} tone="yellow" />
<StatCard value={concerned} label={t('publication.stat.concerned')} />
<StatCard value={`v${state.version}`} label={t('publication.stat.version', { when })} />
<StatCard value={confirmed} label={t('publication.stat.confirmed')} tone="green" />
</div>
<Panel shadow="lg">
<div className="flex items-center gap-[10px] bg-hatch-ink px-3 py-[9px] text-yellow">
<span className="font-display text-[13px] font-black tracking-wide">
{t('publication.draftTitle')}
</span>
<span className="ml-auto font-mono text-[9.5px] opacity-90">
{t('publication.diffWith', { version: state.version })}
</span>
</div>
{diff.length === 0 ? (
<PanelHeader hatched>{t('publication.toConfirmTitle')}</PanelHeader>
{rows.length === 0 ? (
<div className="flex items-center gap-3 px-[14px] py-[22px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-extrabold text-white">
✓
</span>
<span className="font-display text-[13px] font-extrabold leading-snug">
{t('publication.noDiff')}
{t('publication.allConfirmed')}
</span>
</div>
) : (
diff.map((entry, i) => (
rows.map((row) => (
<div
key={i}
key={row.id}
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
>
<span
className={cx(
'flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide',
entry.kind === 'ADD' ? 'bg-green text-white' : 'bg-orange text-ink',
)}
>
{entry.kind === 'ADD' ? t('publication.add') : t('publication.remove')}
<span className="flex-none border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('assignment.status.proposed')}
</span>
<span className="min-w-0 flex-1 font-display text-[12.5px] font-bold">
{entry.volunteerName}
{row.volunteerName}
<span className="block font-mono text-[9.5px] opacity-65">
{entry.positionName} {entry.zone} · {entry.dayShortLabel} · {entry.dayPartLabel}
{row.positionName} · {row.label}
</span>
</span>
</div>
@@ -84,51 +93,22 @@ export function PublicationPage() {
<Panel shadow="lg" className="mb-[14px]">
<PanelHeader>{t('publication.mailPreviewTitle')}</PanelHeader>
<div className="px-[14px] py-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailFrom', { from: state.config.mailFrom })}
</div>
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailTo', { count: concerned })}
</div>
<div className="my-2 border-t-2 border-ink pt-[9px] font-display text-[13.5px] font-extrabold leading-snug">
{t('publication.mailSubjectPrefix', { subject: state.config.mailSubject })}
{t('publication.mailSubject')}
</div>
<p className="font-display text-[12.5px] leading-relaxed">
{t('publication.mailGreeting', { name: sampleFirstName })}
<br />
<br />
{t('publication.mailBody', { slots: sampleSlots, hours: sampleHours })}
<br />
<br />
{t('publication.mailBody2')}
</p>
<p className="font-display text-[12.5px] leading-relaxed">{t('publication.mailBody')}</p>
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
{t('publication.mailCta')}
</div>
<div className="mt-[11px] font-mono text-[9px] leading-relaxed opacity-55">
{t('publication.magicLinkNote', { days: state.config.magicLinkDays })}
</div>
</div>
</Panel>
{diff.length > 0 ? (
<>
<button
type="button"
onClick={() => dispatch({ type: 'publish' })}
className="w-full cursor-pointer border-[3px] border-ink bg-orange p-4 font-display text-[16px] font-black uppercase tracking-wide shadow-brutal-lg hover:bg-yellow"
>
{t('publication.publishButton', { count: concerned })}
</button>
<div className="mt-[9px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.publishNote', { version: state.version + 1 })}
</div>
</>
) : (
<div className="border-[3px] border-ink bg-green p-4 font-display text-[15px] font-black leading-snug text-white shadow-brutal-lg">
{t('publication.publishedState', { version: state.version, when })}
</div>
)}
<div className="border-[3px] border-dashed border-ink bg-paper p-4 font-mono text-[10.5px] leading-relaxed opacity-75">
{t('publication.outOfScope')}
</div>
</div>
</div>
);
+110 -121
View File
@@ -1,69 +1,79 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip } from '@/components/ui';
import { DAY_PARTS, FESTIVAL_DAYS } from '@/domain/festival';
import { indexPlanning, isOverloaded } from '@/domain/selectors';
import { ErrorNote, Loading } from '@/components/state';
import { buildLattice, hoursByVolunteer, isActive, isOverloaded } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type VolunteerFilter = 'all' | 'overloaded' | 'unassigned';
type Filter = 'all' | 'overloaded' | 'unassigned';
export function VolunteerViewPage() {
const { state } = usePlanning();
const [filter, setFilter] = useState<VolunteerFilter>('all');
const volunteers = useVolunteers();
const assignments = useAssignments();
const shifts = useShifts();
const positions = usePositions();
const [filter, setFilter] = useState<Filter>('all');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map(state.positions.map((p) => [p.id, p])),
[state.positions],
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const lattice = useMemo(
() => buildLattice(shifts.data ?? []),
[shifts.data],
);
const hours = useMemo(
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
[assignments.data, shiftsById],
);
const rows = useMemo(() => {
return state.volunteers
.filter((volunteer) => {
if (filter === 'overloaded') return isOverloaded(volunteer, index, state.config);
if (filter === 'unassigned') return (index.byVolunteer.get(volunteer.id) ?? []).length === 0;
const all = assignments.data ?? [];
const byVolunteer = new Map<string, AssignmentRead[]>();
for (const a of all) {
if (!isActive(a)) continue;
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
}
return (volunteers.data ?? [])
.filter((v) => {
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'overloaded') return isOverloaded(v.id, all, shiftsById);
if (filter === 'unassigned') return own.length === 0;
return true;
})
.map((volunteer) => {
const own = index.byVolunteer.get(volunteer.id) ?? [];
const over = isOverloaded(volunteer, index, state.config);
const cells = FESTIVAL_DAYS.flatMap((day) =>
DAY_PARTS.map((part) => {
const assignment = own.find(
(a) => a.dayId === day.id && a.dayPartCode === part.code,
);
if (assignment) {
const position = positionsById.get(assignment.positionId);
return {
kind: assignment.status === 'CONFIRMED' ? ('confirmed' as const) : ('proposed' as const),
code: (position?.id ?? assignment.positionId).slice(0, 4),
title: `${position?.name ?? ''} ${position?.zone ?? ''} · ${
assignment.status === 'CONFIRMED'
? t('assignment.status.confirmed')
: t('assignment.status.proposed')
}`.trim(),
};
}
if (volunteer.availability.has(`${day.id}.${part.code}`)) {
return { kind: 'free' as const, code: '', title: '' };
}
return { kind: 'none' as const, code: '', title: '' };
}),
);
.map((v) => {
const own = byVolunteer.get(v.id) ?? [];
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
for (const a of own) {
const shift = shiftsById.get(a.shift_id);
if (shift) cellByKey.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, { ...a, shift });
}
return {
volunteer,
cells,
total: index.totalHours.get(volunteer.id) ?? 0,
over,
volunteer: v,
cellByKey,
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
over: isOverloaded(v.id, all, shiftsById),
empty: own.length === 0,
};
});
}, [state.volunteers, state.config, filter, index, positionsById]);
}, [volunteers.data, assignments.data, shiftsById, hours, filter]);
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading) return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error;
if (error) return <ErrorNote error={error} />;
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -80,65 +90,57 @@ export function VolunteerViewPage() {
{t('volunteers.filter.unassigned')}
</Chip>
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatchClass="bg-blue border-ink">
{t('volunteers.legend.confirmed')}
</LegendItem>
<LegendItem swatchClass="bg-paper border-ink border-dashed">
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.confirmed')}</LegendItem>
<LegendItem swatch="bg-paper border-ink border-dashed">
{t('volunteers.legend.proposed')}
</LegendItem>
<LegendItem swatchClass="bg-dots-sand border-sand-line">
{t('volunteers.legend.availability')}
</LegendItem>
</div>
</div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div
className="grid w-max border-b-[3px] border-ink"
style={{ gridTemplateColumns: '210px repeat(16, 62px) 84px' }}
>
{/* Ligne 1 */}
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div className="sticky left-0 top-0 z-[7] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('volunteers.count', { count: rows.length })}
</div>
{FESTIVAL_DAYS.map((day) => (
{lattice.days.map((day) => (
<div
key={day.id}
key={day.date}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: 'span 4' }}
style={{ gridColumn: `span ${day.columns.length}` }}
>
<span className="font-display text-[12px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
<span className="font-display text-[12px] font-black">
{shortDayLabel(`${day.date}T12:00`)}
</span>
</div>
))}
<div className="sticky right-0 top-0 z-[7] flex items-center border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider">
{t('volunteers.totalHours')}
</div>
{/* Ligne 2 */}
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
{t('volunteers.name')}
</div>
{FESTIVAL_DAYS.map((day) =>
DAY_PARTS.map((part) => (
<div
key={`${day.id}-${part.code}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{part.label}
</div>
)),
)}
{lattice.columns.map((column) => (
<div
key={column.key}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{column.time}
</div>
))}
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
{t('volunteers.cumulative')}
</div>
{/* Lignes bénévoles */}
{rows.map((row) => (
<VolunteerRow key={row.volunteer.id} row={row} />
<VolunteerRow
key={row.volunteer.id}
row={row}
columns={lattice.columns}
abbr={abbr}
/>
))}
</div>
@@ -151,45 +153,44 @@ export function VolunteerViewPage() {
</div>
) : null}
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('volunteers.footnote', {
maxHours: state.config.maxDailyHours,
restHours: state.config.minRestHours,
})}
{t('volunteers.footnote')}
</div>
</div>
</div>
);
}
function LegendItem({
swatchClass,
children,
}: {
swatchClass: string;
children: React.ReactNode;
}) {
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span className={cx('h-4 w-[30px] border-[1.5px]', swatchClass)} />
<span className={cx('h-4 w-[30px] border-[1.5px]', swatch)} />
{children}
</span>
);
}
type Row = {
volunteer: import('@/domain/types').Volunteer;
cells: Array<{ kind: 'confirmed' | 'proposed' | 'free' | 'none'; code: string; title: string }>;
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
over: boolean;
empty: boolean;
};
function VolunteerRow({ row }: { row: Row }) {
function VolunteerRow({
row,
columns,
abbr,
}: {
row: Row;
columns: { key: string }[];
abbr: (positionId: string) => string;
}) {
return (
<>
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.fullName}
{row.volunteer.first_name} {row.volunteer.last_name}
</span>
{row.over ? (
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
@@ -202,41 +203,29 @@ function VolunteerRow({ row }: { row: Row }) {
</span>
) : null}
</div>
{row.cells.map((cell, i) => {
if (cell.kind === 'confirmed') {
{columns.map((column) => {
const cell = row.cellByKey.get(column.key);
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
if (cell.status === 'CONFIRMED') {
return (
<div
key={i}
title={cell.title}
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
{cell.code}
{abbr(cell.shift.position_id)}
</div>
);
}
if (cell.kind === 'proposed') {
return (
<div
key={i}
title={cell.title}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
>
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
{cell.code}
</span>
</div>
);
}
if (cell.kind === 'free') {
return (
<div
key={i}
className="h-8 border-b border-r border-ink bg-dots-sand"
style={{ backgroundSize: '5px 5px' }}
/>
);
}
return <div key={i} className="h-8 border-b border-r border-ink bg-sand" />;
return (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
>
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
{abbr(cell.shift.position_id)}
</span>
</div>
);
})}
<div
className={cx(
+154 -144
View File
@@ -1,69 +1,93 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
import {
useCreatePosition,
useCreateShift,
useDeleteShift,
usePositions,
useShifts,
useUpdateShift,
} from '@/api/hooks';
import type { ShiftRead } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { categoryLabel } from '@/lib/planning';
import { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function ConfigGrid() {
const { state, dispatch } = usePlanning();
const [positionId, setPositionId] = useState(state.positions[0]?.id ?? '');
const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0];
const positions = usePositions();
const allShifts = useShifts();
const createPosition = useCreatePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
const position =
(positions.data ?? []).find((p) => p.id === selectedId) ?? (positions.data ?? [])[0];
const positionShifts = useShifts(position?.id);
const updateShift = useUpdateShift();
const deleteShift = useDeleteShift();
const createShift = useCreateShift();
const totals = useMemo(() => {
let slots = 0;
let min = 0;
let ideal = 0;
state.positions.forEach((p) =>
p.needs.forEach((need) => {
slots += 1;
min += need.minStaff * dayPartHours(need.dayPartCode);
ideal += need.idealStaff * dayPartHours(need.dayPartCode);
}),
);
return { slots, min, ideal };
}, [state.positions]);
for (const s of allShifts.data ?? []) {
const h = hoursBetween(s.starts_at, s.ends_at);
min += s.min_staff * h;
ideal += s.ideal_staff * h;
}
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
}, [allShifts.data]);
const positionMin = position.needs.reduce(
(sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode),
0,
);
const positionIdeal = position.needs.reduce(
(sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode),
0,
);
if (positions.isLoading) return <Loading />;
if (positions.error) return <ErrorNote error={positions.error} />;
if (!position) {
return (
<div className="p-6">
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
>
{t('config.grid.addPosition')}
</button>
</div>
);
}
const byDay = groupByDay(positionShifts.data ?? []);
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.slots })}
{t('config.grid.positionsHeader', { count: totals.count })}
</div>
{state.positions.map((entry) => {
{(positions.data ?? []).map((entry) => {
const active = entry.id === position.id;
const n = (allShifts.data ?? []).filter((s) => s.position_id === entry.id).length;
return (
<button
key={entry.id}
type="button"
onClick={() => setPositionId(entry.id)}
onClick={() => setSelectedId(entry.id)}
className={cx(
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
active
? 'border-l-[6px] bg-yellow'
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
active ? 'border-l-[6px] bg-yellow' : 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
)}
>
<div className="font-display text-[12.5px] font-bold leading-tight">
{entry.name} <span className="opacity-60">{entry.zone}</span>
<div className="truncate font-display text-[12.5px] font-bold leading-tight">
{entry.name}
</div>
<div className="font-mono text-[9px] opacity-60">
{t('config.grid.slotsCount', { count: entry.needs.length })}
{categoryLabel(entry.category)} · {t('config.grid.slotsCount', { count: n })}
</div>
</button>
);
})}
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addPosition')}
@@ -73,128 +97,89 @@ export function ConfigGrid() {
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div>
<div className="font-display text-[26px] font-black tracking-tight">
{position.name} — {position.zone}
</div>
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10.5px] opacity-65">
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
{categoryLabel(position.category)}
{position.location ? ` · ${position.location}` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionMin })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.minTotal')}
</div>
</div>
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionIdeal })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.idealTotal')}
</div>
</div>
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
</div>
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{FESTIVAL_DAYS.map((day) => (
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
{positionShifts.isLoading ? <Loading /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
{byDay.map(([day, shifts]) => (
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)}
</div>
{DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
if (!need) {
return (
<div
key={part.code}
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
>
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
{part.label}
</span>
<span className="font-display text-[10px] font-semibold opacity-55">
{t('config.grid.closed')}
</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
>
{t('config.grid.open')}
</button>
</div>
);
}
return (
<div
key={part.code}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
{shifts.map((shift) => (
<div
key={shift.id}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[92px] flex-none font-mono text-[11px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
<Stepper
label={t('config.grid.min')}
value={shift.min_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { min_staff: Math.max(0, shift.min_staff + delta) },
})
}
/>
<Stepper
label={t('config.grid.ideal')}
accent
value={shift.ideal_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
})
}
/>
<button
type="button"
onClick={() => deleteShift.mutate(shift.id)}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
<NeedStepper
label={t('config.grid.min')}
value={need.minStaff}
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'min',
delta,
})
}
/>
<NeedStepper
label={t('config.grid.ideal')}
value={need.idealStaff}
accent
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'ideal',
delta,
})
}
/>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.close')}
>
✕
</button>
</div>
);
})}
✕
</button>
</div>
))}
<button
type="button"
onClick={() => {
const start = `${day}T12:00:00`;
const end = `${day}T17:00:00`;
createShift.mutate({
position_id: position.id,
starts_at: start,
ends_at: end,
min_staff: 2,
ideal_staff: 3,
});
}}
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addShift')}
</button>
</div>
))}
{byDay.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.grid.noShifts')}
</div>
) : null}
</div>
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
@@ -205,7 +190,32 @@ export function ConfigGrid() {
);
}
function NeedStepper({
function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> {
const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
const day = datePart(s.starts_at);
map.set(day, [...(map.get(day) ?? []), s]);
}
return [...map.entries()];
}
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
return (
<div
className={cx(
'border-[3px] border-ink px-[11px] py-2 shadow-brutal-md',
accent ? 'bg-yellow' : 'bg-paper',
)}
>
<div className="font-mono text-[20px] font-bold">{value}</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{label}
</div>
</div>
);
}
function Stepper({
label,
value,
accent = false,
+59 -176
View File
@@ -1,112 +1,67 @@
import { usePlanning } from '@/app/planning-store';
import { useState } from 'react';
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const MAIL_VARIABLES = [
'{prenom}',
'{nom}',
'{nb_creneaux}',
'{nb_heures}',
'{poste}',
'{referent}',
'{lien_magique}',
];
const MAIL_VARIABLES = ['{prenom}', '{nom}', '{nb_creneaux}', '{nb_heures}', '{poste}', '{referent}', '{lien_magique}'];
// Écran maquette : l'envoi d'emails et la publication versionnée sont hors
// périmètre (CLAUDE.md §4). Les réglages ci-dessous ne sont pas persistés.
export function ConfigMail() {
const { state, dispatch } = usePlanning();
const { config } = state;
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
const [from, setFrom] = useState('coordination@lessonsdautomne.fr');
const [replyTo, setReplyTo] = useState('marie.bertin@lessonsdautomne.fr');
const [subject, setSubject] = useState('Ton planning bénévole a été mis à jour');
const [body, setBody] = useState(
"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.",
);
return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.mail.from')}</FieldLabel>
<TextInput mono value={config.mailFrom} onChange={(v) => setConfig({ mailFrom: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
<TextInput
mono
value={config.mailReplyTo}
onChange={(v) => setConfig({ mailReplyTo: v })}
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
<input
value={config.mailSubject}
onChange={(e) => setConfig({ mailSubject: e.target.value })}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.body')}</FieldLabel>
<textarea
rows={7}
value={config.mailBody}
onChange={(e) => setConfig({ mailBody: e.target.value })}
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
/>
</label>
<div>
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{MAIL_VARIABLES.map((variable) => (
<span
key={variable}
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
>
{variable}
</span>
))}
</div>
</div>
</div>
</Panel>
<div className="flex flex-col gap-4">
<div className="p-4">
<div className="mb-4 border-[3px] border-dashed border-ink bg-yellow-soft p-3 font-display text-[12px] font-bold">
{t('config.mail.draftBanner')}
</div>
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.mail.sendsTitle')}</PanelHeader>
<div className="flex flex-col gap-[11px] p-[13px]">
<ToggleRow
label={t('config.mail.reminderToggle')}
on={config.reminderEnabled}
onLabel={t('config.mail.on')}
offLabel={t('config.mail.off')}
onToggle={() => setConfig({ reminderEnabled: !config.reminderEnabled })}
/>
<StepperRow
label={t('config.mail.reminderDelay')}
value={t('unit.hours', { count: config.reminderHours })}
onDecrement={() =>
setConfig({ reminderHours: Math.max(12, config.reminderHours - 12) })
}
onIncrement={() =>
setConfig({ reminderHours: Math.min(120, config.reminderHours + 12) })
}
/>
<StepperRow
label={t('config.mail.magicLinkValidity')}
value={t('unit.days', { count: config.magicLinkDays })}
onDecrement={() =>
setConfig({ magicLinkDays: Math.max(15, config.magicLinkDays - 15) })
}
onIncrement={() =>
setConfig({ magicLinkDays: Math.min(90, config.magicLinkDays + 15) })
}
/>
<ToggleRow
label={t('config.mail.smsToggle')}
hint={t('config.mail.smsCost')}
on={config.smsEnabled}
onLabel={t('config.mail.onM')}
offLabel={t('config.mail.offM')}
onToggle={() => setConfig({ smsEnabled: !config.smsEnabled })}
/>
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.mail.from')}</FieldLabel>
<TextInput mono value={from} onChange={setFrom} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
<TextInput mono value={replyTo} onChange={setReplyTo} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.body')}</FieldLabel>
<textarea
rows={7}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
/>
</label>
<div>
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{MAIL_VARIABLES.map((variable) => (
<span
key={variable}
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
>
{variable}
</span>
))}
</div>
</div>
</div>
</Panel>
@@ -114,14 +69,12 @@ export function ConfigMail() {
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
<div className="p-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('config.mail.previewFrom', { from: config.mailFrom })}
{t('config.mail.previewFrom', { from })}
</div>
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
{config.mailSubject}
</div>
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">
{config.mailBody}
{subject}
</div>
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">{body}</div>
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
{t('config.mail.cta')}
</div>
@@ -131,73 +84,3 @@ export function ConfigMail() {
</div>
);
}
function ToggleRow({
label,
hint,
on,
onLabel,
offLabel,
onToggle,
}: {
label: string;
hint?: string;
on: boolean;
onLabel: string;
offLabel: string;
onToggle: () => void;
}) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
{label}
{hint ? (
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
) : null}
</span>
<button
type="button"
onClick={onToggle}
className={cx(
'flex-none cursor-pointer border-2 border-ink px-[11px] py-2 font-display text-[11px] font-extrabold uppercase',
on ? 'bg-green text-white' : 'bg-cream',
)}
>
{on ? onLabel : offLabel}
</button>
</div>
);
}
function StepperRow({
label,
value,
onDecrement,
onIncrement,
}: {
label: string;
value: string;
onDecrement: () => void;
onIncrement: () => void;
}) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px]">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">{label}</span>
<button
type="button"
onClick={onDecrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
>
–
</button>
<span className="min-w-[54px] text-center font-mono text-[16px] font-bold">{value}</span>
<button
type="button"
onClick={onIncrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
>
+
</button>
</div>
);
}
+50 -106
View File
@@ -1,138 +1,82 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { indexPlanning, isOverloaded } from '@/domain/selectors';
import { API_MODE, EDITION_ID } from '@/api';
import { usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
export function ConfigRules() {
const { state, dispatch } = usePlanning();
const { config } = state;
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
const RULE_CODES = [
'SHIFT_OVERLAP',
'OUTSIDE_AVAILABILITY',
'DAILY_HOURS_EXCEEDED',
'INSUFFICIENT_REST',
'MISSING_REQUIRED_SKILL',
] as const;
const overCount = useMemo(() => {
const index = indexPlanning(state.assignments, state.volunteers);
return state.volunteers.filter((v) => isOverloaded(v, index, config)).length;
}, [state.assignments, state.volunteers, config]);
// Écran informatif : les seuils du moteur sont figés (CLAUDE.md §3) et il n'y a
// pas encore d'endpoint de configuration d'édition.
export function ConfigRules() {
const volunteers = useVolunteers();
const positions = usePositions();
const shifts = useShifts();
return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.rules.festivalName')}</FieldLabel>
<input
value={config.festivalName}
onChange={(e) => setConfig({ festivalName: e.target.value })}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[15px] font-black"
/>
</label>
<label className="block">
<FieldLabel>{t('config.rules.dates')}</FieldLabel>
<TextInput value={config.datesLabel} onChange={(v) => setConfig({ datesLabel: v })} />
</label>
<div className="border-t-2 border-ink pt-[11px] font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.datesNote')}
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.editionId')} value={EDITION_ID} mono />
<InfoRow label={t('config.rules.apiMode')} value={API_MODE.toUpperCase()} />
<InfoRow label={t('config.rules.volunteersCount')} value={String(volunteers.data?.length ?? '…')} />
<InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} />
<InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} />
<div className="mt-1 border-t-2 border-ink pt-2 font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.editionNote')}
</div>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.rules.thresholdsTitle')}</PanelHeader>
<div className="flex flex-col gap-[11px] p-[13px]">
<ThresholdRow
label={t('config.rules.maxDaily')}
hint={t('config.rules.maxDailyNote')}
value={t('unit.hours', { count: config.maxDailyHours })}
onDecrement={() => setConfig({ maxDailyHours: Math.max(4, config.maxDailyHours - 1) })}
onIncrement={() => setConfig({ maxDailyHours: Math.min(14, config.maxDailyHours + 1) })}
/>
<ThresholdRow
label={t('config.rules.minRest')}
hint={t('config.rules.minRestNote')}
value={t('unit.hours', { count: config.minRestHours })}
onDecrement={() => setConfig({ minRestHours: Math.max(0, config.minRestHours - 1) })}
onIncrement={() => setConfig({ minRestHours: Math.min(14, config.minRestHours + 1) })}
/>
<div className="flex items-center gap-[11px] border-t-2 border-ink pt-[11px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-orange font-display text-[15px] font-extrabold">
▲
</span>
<span className="font-display text-[13px] font-bold leading-snug">
{t('config.rules.overCount', { count: overCount })}
</span>
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
<InfoRow label={t('config.rules.minRest')} value="8 h" />
<div className="mt-1 font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.thresholdsNote')}
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.overlapNote')}
<div className="mt-2 border-t-2 border-ink pt-2">
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
{t('config.rules.rulesTitle')}
</div>
{RULE_CODES.map((code) => (
<div key={code} className="flex items-baseline gap-2 py-[3px]">
<span className="font-mono text-[9px] opacity-55">{code}</span>
<span className="font-display text-[11px] font-semibold leading-snug">
{t(`validation.${code}.rule`)}
</span>
</div>
))}
</div>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.rules.rolesTitle')}</PanelHeader>
<PanelHeader>{t('config.rules.rolesTitle2')}</PanelHeader>
<div className="flex flex-col gap-[9px] p-[13px]">
<RoleCard
tone="bg-yellow"
title={t('config.rules.roleCoordinator')}
desc={t('config.rules.roleCoordinatorDesc')}
/>
<RoleCard
tone="bg-blue"
title={t('config.rules.roleLead')}
desc={t('config.rules.roleLeadDesc')}
/>
<RoleCard
tone="bg-cream"
title={t('config.rules.roleVolunteer', { count: state.volunteers.length })}
desc={t('config.rules.roleVolunteerDesc')}
/>
<button
type="button"
className="mt-[3px] cursor-pointer border-[3px] border-ink bg-cream p-[11px] font-display text-[11.5px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-yellow"
>
{t('config.rules.designateLead')}
</button>
<RoleCard tone="bg-yellow" title={t('config.rules.roleCoordinator')} desc={t('config.rules.roleCoordinatorDesc')} />
<RoleCard tone="bg-blue" title={t('config.rules.roleLead')} desc={t('config.rules.roleLeadDesc')} />
<RoleCard tone="bg-cream" title={t('config.rules.roleVolunteer')} desc={t('config.rules.roleVolunteerDesc')} />
</div>
</Panel>
</div>
);
}
function ThresholdRow({
label,
hint,
value,
onDecrement,
onIncrement,
}: {
label: string;
hint: string;
value: string;
onDecrement: () => void;
onIncrement: () => void;
}) {
function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
{label}
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
<div className="flex items-baseline justify-between gap-3">
<span className="font-display text-[11px] font-bold">{label}</span>
<span className={mono ? 'font-mono text-[10px] opacity-75' : 'font-display text-[12px] font-extrabold'}>
{value}
</span>
<button
type="button"
onClick={onDecrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
>
–
</button>
<span className="min-w-[48px] text-center font-mono text-[18px] font-bold">{value}</span>
<button
type="button"
onClick={onIncrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
>
+
</button>
</div>
);
}
+154 -128
View File
@@ -1,35 +1,50 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import {
useCreateVolunteer,
useDeleteVolunteer,
useUpdateVolunteer,
useVolunteers,
} from '@/api/hooks';
import type { TshirtSize } from '@/api/schema';
import { FieldLabel, TextInput } from '@/components/ui';
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { ErrorNote, Loading } from '@/components/state';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const TABLE_COLUMNS = 'minmax(0,2fr) minmax(0,1.3fr) minmax(0,1.6fr) minmax(0,1fr)';
function demoEmail(firstName: string, lastName: string): string {
const normalize = (s: string) =>
s
.toLowerCase()
.normalize('NFD')
.replace(/[^a-z]/g, '');
return `${normalize(firstName)}.${normalize(lastName)}@mail.fr`;
}
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
const IMPORT_SAMPLE: Array<[string, string]> = [
['Océane', 'Thibault'],
['Samir', 'Amrani'],
['Lucie', 'Charpentier'],
['Noé', 'Delaunay'],
];
export function ConfigVolunteers() {
const { state, dispatch } = usePlanning();
const [selectedId, setSelectedId] = useState(state.volunteers[0]?.id ?? '');
const selected = state.volunteers.find((v) => v.id === selectedId) ?? state.volunteers[0];
const volunteers = useVolunteers();
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const createVolunteer = useCreateVolunteer();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [skillDraft, setSkillDraft] = useState('');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const selected = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === selectedId) ?? list[0];
}, [volunteers.data, selectedId]);
const rows = state.volunteers.slice(0, 26);
const assignedHours = index.totalHours.get(selected.id) ?? 0;
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
const rows = (volunteers.data ?? []).slice(0, 40);
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
selected && updateVolunteer.mutate({ id: selected.id, body });
const runImport = () => {
for (const [first_name, last_name] of IMPORT_SAMPLE) {
createVolunteer.mutate({ first_name, last_name });
}
};
return (
<div className="flex min-h-0 flex-1 items-stretch">
@@ -49,40 +64,33 @@ export function ConfigVolunteers() {
</span>
<button
type="button"
onClick={() => dispatch({ type: 'runImport' })}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
onClick={runImport}
disabled={createVolunteer.isPending}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.importButton')}
</button>
</div>
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">{state.volunteers.length}</div>
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.profiles')}
</div>
</div>
{state.importedCount > 0 ? (
<div className="flex-none border-[3px] border-ink bg-green px-[14px] py-3 text-white shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">+{state.importedCount}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.imported')}
</div>
</div>
) : null}
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colWanted')}</span>
<span>{t('config.vol.colAvailability')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected.id;
const active = volunteer.id === selected?.id;
return (
<button
key={volunteer.id}
@@ -92,29 +100,23 @@ export function ConfigVolunteers() {
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.initials}
{volunteer.first_name[0]}
{volunteer.last_name[0]}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.fullName}
{volunteer.first_name} {volunteer.last_name}
</span>
{volunteer.imported ? (
<span className="flex-none border-[1.5px] border-ink bg-green px-1 py-[2px] font-display text-[8px] font-extrabold text-white">
{t('config.vol.new')}
</span>
) : null}
</span>
<span className="font-mono text-[11px] opacity-80">{volunteer.phone}</span>
<span className="truncate font-display text-[11px] font-semibold opacity-80">
{volunteer.preferredCategories
.map((c) => t(categoryLabelKey(c)))
.join(' · ')}
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
{volunteer.email ?? '—'}
</span>
<span className="font-mono text-[10.5px] opacity-65">
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
</span>
</button>
);
@@ -122,95 +124,119 @@ export function ConfigVolunteers() {
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.initials}
</span>
<span className="font-display text-[19px] font-black tracking-tight">
{selected.fullName}
</span>
</div>
</div>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.vol.fullName')}</FieldLabel>
<TextInput
value={selected.fullName}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'fullName', value })
}
/>
</label>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput
mono
value={selected.phone}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'phone', value })
}
/>
</label>
<div>
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<div className="truncate border-2 border-ink bg-sand p-[9px] font-mono text-[12px]">
{demoEmail(selected.firstName, selected.lastName)}
{selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.first_name[0]}
{selected.last_name[0]}
</span>
<span className="font-display text-[18px] font-black tracking-tight">
{selected.first_name} {selected.last_name}
</span>
</div>
</div>
<div>
<FieldLabel>{t('config.vol.wanted')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{DEMO_CATEGORIES.map((category) => {
const on = selected.preferredCategories.includes(category);
return (
<div className="flex flex-col gap-3 p-[13px]">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
</label>
</div>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.tshirt')}</FieldLabel>
<select
value={selected.tshirt_size ?? ''}
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
>
<option value="">—</option>
{TSHIRT_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<div>
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.skill_codes.map((code) => (
<button
key={category}
key={code}
type="button"
onClick={() =>
dispatch({ type: 'togglePreference', id: selected.id, category })
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
}
className={cx(
'cursor-pointer border-2 border-ink px-2 py-[6px] font-display text-[10.5px]',
on ? 'bg-blue font-extrabold' : 'bg-cream font-semibold hover:bg-yellow',
)}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{on ? '★ ' : ''}
{t(categoryLabelKey(category))}
{code} ✕
</button>
);
})}
</div>
</div>
<div className="flex gap-[9px] border-t-2 border-ink pt-[11px]">
<div className="flex-1 border-2 border-ink bg-cream p-2">
<div className="font-mono text-[17px] font-bold">
{t('unit.hours', { count: assignedHours })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.hoursAssigned')}
))}
{selected.skill_codes.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
) : null}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const code = skillDraft.trim().toUpperCase();
if (code && !selected.skill_codes.includes(code)) {
patch({ skill_codes: [...selected.skill_codes, code] });
}
setSkillDraft('');
}}
className="flex gap-2"
>
<input
value={skillDraft}
onChange={(e) => setSkillDraft(e.target.value)}
placeholder={t('config.vol.skillPlaceholder')}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
/>
<button
type="submit"
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
>
{t('action.add')}
</button>
</form>
</div>
<div
className="flex-1 border-2 border-ink bg-dots-sand p-2"
style={{ backgroundSize: '5px 5px' }}
<button
type="button"
onClick={() => {
if (window.confirm(t('config.vol.deleteConfirm'))) {
deleteVolunteer.mutate(selected.id);
setSelectedId(null);
}
}}
className="mt-2 cursor-pointer border-[3px] border-ink bg-cream p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-orange"
>
<div className="font-mono text-[11px] leading-tight">
{t('config.vol.availabilityValue', { count: selected.availability.size })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.availabilityDeclared')}
</div>
{t('config.vol.delete')}
</button>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
</aside>
</aside>
) : null}
</div>
);
}
+217 -162
View File
@@ -1,109 +1,103 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { ApiError } from '@/api';
import {
useAssignments,
useCreateAssignment,
useDeleteAssignment,
usePositions,
useShifts,
useUpdateAssignment,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import { Badge, Btn } from '@/components/ui';
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
import type { PlanningIndex } from '@/domain/selectors';
import { Loading } from '@/components/state';
import { categoryLabel, hoursByVolunteer, isActive, violationText } from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
function violationText(violation: Violation): string {
return t(`validation.${violation.code}`, violation.params);
interface Props {
positionName: string;
positionLocation: string | null;
positionCategory: PositionCategory;
shift: MatrixShift;
onClose: () => void;
}
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
const { state, dispatch } = usePlanning();
const [positionId, dayId, dayPartCode] = slot.split('|');
const MAX_CANDIDATES = 10;
const positionsById = useMemo(
() => new Map(state.positions.map((p) => [p.id, p])),
[state.positions],
export function AssignmentPanel({
positionName,
positionLocation,
positionCategory,
shift,
onClose,
}: Props) {
const volunteers = useVolunteers();
const allAssignments = useAssignments();
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
const shifts = useShifts();
const positions = usePositions();
const deleteAssignment = useDeleteAssignment();
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const position = positionsById.get(positionId);
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const hours = useMemo(
() => hoursByVolunteer(allAssignments.data ?? [], shiftsById),
[allAssignments.data, shiftsById],
);
const slotsByVolunteer = useMemo(() => {
const map = new Map<string, number>();
for (const a of allAssignments.data ?? []) {
if (isActive(a)) map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
}
return map;
}, [allAssignments.data]);
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
const durationHours = dayPartHours(dayPartCode);
const resolvePosition = (shiftId: string): string | undefined => {
const s = shiftsById.get(shiftId);
const p = s ? positionsById.get(s.position_id) : undefined;
return p?.name;
};
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
const candidates = useMemo(() => {
if (!position || !need) return [];
return state.volunteers
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
.map((volunteer) => {
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
const violations = evaluateAssignment({
volunteer,
target: { position, dayId, dayPartCode },
currentAssignments,
positionsById,
thresholds: {
maxDailyHours: state.config.maxDailyHours,
minRestHours: state.config.minRestHours,
},
});
const blocked = hasBlocking(violations);
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
const warnings = violations.filter((v) => v.severity === 'WARNING');
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
const wanted = volunteer.preferredCategories.includes(position.category);
const hours = index.totalHours.get(volunteer.id) ?? 0;
const score =
(available ? 100 : 0) +
(wanted ? 40 : 0) -
warnings.length * 12 -
hours * 2 -
(blocked ? 1000 : 0);
return {
volunteer,
blocked,
blocking,
warnings,
available,
wanted,
hours,
slots: currentAssignments.length,
score,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, 9);
}, [
position,
need,
state.volunteers,
state.config.maxDailyHours,
state.config.minRestHours,
assignedList,
index,
positionsById,
dayId,
dayPartCode,
]);
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
return (volunteers.data ?? [])
.filter((v) => !assignedIds.has(v.id))
.sort((a, b) => (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0))
.slice(0, MAX_CANDIDATES);
}, [volunteers.data, assigned, hours]);
if (!position || !need) {
return (
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
{t('grille.panel.empty.body')}
</div>
);
if (volunteers.isLoading || shiftAssignments.isLoading) {
return <Loading />;
}
const day = festivalDay(dayId);
const part = dayPart(dayPartCode);
return (
<div>
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="font-display text-[16px] font-black tracking-tight">{position.name}</div>
<div className="font-display text-[16px] font-black tracking-tight">{positionName}</div>
<div className="font-mono text-[10px] text-yellow">
{position.zone} · {position.location}
{categoryLabel(positionCategory)}
{positionLocation ? ` · ${positionLocation}` : ''}
</div>
</div>
<button
type="button"
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
onClick={onClose}
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
aria-label={t('action.close')}
>
@@ -112,31 +106,34 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
</div>
<div className="mt-[9px] flex items-stretch gap-2">
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
{day.shortLabel} · {part.label}
{shortDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
{t('grille.panel.slotDuration', { hours: durationHours })}
</div>
</div>
<div className="mt-[9px] flex gap-[14px] font-mono text-[10px]">
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
<span>
{t('grille.panel.assigned')}{' '}
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
{t('grille.panel.confirmed')}{' '}
<b className="text-[15px]">{shift.confirmed}</b>
</span>
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
<span className="opacity-70">
{t('grille.panel.proposedCount', { count: shift.proposed })}
</span>
<span className="opacity-70">{t('grille.panel.min', { count: shift.min_staff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: shift.ideal_staff })}</span>
</div>
</div>
<div className="border-b-2 border-ink px-[13px] py-3">
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.assignmentsTitle', { count: assignedList.length })}
{t('grille.panel.assignmentsTitle', { count: assigned.length })}
</div>
{assignedList.length === 0 ? (
{assigned.length === 0 ? (
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
) : (
assignedList.map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
assigned.map((assignment) => {
const volunteer = volunteers.data?.find((v) => v.id === assignment.volunteer_id);
if (!volunteer) return null;
return (
<div
@@ -144,24 +141,30 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
>
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
{volunteer.initials}
{initials(volunteer)}
</span>
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
{volunteer.fullName}
{volunteer.first_name} {volunteer.last_name}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursTotal', {
hours: index.totalHours.get(volunteer.id) ?? 0,
})}
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
</span>
</span>
{assignment.status === 'CONFIRMED' ? (
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
) : (
<Badge tone="proposed">{t('assignment.status.proposed')}</Badge>
<button
type="button"
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide hover:bg-green hover:text-white"
>
{t('assignment.status.proposed')}
</button>
)}
<button
type="button"
onClick={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
onClick={() => deleteAssignment.mutate(assignment.id)}
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
@@ -178,74 +181,126 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.candidates')}
</span>
<span className="font-mono text-[9px] opacity-60">
{t('grille.panel.candidatesHint')}
</span>
<span className="font-mono text-[9px] opacity-60">{t('grille.panel.candidatesHint')}</span>
</div>
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
<div
{candidates.map((volunteer) => (
<CandidateRow
key={volunteer.id}
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
>
<div className="flex items-center gap-2 p-[7px]">
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
{volunteer.initials}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.fullName}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursAssigned', { hours, slots })}
</span>
</span>
{blocked ? (
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
{t('grille.panel.blocked')}
</span>
) : (
<Btn onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
{t('grille.panel.assign')}
</Btn>
)}
</div>
{(available || wanted) && (
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
{available ? (
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.availableTag')}
</span>
) : null}
{wanted ? (
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.wantedTag')}
</span>
) : null}
</div>
)}
{blocked && blocking ? (
<div className="flex items-stretch border-t-2 border-ink">
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
⛔
</span>
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('grille.panel.blockingPrefix', { reason: violationText(blocking) })}
</span>
</div>
) : null}
{!blocked && warnings.length > 0 ? (
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
{t('grille.panel.warningsTitle')}
</div>
{warnings.map((warning, i) => (
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
· {violationText(warning)}
</div>
))}
</div>
) : null}
</div>
volunteer={volunteer}
shiftId={shift.shift_id}
hours={round(hours.get(volunteer.id) ?? 0)}
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
resolvePosition={resolvePosition}
/>
))}
</div>
</div>
);
}
function CandidateRow({
volunteer,
shiftId,
hours,
slots,
resolvePosition,
}: {
volunteer: VolunteerRead;
shiftId: string;
hours: number;
slots: number;
resolvePosition: (shiftId: string) => string | undefined;
}) {
const validation = useValidateAssignment(volunteer.id, shiftId);
const createAssignment = useCreateAssignment();
const blocking = validation.data?.violations.find((v) => v.severity === 'BLOCKING') ?? null;
const warnings = validation.data?.violations.filter((v) => v.severity === 'WARNING') ?? [];
const isBlocked = validation.data?.blocking ?? false;
const assignError =
createAssignment.error instanceof ApiError ? createAssignment.error : null;
return (
<div className="mb-[10px] border-2 border-ink bg-cream shadow-brutal">
<div className="flex items-center gap-2 p-[7px]">
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
{initials(volunteer)}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.first_name} {volunteer.last_name}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursAssigned', { hours, slots })}
</span>
</span>
{validation.isLoading ? (
<span className="h-4 w-4 flex-none animate-spin border-2 border-ink border-t-transparent" />
) : isBlocked ? (
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
{t('grille.panel.blocked')}
</span>
) : (
<Btn
disabled={createAssignment.isPending}
onClick={() =>
createAssignment.mutate({ volunteer_id: volunteer.id, shift_id: shiftId })
}
>
{t('grille.panel.assign')}
</Btn>
)}
</div>
{volunteer.skill_codes.length > 0 ? (
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
{volunteer.skill_codes.map((code) => (
<span
key={code}
className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-mono text-[9px] font-semibold"
>
{code}
</span>
))}
</div>
) : null}
{blocking ? (
<div className="flex items-stretch border-t-2 border-ink">
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
⛔
</span>
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
</span>
</div>
) : null}
{!isBlocked && warnings.length > 0 ? (
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
{t('grille.panel.warningsTitle')}
</div>
{warnings.map((warning, i) => (
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
· {violationText(warning, resolvePosition)}
</div>
))}
</div>
) : null}
{assignError ? (
<div className="border-t-2 border-ink bg-orange px-2 py-[6px] font-display text-[10px] font-bold">
{t(assignError.code)}
</div>
) : null}
</div>
);
}
function initials(v: VolunteerRead): string {
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
}
function round(n: number): number {
return Math.round(n * 10) / 10;
}