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 type {
|
||||
ArtistCreate,
|
||||
ArtistUpdate,
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateResult,
|
||||
@@ -48,6 +50,10 @@ export function useDayParts() {
|
||||
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 }) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.volunteers,
|
||||
@@ -111,6 +117,7 @@ function useInvalidate() {
|
||||
const client = useQueryClient();
|
||||
return {
|
||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
|
||||
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() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
@@ -252,6 +288,8 @@ export function useDeletePosition() {
|
||||
invalidate.positions();
|
||||
invalidate.shifts();
|
||||
invalidate.planning();
|
||||
// Le poste disparaît des vœux des bénévoles.
|
||||
invalidate.volunteers();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
ArtistCreate,
|
||||
ArtistUpdate,
|
||||
AssignmentCreate,
|
||||
AssignmentUpdate,
|
||||
AssignmentValidateRequest,
|
||||
@@ -87,6 +89,15 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
remove: (editionId, id) =>
|
||||
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: {
|
||||
list: (editionId) => request(`${base(editionId)}/volunteers`),
|
||||
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { hoursBetween } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
AccountRead,
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
@@ -27,6 +28,7 @@ export const DEMO_COORDINATOR_PASSWORD = 'demo';
|
||||
export interface MockState {
|
||||
edition: EditionRead;
|
||||
dayParts: DayPartRead[];
|
||||
artists: ArtistRead[];
|
||||
volunteers: VolunteerRead[];
|
||||
positions: PositionRead[];
|
||||
shifts: ShiftRead[];
|
||||
@@ -87,6 +89,16 @@ const LAST_NAMES = [
|
||||
'Renard', 'Dupuy',
|
||||
];
|
||||
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 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 availability = new Map<string, Set<string>>();
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
@@ -186,6 +210,11 @@ export function buildMockState(): MockState {
|
||||
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)]] : [];
|
||||
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({
|
||||
id,
|
||||
edition_id,
|
||||
@@ -193,10 +222,10 @@ export function buildMockState(): MockState {
|
||||
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,
|
||||
artist_ids,
|
||||
wished_position_ids,
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
@@ -361,6 +390,7 @@ export function buildMockState(): MockState {
|
||||
return {
|
||||
edition,
|
||||
dayParts,
|
||||
artists,
|
||||
volunteers,
|
||||
positions,
|
||||
shifts,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+32
-12
@@ -15,7 +15,6 @@ export type PositionCategory =
|
||||
// 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.
|
||||
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
||||
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||
export type AuthMethod = 'password' | 'magic_link';
|
||||
@@ -104,10 +103,12 @@ export interface VolunteerRead {
|
||||
last_name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
tshirt_size: TshirtSize | null;
|
||||
diet: string | null;
|
||||
comment: string | null;
|
||||
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;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -117,10 +118,10 @@ export interface VolunteerCreate {
|
||||
last_name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
tshirt_size?: TshirtSize | null;
|
||||
diet?: string | null;
|
||||
comment?: string | null;
|
||||
skill_codes?: string[];
|
||||
artist_ids?: string[];
|
||||
wished_position_ids?: string[];
|
||||
}
|
||||
|
||||
export interface VolunteerUpdate {
|
||||
@@ -128,11 +129,34 @@ export interface VolunteerUpdate {
|
||||
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. */
|
||||
/** null / absent = inchangé ; [] = vider. Idem artist_ids / wished_position_ids. */
|
||||
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'
|
||||
| 'email'
|
||||
| 'phone'
|
||||
| 'tshirt_size'
|
||||
| 'diet'
|
||||
| 'comment'
|
||||
| 'skills';
|
||||
|
||||
@@ -153,8 +175,6 @@ export interface ImportColumnMapping {
|
||||
last_name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
tshirt_size?: string | null;
|
||||
diet?: string | null;
|
||||
comment?: string | null;
|
||||
skills?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
AccountRead,
|
||||
ArtistCreate,
|
||||
ArtistRead,
|
||||
ArtistUpdate,
|
||||
AssignmentCreate,
|
||||
AssignmentRead,
|
||||
AssignmentUpdate,
|
||||
@@ -49,6 +52,12 @@ export interface PlanningApi {
|
||||
update(editionId: string, id: string, body: DayPartUpdate): Promise<DayPartRead>;
|
||||
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: {
|
||||
list(editionId: string): Promise<VolunteerRead[]>;
|
||||
get(editionId: string, id: string): Promise<VolunteerRead>;
|
||||
|
||||
Reference in New Issue
Block a user