Front : programmation des artistes et vœux des bénévoles

Aligne le frontend sur le back-end (étape 13).

- Types : retrait de `tshirt_size` / `diet` (Volunteer et mapping
  d'import) ; ajout de `ArtistRead/Create/Update` et des champs
  `artist_ids` / `wished_position_ids` sur la fiche bénévole.
- Surface API `artists` (HTTP + mock) et hooks
  `useArtists` / `useCreateArtist` / `useUpdateArtist` / `useDeleteArtist`.
- Nouvel onglet Configuration « Programmation » : liste d'artistes
  éditable, colonne par jour de l'édition (création, renommage,
  suppression ; 409 doublon, 422 nom vide).
- Fiche bénévole & formulaire d'ajout manuel : champs t-shirt / régime
  retirés, sélecteurs « Artistes à voir » et « Postes souhaités »
  (puces retirables, 422 si id étranger).
- Mock : jeu de démo enrichi (2 artistes par jour, vœux aléatoires),
  cascade sur suppression d'artiste / de poste, import CSV sans
  t-shirt / régime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 22:22:45 +02:00
co-authored by Claude Sonnet 5
parent 24caece7df
commit 81609eecc5
10 changed files with 533 additions and 99 deletions
+97 -30
View File
@@ -2,6 +2,9 @@ import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
AccountRead,
ArtistCreate,
ArtistRead,
ArtistUpdate,
AssignmentRead,
AssignmentValidateResult,
AvailabilityStatus,
@@ -18,7 +21,6 @@ import type {
PlanningNotificationRequest,
ShiftRead,
TokenResponse,
TshirtSize,
Violation,
} from '../schema';
import { getAuthToken } from '../session';
@@ -90,6 +92,18 @@ const slugCode = (label: string): string =>
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
function resolveWishes<T extends { id: string }>(
pool: T[],
ids: string[],
code: string,
): string[] {
const wanted = [...new Set(ids)];
const known = new Set(pool.map((x) => x.id));
if (wanted.some((id) => !known.has(id))) throw new ApiError(422, code);
return wanted;
}
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
@@ -142,8 +156,6 @@ const IMPORT_HINTS: Record<string, string[]> = {
first_name: ['prenom', 'first name', 'firstname', 'given name'],
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
tshirt_size: ['tshirt', 't shirt', 'taille', 'shirt'],
diet: ['regime', 'alimentaire', 'diet', 'allergie'],
comment: ['commentaire', 'remarque', 'note', 'precision'],
skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'],
};
@@ -160,8 +172,6 @@ const AVAIL_UNAVAILABLE = new Set([
'non', 'n', 'no', '0', 'faux', 'false', 'indispo', 'indisponible', 'absent', '-', 'x-',
]);
const AVAIL_EMPTY = new Set(['', 'na', 'n/a', 'nc']);
const TSHIRT_ALIASES: Record<string, TshirtSize> = { '2XL': 'XXL', '3XL': 'XXXL' };
const TSHIRT_VALUES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
function normalizeText(value: string): string {
return (value ?? '')
@@ -446,6 +456,64 @@ export function createMockApi(): PlanningApi {
},
},
artists: {
list: () =>
delay(
[...state.artists].sort(
(a, b) =>
a.performance_date.localeCompare(b.performance_date) ||
a.name.localeCompare(b.name, 'fr'),
),
),
create: (_edition, body: ArtistCreate) => {
const name = body.name.trim();
if (!name) throw new ApiError(422, 'artist.name_required');
if (
state.artists.some(
(a) => a.name === name && a.performance_date === body.performance_date,
)
) {
throw new ApiError(409, 'artist.duplicate');
}
const now = touch();
const artist: ArtistRead = {
id: `art_${state.seq++}`,
edition_id: state.edition.id,
name,
performance_date: body.performance_date,
created_at: now,
updated_at: now,
};
state.artists.push(artist);
return delay(artist);
},
update: (_edition, id, body: ArtistUpdate) => {
const artist = state.artists.find((a) => a.id === id) ?? notFound();
const name = 'name' in body ? (body.name ?? '').trim() : artist.name;
if (!name) throw new ApiError(422, 'artist.name_required');
const date =
'performance_date' in body && body.performance_date
? body.performance_date
: artist.performance_date;
if (
state.artists.some(
(a) => a.id !== id && a.name === name && a.performance_date === date,
)
) {
throw new ApiError(409, 'artist.duplicate');
}
Object.assign(artist, { name, performance_date: date, updated_at: touch() });
return delay(artist);
},
remove: (_edition, id) => {
state.artists = state.artists.filter((a) => a.id !== id);
state.volunteers.forEach((v) => {
v.artist_ids = v.artist_ids.filter((aid) => aid !== id);
});
return delay(undefined);
},
},
volunteers: {
list: () =>
delay(
@@ -463,15 +531,19 @@ export function createMockApi(): PlanningApi {
const now = touch();
const volunteer = {
id: `v${state.seq++}`,
edition_id: state.positions[0].edition_id,
edition_id: state.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 ?? [],
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
wished_position_ids: resolveWishes(
state.positions,
body.wished_position_ids ?? [],
'position.unknown',
),
created_at: now,
updated_at: now,
};
@@ -486,10 +558,20 @@ export function createMockApi(): PlanningApi {
...('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 } : {}),
...(body.artist_ids != null
? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') }
: {}),
...(body.wished_position_ids != null
? {
wished_position_ids: resolveWishes(
state.positions,
body.wished_position_ids,
'position.unknown',
),
}
: {}),
updated_at: touch(),
});
return delay(v);
@@ -646,10 +728,10 @@ export function createMockApi(): PlanningApi {
last_name: last,
email: null,
phone: null,
tshirt_size: null,
diet: null,
comment: null,
skill_codes: [],
artist_ids: [],
wished_position_ids: [],
created_at: now,
updated_at: now,
};
@@ -661,25 +743,7 @@ export function createMockApi(): PlanningApi {
vol.last_name = last;
if (has(cols.email)) vol.email = emailRaw || null;
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null;
if (has(cols.diet)) vol.diet = cell(row, cols.diet) || null;
if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null;
if (has(cols.tshirt_size)) {
const raw = cell(row, cols.tshirt_size);
const key = raw.toUpperCase().replace(/[\s-]/g, '');
const size = TSHIRT_ALIASES[key] ?? (key as TshirtSize);
if (raw) {
if (TSHIRT_VALUES.includes(size)) {
vol.tshirt_size = size;
} else {
entry.issues.push({
code: 'import.row.invalid_tshirt_size',
severity: 'WARNING',
field: 'tshirt_size',
params: { value: raw },
});
}
}
}
if (has(cols.skills)) {
const tokens = cell(row, cols.skills)
.split(/[;,/\n]+/)
@@ -783,6 +847,9 @@ export function createMockApi(): PlanningApi {
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));
state.volunteers.forEach((v) => {
v.wished_position_ids = v.wished_position_ids.filter((pid) => pid !== id);
});
return delay(undefined);
},
generateShifts: (_edition, id) => {