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:
co-authored by
Claude Sonnet 5
parent
24caece7df
commit
81609eecc5
@@ -7,6 +7,8 @@ import {
|
|||||||
|
|
||||||
import { EDITION_ID, api } from './index';
|
import { EDITION_ID, api } from './index';
|
||||||
import type {
|
import type {
|
||||||
|
ArtistCreate,
|
||||||
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
@@ -48,6 +50,10 @@ export function useDayParts() {
|
|||||||
return useQuery({ queryKey: ['dayParts', edition], queryFn: () => api.dayParts.list(edition) });
|
return useQuery({ queryKey: ['dayParts', edition], queryFn: () => api.dayParts.list(edition) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useArtists() {
|
||||||
|
return useQuery({ queryKey: ['artists', edition], queryFn: () => api.artists.list(edition) });
|
||||||
|
}
|
||||||
|
|
||||||
export function useVolunteers(options?: { enabled?: boolean }) {
|
export function useVolunteers(options?: { enabled?: boolean }) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.volunteers,
|
queryKey: queryKeys.volunteers,
|
||||||
@@ -111,6 +117,7 @@ function useInvalidate() {
|
|||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
return {
|
return {
|
||||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||||
|
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||||
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
|
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
|
||||||
planning: () => {
|
planning: () => {
|
||||||
@@ -170,6 +177,35 @@ export function useGenerateShifts() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useCreateArtist() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ArtistCreate) => api.artists.create(edition, body),
|
||||||
|
onSuccess: invalidate.artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateArtist() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: ArtistUpdate }) =>
|
||||||
|
api.artists.update(edition, id, body),
|
||||||
|
onSuccess: invalidate.artists,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteArtist() {
|
||||||
|
const invalidate = useInvalidate();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.artists.remove(edition, id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidate.artists();
|
||||||
|
// La suppression retire l'artiste des vœux des bénévoles.
|
||||||
|
invalidate.volunteers();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateVolunteer() {
|
export function useCreateVolunteer() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -252,6 +288,8 @@ export function useDeletePosition() {
|
|||||||
invalidate.positions();
|
invalidate.positions();
|
||||||
invalidate.shifts();
|
invalidate.shifts();
|
||||||
invalidate.planning();
|
invalidate.planning();
|
||||||
|
// Le poste disparaît des vœux des bénévoles.
|
||||||
|
invalidate.volunteers();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ArtistCreate,
|
||||||
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
@@ -87,6 +89,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/day-parts/${id}`, { method: 'DELETE' }),
|
request(`${base(editionId)}/day-parts/${id}`, { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
|
artists: {
|
||||||
|
list: (editionId) => request(`${base(editionId)}/artists`),
|
||||||
|
create: (editionId, body: ArtistCreate) =>
|
||||||
|
request(`${base(editionId)}/artists`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
update: (editionId, id, body: ArtistUpdate) =>
|
||||||
|
request(`${base(editionId)}/artists/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
remove: (editionId, id) =>
|
||||||
|
request(`${base(editionId)}/artists/${id}`, { method: 'DELETE' }),
|
||||||
|
},
|
||||||
volunteers: {
|
volunteers: {
|
||||||
list: (editionId) => request(`${base(editionId)}/volunteers`),
|
list: (editionId) => request(`${base(editionId)}/volunteers`),
|
||||||
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
|
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { hoursBetween } from '@/lib/time';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
@@ -27,6 +28,7 @@ export const DEMO_COORDINATOR_PASSWORD = 'demo';
|
|||||||
export interface MockState {
|
export interface MockState {
|
||||||
edition: EditionRead;
|
edition: EditionRead;
|
||||||
dayParts: DayPartRead[];
|
dayParts: DayPartRead[];
|
||||||
|
artists: ArtistRead[];
|
||||||
volunteers: VolunteerRead[];
|
volunteers: VolunteerRead[];
|
||||||
positions: PositionRead[];
|
positions: PositionRead[];
|
||||||
shifts: ShiftRead[];
|
shifts: ShiftRead[];
|
||||||
@@ -87,6 +89,16 @@ const LAST_NAMES = [
|
|||||||
'Renard', 'Dupuy',
|
'Renard', 'Dupuy',
|
||||||
];
|
];
|
||||||
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
|
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
|
||||||
|
const ARTIST_POOL = [
|
||||||
|
'La Battante',
|
||||||
|
'Orchestre Miroir',
|
||||||
|
'Kntongo',
|
||||||
|
'Louve',
|
||||||
|
'Les Frères Vent',
|
||||||
|
'Nuage Bleu',
|
||||||
|
'Sœur Machine',
|
||||||
|
'Comète',
|
||||||
|
];
|
||||||
|
|
||||||
const NOW = '2026-10-10T09:00:00';
|
const NOW = '2026-10-10T09:00:00';
|
||||||
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
|
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
|
||||||
@@ -168,6 +180,18 @@ export function buildMockState(): MockState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Programmation : 2 artistes par jour de l'édition.
|
||||||
|
const artists: ArtistRead[] = DAY_DATES.flatMap((dateStr, d) =>
|
||||||
|
[0, 1].map((k) => ({
|
||||||
|
id: `art${d * 2 + k}`,
|
||||||
|
edition_id,
|
||||||
|
name: ARTIST_POOL[(d * 2 + k) % ARTIST_POOL.length],
|
||||||
|
performance_date: dateStr,
|
||||||
|
created_at: NOW,
|
||||||
|
updated_at: NOW,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
const volunteers: VolunteerRead[] = [];
|
const volunteers: VolunteerRead[] = [];
|
||||||
const availability = new Map<string, Set<string>>();
|
const availability = new Map<string, Set<string>>();
|
||||||
for (let i = 0; i < 64; i += 1) {
|
for (let i = 0; i < 64; i += 1) {
|
||||||
@@ -186,6 +210,11 @@ export function buildMockState(): MockState {
|
|||||||
availability.set(id, avail);
|
availability.set(id, avail);
|
||||||
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
|
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
|
||||||
const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : [];
|
const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : [];
|
||||||
|
const artist_ids = artists
|
||||||
|
.filter(() => rnd() < 0.25)
|
||||||
|
.map((a) => a.id);
|
||||||
|
const wished_position_ids =
|
||||||
|
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
|
||||||
volunteers.push({
|
volunteers.push({
|
||||||
id,
|
id,
|
||||||
edition_id,
|
edition_id,
|
||||||
@@ -193,10 +222,10 @@ export function buildMockState(): MockState {
|
|||||||
last_name,
|
last_name,
|
||||||
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
|
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
|
||||||
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
|
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,
|
comment: null,
|
||||||
skill_codes,
|
skill_codes,
|
||||||
|
artist_ids,
|
||||||
|
wished_position_ids,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -361,6 +390,7 @@ export function buildMockState(): MockState {
|
|||||||
return {
|
return {
|
||||||
edition,
|
edition,
|
||||||
dayParts,
|
dayParts,
|
||||||
|
artists,
|
||||||
volunteers,
|
volunteers,
|
||||||
positions,
|
positions,
|
||||||
shifts,
|
shifts,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { hoursBetween, naiveMinutes } from '@/lib/time';
|
|||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
|
ArtistCreate,
|
||||||
|
ArtistRead,
|
||||||
|
ArtistUpdate,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilityStatus,
|
AvailabilityStatus,
|
||||||
@@ -18,7 +21,6 @@ import type {
|
|||||||
PlanningNotificationRequest,
|
PlanningNotificationRequest,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
TshirtSize,
|
|
||||||
Violation,
|
Violation,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
import { getAuthToken } from '../session';
|
import { getAuthToken } from '../session';
|
||||||
@@ -90,6 +92,18 @@ const slugCode = (label: string): string =>
|
|||||||
.replace(/[^A-Z0-9]+/g, '_')
|
.replace(/[^A-Z0-9]+/g, '_')
|
||||||
.replace(/^_+|_+$/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] {
|
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
|
||||||
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
||||||
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
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'],
|
first_name: ['prenom', 'first name', 'firstname', 'given name'],
|
||||||
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
|
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
|
||||||
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
|
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
|
||||||
tshirt_size: ['tshirt', 't shirt', 'taille', 'shirt'],
|
|
||||||
diet: ['regime', 'alimentaire', 'diet', 'allergie'],
|
|
||||||
comment: ['commentaire', 'remarque', 'note', 'precision'],
|
comment: ['commentaire', 'remarque', 'note', 'precision'],
|
||||||
skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'],
|
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-',
|
'non', 'n', 'no', '0', 'faux', 'false', 'indispo', 'indisponible', 'absent', '-', 'x-',
|
||||||
]);
|
]);
|
||||||
const AVAIL_EMPTY = new Set(['', 'na', 'n/a', 'nc']);
|
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 {
|
function normalizeText(value: string): string {
|
||||||
return (value ?? '')
|
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: {
|
volunteers: {
|
||||||
list: () =>
|
list: () =>
|
||||||
delay(
|
delay(
|
||||||
@@ -463,15 +531,19 @@ export function createMockApi(): PlanningApi {
|
|||||||
const now = touch();
|
const now = touch();
|
||||||
const volunteer = {
|
const volunteer = {
|
||||||
id: `v${state.seq++}`,
|
id: `v${state.seq++}`,
|
||||||
edition_id: state.positions[0].edition_id,
|
edition_id: state.edition.id,
|
||||||
first_name: body.first_name,
|
first_name: body.first_name,
|
||||||
last_name: body.last_name,
|
last_name: body.last_name,
|
||||||
email: body.email ?? null,
|
email: body.email ?? null,
|
||||||
phone: body.phone ?? null,
|
phone: body.phone ?? null,
|
||||||
tshirt_size: body.tshirt_size ?? null,
|
|
||||||
diet: body.diet ?? null,
|
|
||||||
comment: body.comment ?? null,
|
comment: body.comment ?? null,
|
||||||
skill_codes: body.skill_codes ?? [],
|
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,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -486,10 +558,20 @@ export function createMockApi(): PlanningApi {
|
|||||||
...('last_name' in body ? { last_name: body.last_name } : {}),
|
...('last_name' in body ? { last_name: body.last_name } : {}),
|
||||||
...('email' in body ? { email: body.email ?? null } : {}),
|
...('email' in body ? { email: body.email ?? null } : {}),
|
||||||
...('phone' in body ? { phone: body.phone ?? 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 } : {}),
|
...('comment' in body ? { comment: body.comment ?? null } : {}),
|
||||||
...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}),
|
...(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(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
return delay(v);
|
return delay(v);
|
||||||
@@ -646,10 +728,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
last_name: last,
|
last_name: last,
|
||||||
email: null,
|
email: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
tshirt_size: null,
|
|
||||||
diet: null,
|
|
||||||
comment: null,
|
comment: null,
|
||||||
skill_codes: [],
|
skill_codes: [],
|
||||||
|
artist_ids: [],
|
||||||
|
wished_position_ids: [],
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -661,25 +743,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
vol.last_name = last;
|
vol.last_name = last;
|
||||||
if (has(cols.email)) vol.email = emailRaw || null;
|
if (has(cols.email)) vol.email = emailRaw || null;
|
||||||
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || 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.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)) {
|
if (has(cols.skills)) {
|
||||||
const tokens = cell(row, cols.skills)
|
const tokens = cell(row, cols.skills)
|
||||||
.split(/[;,/\n]+/)
|
.split(/[;,/\n]+/)
|
||||||
@@ -783,6 +847,9 @@ export function createMockApi(): PlanningApi {
|
|||||||
state.positions = state.positions.filter((x) => x.id !== id);
|
state.positions = state.positions.filter((x) => x.id !== id);
|
||||||
state.shifts = state.shifts.filter((s) => s.position_id !== id);
|
state.shifts = state.shifts.filter((s) => s.position_id !== id);
|
||||||
state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_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);
|
return delay(undefined);
|
||||||
},
|
},
|
||||||
generateShifts: (_edition, id) => {
|
generateShifts: (_edition, id) => {
|
||||||
|
|||||||
+32
-12
@@ -15,7 +15,6 @@ export type PositionCategory =
|
|||||||
// Depuis l'Étape 9 back-end : une affectation est valide dès sa création,
|
// Depuis l'Étape 9 back-end : une affectation est valide dès sa création,
|
||||||
// il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne.
|
// il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne.
|
||||||
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||||
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
|
||||||
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
||||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||||
export type AuthMethod = 'password' | 'magic_link';
|
export type AuthMethod = 'password' | 'magic_link';
|
||||||
@@ -104,10 +103,12 @@ export interface VolunteerRead {
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
tshirt_size: TshirtSize | null;
|
|
||||||
diet: string | null;
|
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
skill_codes: string[];
|
skill_codes: string[];
|
||||||
|
/** Vœux : artistes que le bénévole veut voir. */
|
||||||
|
artist_ids: string[];
|
||||||
|
/** Vœux : postes que le bénévole voudrait tenir. */
|
||||||
|
wished_position_ids: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -117,10 +118,10 @@ export interface VolunteerCreate {
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
tshirt_size?: TshirtSize | null;
|
|
||||||
diet?: string | null;
|
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
skill_codes?: string[];
|
skill_codes?: string[];
|
||||||
|
artist_ids?: string[];
|
||||||
|
wished_position_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolunteerUpdate {
|
export interface VolunteerUpdate {
|
||||||
@@ -128,11 +129,34 @@ export interface VolunteerUpdate {
|
|||||||
last_name?: string;
|
last_name?: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
tshirt_size?: TshirtSize | null;
|
|
||||||
diet?: string | null;
|
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** null / absent = inchangé ; [] = vider les compétences. */
|
/** null / absent = inchangé ; [] = vider. Idem artist_ids / wished_position_ids. */
|
||||||
skill_codes?: string[] | null;
|
skill_codes?: string[] | null;
|
||||||
|
artist_ids?: string[] | null;
|
||||||
|
wished_position_ids?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Artist (programmation) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface ArtistRead {
|
||||||
|
id: string;
|
||||||
|
edition_id: string;
|
||||||
|
name: string;
|
||||||
|
/** Jour de passage, date ISO (YYYY-MM-DD). */
|
||||||
|
performance_date: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistCreate {
|
||||||
|
name: string;
|
||||||
|
performance_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistUpdate {
|
||||||
|
name?: string;
|
||||||
|
performance_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -143,8 +167,6 @@ export type ImportFieldKey =
|
|||||||
| 'last_name'
|
| 'last_name'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'phone'
|
| 'phone'
|
||||||
| 'tshirt_size'
|
|
||||||
| 'diet'
|
|
||||||
| 'comment'
|
| 'comment'
|
||||||
| 'skills';
|
| 'skills';
|
||||||
|
|
||||||
@@ -153,8 +175,6 @@ export interface ImportColumnMapping {
|
|||||||
last_name: string;
|
last_name: string;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
tshirt_size?: string | null;
|
|
||||||
diet?: string | null;
|
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
skills?: string | null;
|
skills?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
|
ArtistCreate,
|
||||||
|
ArtistRead,
|
||||||
|
ArtistUpdate,
|
||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
@@ -49,6 +52,12 @@ export interface PlanningApi {
|
|||||||
update(editionId: string, id: string, body: DayPartUpdate): Promise<DayPartRead>;
|
update(editionId: string, id: string, body: DayPartUpdate): Promise<DayPartRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
};
|
};
|
||||||
|
artists: {
|
||||||
|
list(editionId: string): Promise<ArtistRead[]>;
|
||||||
|
create(editionId: string, body: ArtistCreate): Promise<ArtistRead>;
|
||||||
|
update(editionId: string, id: string, body: ArtistUpdate): Promise<ArtistRead>;
|
||||||
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
|
};
|
||||||
volunteers: {
|
volunteers: {
|
||||||
list(editionId: string): Promise<VolunteerRead[]>;
|
list(editionId: string): Promise<VolunteerRead[]>;
|
||||||
get(editionId: string, id: string): Promise<VolunteerRead>;
|
get(editionId: string, id: string): Promise<VolunteerRead>;
|
||||||
|
|||||||
@@ -169,7 +169,6 @@
|
|||||||
"config.vol.lastName": "NOM",
|
"config.vol.lastName": "NOM",
|
||||||
"config.vol.phone": "TÉLÉPHONE",
|
"config.vol.phone": "TÉLÉPHONE",
|
||||||
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
||||||
"config.vol.tshirt": "TAILLE DE T-SHIRT",
|
|
||||||
"config.vol.skills": "COMPÉTENCES",
|
"config.vol.skills": "COMPÉTENCES",
|
||||||
"config.vol.noSkills": "Aucune compétence déclarée",
|
"config.vol.noSkills": "Aucune compétence déclarée",
|
||||||
"config.vol.skillPlaceholder": "Code (ex. CAISSE)",
|
"config.vol.skillPlaceholder": "Code (ex. CAISSE)",
|
||||||
@@ -231,8 +230,6 @@
|
|||||||
"config.vol.field.lastName": "Nom",
|
"config.vol.field.lastName": "Nom",
|
||||||
"config.vol.field.email": "Email (lien magique)",
|
"config.vol.field.email": "Email (lien magique)",
|
||||||
"config.vol.field.phone": "Téléphone",
|
"config.vol.field.phone": "Téléphone",
|
||||||
"config.vol.field.tshirt": "Taille de t-shirt",
|
|
||||||
"config.vol.field.diet": "Régime alimentaire",
|
|
||||||
"config.vol.field.comment": "Commentaire",
|
"config.vol.field.comment": "Commentaire",
|
||||||
"config.vol.field.skills": "Compétences",
|
"config.vol.field.skills": "Compétences",
|
||||||
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
||||||
@@ -290,7 +287,19 @@
|
|||||||
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
||||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.",
|
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.",
|
||||||
"import.row.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.",
|
|
||||||
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
"import.row.skill_created": "Nouvelle compétence créée : {label}.",
|
||||||
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »)."
|
"import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).",
|
||||||
|
"config.tab.artists": "PROGRAMMATION",
|
||||||
|
"config.artists.title": "PROGRAMMATION",
|
||||||
|
"config.artists.hint": "La liste des artistes de l'édition, jour par jour. Les bénévoles y choisissent ceux qu'ils veulent voir depuis leur fiche.",
|
||||||
|
"config.artists.count": "ARTISTES",
|
||||||
|
"config.artists.empty": "Aucun artiste ce jour.",
|
||||||
|
"config.artists.outside": "hors dates",
|
||||||
|
"config.artists.namePlaceholder": "Nom de l'artiste",
|
||||||
|
"config.vol.field.artists": "Artistes à voir",
|
||||||
|
"config.vol.field.positions": "Postes souhaités",
|
||||||
|
"config.vol.noArtists": "Aucun artiste choisi",
|
||||||
|
"config.vol.noPositions": "Aucun poste choisi",
|
||||||
|
"config.vol.pickArtist": "Ajouter un artiste…",
|
||||||
|
"config.vol.pickPosition": "Ajouter un poste…"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Chip } from '@/components/ui';
|
import { Chip } from '@/components/ui';
|
||||||
|
import { ConfigArtists } from '@/pages/config/ConfigArtists';
|
||||||
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
import { ConfigGrid } from '@/pages/config/ConfigGrid';
|
||||||
import { ConfigMail } from '@/pages/config/ConfigMail';
|
import { ConfigMail } from '@/pages/config/ConfigMail';
|
||||||
import { ConfigRules } from '@/pages/config/ConfigRules';
|
import { ConfigRules } from '@/pages/config/ConfigRules';
|
||||||
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
type ConfigTab = 'grid' | 'volunteers' | 'mail' | 'rules';
|
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules';
|
||||||
|
|
||||||
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
|
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
|
||||||
{ id: 'grid', labelKey: 'config.tab.grid' },
|
{ id: 'grid', labelKey: 'config.tab.grid' },
|
||||||
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
|
||||||
|
{ id: 'artists', labelKey: 'config.tab.artists' },
|
||||||
{ id: 'mail', labelKey: 'config.tab.mail' },
|
{ id: 'mail', labelKey: 'config.tab.mail' },
|
||||||
{ id: 'rules', labelKey: 'config.tab.rules' },
|
{ id: 'rules', labelKey: 'config.tab.rules' },
|
||||||
];
|
];
|
||||||
@@ -35,6 +37,7 @@ export function ConfigPage() {
|
|||||||
|
|
||||||
{tab === 'grid' ? <ConfigGrid /> : null}
|
{tab === 'grid' ? <ConfigGrid /> : null}
|
||||||
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
||||||
|
{tab === 'artists' ? <ConfigArtists /> : null}
|
||||||
{tab === 'mail' ? <ConfigMail /> : null}
|
{tab === 'mail' ? <ConfigMail /> : null}
|
||||||
{tab === 'rules' ? <ConfigRules /> : null}
|
{tab === 'rules' ? <ConfigRules /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useArtists,
|
||||||
|
useCreateArtist,
|
||||||
|
useDeleteArtist,
|
||||||
|
useEdition,
|
||||||
|
useUpdateArtist,
|
||||||
|
} from '@/api/hooks';
|
||||||
|
import type { ArtistRead } from '@/api/schema';
|
||||||
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { Panel, PanelHeader } from '@/components/ui';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { longDayLabel } from '@/lib/time';
|
||||||
|
|
||||||
|
function editionDays(start: string, end: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (
|
||||||
|
let d = Date.parse(`${start}T00:00:00Z`);
|
||||||
|
d <= Date.parse(`${end}T00:00:00Z`);
|
||||||
|
d += 86_400_000
|
||||||
|
) {
|
||||||
|
out.push(new Date(d).toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Programmation de l'édition : la liste d'artistes que les bénévoles pourront
|
||||||
|
// choisir (voir onglet « Bénévoles & import »). Constituée jour par jour.
|
||||||
|
export function ConfigArtists() {
|
||||||
|
const edition = useEdition();
|
||||||
|
const artists = useArtists();
|
||||||
|
|
||||||
|
const byDate = useMemo(() => {
|
||||||
|
const map = new Map<string, ArtistRead[]>();
|
||||||
|
for (const a of artists.data ?? []) {
|
||||||
|
const list = map.get(a.performance_date) ?? [];
|
||||||
|
list.push(a);
|
||||||
|
map.set(a.performance_date, list);
|
||||||
|
}
|
||||||
|
for (const list of map.values()) {
|
||||||
|
list.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [artists.data]);
|
||||||
|
|
||||||
|
if (edition.isLoading || artists.isLoading) return <Loading />;
|
||||||
|
const error = edition.error ?? artists.error;
|
||||||
|
if (error) return <ErrorNote error={error} />;
|
||||||
|
|
||||||
|
const days = edition.data
|
||||||
|
? editionDays(edition.data.start_date, edition.data.end_date)
|
||||||
|
: [];
|
||||||
|
const extraDays = [...byDate.keys()].filter((d) => !days.includes(d)).sort();
|
||||||
|
const total = artists.data?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-h-0 flex-1 overflow-auto p-4">
|
||||||
|
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
||||||
|
<div className="flex flex-1 flex-col justify-center gap-1 border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||||
|
<span className="font-display text-[15px] font-black">{t('config.artists.title')}</span>
|
||||||
|
<span className="font-mono text-[10px] leading-relaxed opacity-70">
|
||||||
|
{t('config.artists.hint')}
|
||||||
|
</span>
|
||||||
|
</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">{total}</div>
|
||||||
|
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.artists.count')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
|
||||||
|
{days.map((date) => (
|
||||||
|
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
||||||
|
))}
|
||||||
|
{extraDays.map((date) => (
|
||||||
|
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} outside />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayColumn({
|
||||||
|
date,
|
||||||
|
artists,
|
||||||
|
outside = false,
|
||||||
|
}: {
|
||||||
|
date: string;
|
||||||
|
artists: ArtistRead[];
|
||||||
|
outside?: boolean;
|
||||||
|
}) {
|
||||||
|
const create = useCreateArtist();
|
||||||
|
const update = useUpdateArtist();
|
||||||
|
const remove = useDeleteArtist();
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const name = draft.trim();
|
||||||
|
if (!name) return;
|
||||||
|
create.mutate(
|
||||||
|
{ name, performance_date: date },
|
||||||
|
{ onSuccess: () => setDraft('') },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>
|
||||||
|
{longDayLabel(`${date}T12:00`)}
|
||||||
|
{outside ? ` · ${t('config.artists.outside')}` : ''}
|
||||||
|
</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-[6px] p-[13px]">
|
||||||
|
{artists.length === 0 ? (
|
||||||
|
<div className="font-mono text-[10px] opacity-55">{t('config.artists.empty')}</div>
|
||||||
|
) : null}
|
||||||
|
{artists.map((a) => (
|
||||||
|
<div key={a.id} className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
defaultValue={a.name}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const v = e.target.value.trim();
|
||||||
|
if (v && v !== a.name) update.mutate({ id: a.id, body: { name: v } });
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove.mutate(a.id)}
|
||||||
|
aria-label={t('action.remove')}
|
||||||
|
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
add();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('config.artists.namePlaceholder')}
|
||||||
|
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draft.trim() || create.isPending}
|
||||||
|
onClick={add}
|
||||||
|
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('action.add')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{create.isError ? <ErrorNote error={create.error} /> : null}
|
||||||
|
{update.isError ? <ErrorNote error={update.error} /> : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,19 +3,22 @@ import { useMemo, useRef, useState } from 'react';
|
|||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import {
|
import {
|
||||||
useAnalyzeImport,
|
useAnalyzeImport,
|
||||||
|
useArtists,
|
||||||
useCreateVolunteer,
|
useCreateVolunteer,
|
||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
|
usePositions,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type {
|
import type {
|
||||||
|
ArtistRead,
|
||||||
ImportAnalyzeResponse,
|
ImportAnalyzeResponse,
|
||||||
ImportFieldKey,
|
ImportFieldKey,
|
||||||
ImportIssue,
|
ImportIssue,
|
||||||
ImportMapping,
|
ImportMapping,
|
||||||
ImportReport,
|
ImportReport,
|
||||||
TshirtSize,
|
PositionRead,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { FieldLabel, TextInput } from '@/components/ui';
|
import { FieldLabel, TextInput } from '@/components/ui';
|
||||||
@@ -24,15 +27,11 @@ import { t } from '@/i18n';
|
|||||||
import { shortDayLabel } from '@/lib/time';
|
import { shortDayLabel } from '@/lib/time';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
|
||||||
|
|
||||||
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
||||||
{ key: 'first_name', required: true },
|
{ key: 'first_name', required: true },
|
||||||
{ key: 'last_name', required: true },
|
{ key: 'last_name', required: true },
|
||||||
{ key: 'email', required: false },
|
{ key: 'email', required: false },
|
||||||
{ key: 'phone', required: false },
|
{ key: 'phone', required: false },
|
||||||
{ key: 'tshirt_size', required: false },
|
|
||||||
{ key: 'diet', required: false },
|
|
||||||
{ key: 'comment', required: false },
|
{ key: 'comment', required: false },
|
||||||
{ key: 'skills', required: false },
|
{ key: 'skills', required: false },
|
||||||
];
|
];
|
||||||
@@ -42,12 +41,87 @@ const FIELD_LABEL: Record<ImportFieldKey, string> = {
|
|||||||
last_name: 'config.vol.field.lastName',
|
last_name: 'config.vol.field.lastName',
|
||||||
email: 'config.vol.field.email',
|
email: 'config.vol.field.email',
|
||||||
phone: 'config.vol.field.phone',
|
phone: 'config.vol.field.phone',
|
||||||
tshirt_size: 'config.vol.field.tshirt',
|
|
||||||
diet: 'config.vol.field.diet',
|
|
||||||
comment: 'config.vol.field.comment',
|
comment: 'config.vol.field.comment',
|
||||||
skills: 'config.vol.field.skills',
|
skills: 'config.vol.field.skills',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WishOption = { id: string; label: string; hint?: string };
|
||||||
|
|
||||||
|
function artistOptions(artists: ArtistRead[] | undefined): WishOption[] {
|
||||||
|
return [...(artists ?? [])]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.performance_date.localeCompare(b.performance_date) ||
|
||||||
|
a.name.localeCompare(b.name, 'fr'),
|
||||||
|
)
|
||||||
|
.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
label: a.name,
|
||||||
|
hint: shortDayLabel(`${a.performance_date}T12:00`),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionOptions(positions: PositionRead[] | undefined): WishOption[] {
|
||||||
|
return [...(positions ?? [])]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||||
|
.map((p) => ({ id: p.id, label: p.name, hint: p.location ?? undefined }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sélecteur de vœux : liste déroulante + puces retirables. */
|
||||||
|
function WishPicker({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
emptyLabel,
|
||||||
|
addLabel,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: WishOption[];
|
||||||
|
selected: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
emptyLabel: string;
|
||||||
|
addLabel: string;
|
||||||
|
}) {
|
||||||
|
const byId = new Map(options.map((o) => [o.id, o]));
|
||||||
|
const available = options.filter((o) => !selected.includes(o.id));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{label}</FieldLabel>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||||
|
{selected.map((id) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(selected.filter((x) => x !== id))}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
|
||||||
|
>
|
||||||
|
{byId.get(id)?.label ?? id} ✕
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selected.length === 0 ? (
|
||||||
|
<span className="font-mono text-[10px] opacity-55">{emptyLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value) onChange([...selected, e.target.value]);
|
||||||
|
}}
|
||||||
|
disabled={available.length === 0}
|
||||||
|
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">{addLabel}</option>
|
||||||
|
{available.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.hint ? `${o.hint} · ${o.label}` : o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Mode = 'list' | 'add' | 'import';
|
type Mode = 'list' | 'add' | 'import';
|
||||||
|
|
||||||
/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */
|
/** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */
|
||||||
@@ -61,6 +135,8 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
|
|||||||
|
|
||||||
export function ConfigVolunteers() {
|
export function ConfigVolunteers() {
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
|
const artists = useArtists();
|
||||||
|
const positions = usePositions();
|
||||||
const updateVolunteer = useUpdateVolunteer();
|
const updateVolunteer = useUpdateVolunteer();
|
||||||
const deleteVolunteer = useDeleteVolunteer();
|
const deleteVolunteer = useDeleteVolunteer();
|
||||||
|
|
||||||
@@ -69,6 +145,9 @@ export function ConfigVolunteers() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [skillDraft, setSkillDraft] = useState('');
|
const [skillDraft, setSkillDraft] = useState('');
|
||||||
|
|
||||||
|
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
||||||
|
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase();
|
const needle = search.trim().toLowerCase();
|
||||||
const list = [...(volunteers.data ?? [])];
|
const list = [...(volunteers.data ?? [])];
|
||||||
@@ -250,25 +329,6 @@ export function ConfigVolunteers() {
|
|||||||
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
|
||||||
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
|
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
|
||||||
<FieldLabel>{t('config.vol.field.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>
|
|
||||||
<label className="block">
|
|
||||||
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
|
|
||||||
<TextInput value={selected.diet ?? ''} onChange={(v) => patch({ diet: v || null })} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -279,6 +339,23 @@ export function ConfigVolunteers() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<WishPicker
|
||||||
|
label={t('config.vol.field.artists')}
|
||||||
|
options={artistOpts}
|
||||||
|
selected={selected.artist_ids}
|
||||||
|
onChange={(ids) => patch({ artist_ids: ids })}
|
||||||
|
emptyLabel={t('config.vol.noArtists')}
|
||||||
|
addLabel={t('config.vol.pickArtist')}
|
||||||
|
/>
|
||||||
|
<WishPicker
|
||||||
|
label={t('config.vol.field.positions')}
|
||||||
|
options={positionOpts}
|
||||||
|
selected={selected.wished_position_ids}
|
||||||
|
onChange={(ids) => patch({ wished_position_ids: ids })}
|
||||||
|
emptyLabel={t('config.vol.noPositions')}
|
||||||
|
addLabel={t('config.vol.pickPosition')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
||||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||||
@@ -351,11 +428,18 @@ export function ConfigVolunteers() {
|
|||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
||||||
const createVolunteer = useCreateVolunteer();
|
const createVolunteer = useCreateVolunteer();
|
||||||
|
const artists = useArtists();
|
||||||
|
const positions = usePositions();
|
||||||
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
|
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
|
||||||
const [skills, setSkills] = useState<string[]>([]);
|
const [skills, setSkills] = useState<string[]>([]);
|
||||||
|
const [artistIds, setArtistIds] = useState<string[]>([]);
|
||||||
|
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
|
||||||
const [skillDraft, setSkillDraft] = useState('');
|
const [skillDraft, setSkillDraft] = useState('');
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
||||||
|
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
|
||||||
|
|
||||||
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
|
const set = <K extends keyof VolunteerCreate>(key: K, value: VolunteerCreate[K]) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
@@ -371,10 +455,10 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
last_name: form.last_name.trim(),
|
last_name: form.last_name.trim(),
|
||||||
email: form.email?.trim() || null,
|
email: form.email?.trim() || null,
|
||||||
phone: form.phone?.trim() || null,
|
phone: form.phone?.trim() || null,
|
||||||
tshirt_size: form.tshirt_size ?? null,
|
|
||||||
diet: form.diet?.trim() || null,
|
|
||||||
comment: form.comment?.trim() || null,
|
comment: form.comment?.trim() || null,
|
||||||
skill_codes: skills,
|
skill_codes: skills,
|
||||||
|
artist_ids: artistIds,
|
||||||
|
wished_position_ids: wishedPositionIds,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -423,25 +507,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
|
||||||
<TextInput mono value={form.phone ?? ''} onChange={(v) => set('phone', v)} />
|
<TextInput mono value={form.phone ?? ''} onChange={(v) => set('phone', v)} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
|
||||||
<FieldLabel>{t('config.vol.field.tshirt')}</FieldLabel>
|
|
||||||
<select
|
|
||||||
value={form.tshirt_size ?? ''}
|
|
||||||
onChange={(e) => set('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>
|
|
||||||
<label className="block">
|
|
||||||
<FieldLabel>{t('config.vol.field.diet')}</FieldLabel>
|
|
||||||
<TextInput value={form.diet ?? ''} onChange={(v) => set('diet', v)} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -452,6 +517,23 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<WishPicker
|
||||||
|
label={t('config.vol.field.artists')}
|
||||||
|
options={artistOpts}
|
||||||
|
selected={artistIds}
|
||||||
|
onChange={setArtistIds}
|
||||||
|
emptyLabel={t('config.vol.noArtists')}
|
||||||
|
addLabel={t('config.vol.pickArtist')}
|
||||||
|
/>
|
||||||
|
<WishPicker
|
||||||
|
label={t('config.vol.field.positions')}
|
||||||
|
options={positionOpts}
|
||||||
|
selected={wishedPositionIds}
|
||||||
|
onChange={setWishedPositionIds}
|
||||||
|
emptyLabel={t('config.vol.noPositions')}
|
||||||
|
addLabel={t('config.vol.pickPosition')}
|
||||||
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
||||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||||
@@ -567,8 +649,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
last_name: colMap.last_name ?? '',
|
last_name: colMap.last_name ?? '',
|
||||||
email: colMap.email || null,
|
email: colMap.email || null,
|
||||||
phone: colMap.phone || null,
|
phone: colMap.phone || null,
|
||||||
tshirt_size: colMap.tshirt_size || null,
|
|
||||||
diet: colMap.diet || null,
|
|
||||||
comment: colMap.comment || null,
|
comment: colMap.comment || null,
|
||||||
skills: colMap.skills || null,
|
skills: colMap.skills || null,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user