diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index ac26347..e047c6b 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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(); }, }); } diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 8c6bdd1..443aece 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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}`), diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index d63d9a4..6490ce5 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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>(); 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, diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 8d02867..865fdbf 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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( + 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 = { 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 = { '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) => { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 86b7bbb..be44355 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 9d0df4e..b19d755 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; remove(editionId: string, id: string): Promise; }; + artists: { + list(editionId: string): Promise; + create(editionId: string, body: ArtistCreate): Promise; + update(editionId: string, id: string, body: ArtistUpdate): Promise; + remove(editionId: string, id: string): Promise; + }; volunteers: { list(editionId: string): Promise; get(editionId: string, id: string): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index afab5d4..aadec8c 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -169,7 +169,6 @@ "config.vol.lastName": "NOM", "config.vol.phone": "TÉLÉPHONE", "config.vol.email": "EMAIL (LIEN MAGIQUE)", - "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)", @@ -231,8 +230,6 @@ "config.vol.field.lastName": "Nom", "config.vol.field.email": "Email (lien magique)", "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.skills": "Compétences", "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.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.invalid_tshirt_size": "Taille de t-shirt non reconnue : {value}.", "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…" } diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 2e38d26..ddb4039 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -1,17 +1,19 @@ import { useState } from 'react'; import { Chip } from '@/components/ui'; +import { ConfigArtists } from '@/pages/config/ConfigArtists'; import { ConfigGrid } from '@/pages/config/ConfigGrid'; import { ConfigMail } from '@/pages/config/ConfigMail'; import { ConfigRules } from '@/pages/config/ConfigRules'; import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers'; import { t } from '@/i18n'; -type ConfigTab = 'grid' | 'volunteers' | 'mail' | 'rules'; +type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules'; const TABS: Array<{ id: ConfigTab; labelKey: string }> = [ { id: 'grid', labelKey: 'config.tab.grid' }, { id: 'volunteers', labelKey: 'config.tab.volunteers' }, + { id: 'artists', labelKey: 'config.tab.artists' }, { id: 'mail', labelKey: 'config.tab.mail' }, { id: 'rules', labelKey: 'config.tab.rules' }, ]; @@ -35,6 +37,7 @@ export function ConfigPage() { {tab === 'grid' ? : null} {tab === 'volunteers' ? : null} + {tab === 'artists' ? : null} {tab === 'mail' ? : null} {tab === 'rules' ? : null} diff --git a/frontend/src/pages/config/ConfigArtists.tsx b/frontend/src/pages/config/ConfigArtists.tsx new file mode 100644 index 0000000..3818bef --- /dev/null +++ b/frontend/src/pages/config/ConfigArtists.tsx @@ -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(); + 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 ; + const error = edition.error ?? artists.error; + if (error) return ; + + 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 ( +
+
+
+ {t('config.artists.title')} + + {t('config.artists.hint')} + +
+
+
{total}
+
+ {t('config.artists.count')} +
+
+
+ +
+ {days.map((date) => ( + + ))} + {extraDays.map((date) => ( + + ))} +
+
+ ); +} + +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 ( + + + {longDayLabel(`${date}T12:00`)} + {outside ? ` · ${t('config.artists.outside')}` : ''} + +
+ {artists.length === 0 ? ( +
{t('config.artists.empty')}
+ ) : null} + {artists.map((a) => ( +
+ { + 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" + /> + +
+ ))} + +
+ 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" + /> + +
+ {create.isError ? : null} + {update.isError ? : null} +
+
+ ); +} diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 460c899..77598ce 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -3,19 +3,22 @@ import { useMemo, useRef, useState } from 'react'; import { ApiError } from '@/api'; import { useAnalyzeImport, + useArtists, useCreateVolunteer, useDeleteVolunteer, useExecuteImport, + usePositions, useUpdateVolunteer, useVolunteers, } from '@/api/hooks'; import type { + ArtistRead, ImportAnalyzeResponse, ImportFieldKey, ImportIssue, ImportMapping, ImportReport, - TshirtSize, + PositionRead, VolunteerCreate, } from '@/api/schema'; import { FieldLabel, TextInput } from '@/components/ui'; @@ -24,15 +27,11 @@ import { t } from '@/i18n'; import { shortDayLabel } from '@/lib/time'; import { cx } from '@/lib/cx'; -const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL']; - const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [ { key: 'first_name', required: true }, { key: 'last_name', required: true }, { key: 'email', required: false }, { key: 'phone', required: false }, - { key: 'tshirt_size', required: false }, - { key: 'diet', required: false }, { key: 'comment', required: false }, { key: 'skills', required: false }, ]; @@ -42,12 +41,87 @@ const FIELD_LABEL: Record = { last_name: 'config.vol.field.lastName', email: 'config.vol.field.email', phone: 'config.vol.field.phone', - tshirt_size: 'config.vol.field.tshirt', - diet: 'config.vol.field.diet', comment: 'config.vol.field.comment', 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 ( +
+ {label} +
+ {selected.map((id) => ( + + ))} + {selected.length === 0 ? ( + {emptyLabel} + ) : null} +
+ +
+ ); +} + type Mode = 'list' | 'add' | 'import'; /** Retire les paramètres nuls avant traduction (`t` n'accepte pas `null`). */ @@ -61,6 +135,8 @@ function issueParams(issue: ImportIssue): Record { export function ConfigVolunteers() { const volunteers = useVolunteers(); + const artists = useArtists(); + const positions = usePositions(); const updateVolunteer = useUpdateVolunteer(); const deleteVolunteer = useDeleteVolunteer(); @@ -69,6 +145,9 @@ export function ConfigVolunteers() { const [search, setSearch] = 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 needle = search.trim().toLowerCase(); const list = [...(volunteers.data ?? [])]; @@ -250,25 +329,6 @@ export function ConfigVolunteers() { {t('config.vol.field.email')} patch({ email: v || null })} /> - -