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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 22:22:45 +02:00
co-authored by Claude Sonnet 5
parent 24caece7df
commit 81609eecc5
10 changed files with 533 additions and 99 deletions
+167
View File
@@ -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>
);
}
+129 -49
View File
@@ -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<ImportFieldKey, string> = {
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 (
<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';
/** 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() {
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() {
<FieldLabel>{t('config.vol.field.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
</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">
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
<textarea
@@ -279,6 +339,23 @@ export function ConfigVolunteers() {
/>
</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>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
@@ -351,11 +428,18 @@ export function ConfigVolunteers() {
// --------------------------------------------------------------------------- //
function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const createVolunteer = useCreateVolunteer();
const artists = useArtists();
const positions = usePositions();
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
const [skills, setSkills] = useState<string[]>([]);
const [artistIds, setArtistIds] = useState<string[]>([]);
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
const [skillDraft, setSkillDraft] = useState('');
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]) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -371,10 +455,10 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
last_name: form.last_name.trim(),
email: form.email?.trim() || null,
phone: form.phone?.trim() || null,
tshirt_size: form.tshirt_size ?? null,
diet: form.diet?.trim() || null,
comment: form.comment?.trim() || null,
skill_codes: skills,
artist_ids: artistIds,
wished_position_ids: wishedPositionIds,
});
onClose();
} catch (err) {
@@ -423,25 +507,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
<FieldLabel>{t('config.vol.field.phone')}</FieldLabel>
<TextInput mono value={form.phone ?? ''} onChange={(v) => set('phone', v)} />
</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">
<FieldLabel>{t('config.vol.field.comment')}</FieldLabel>
<textarea
@@ -452,6 +517,23 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
/>
</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>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
@@ -567,8 +649,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
last_name: colMap.last_name ?? '',
email: colMap.email || null,
phone: colMap.phone || null,
tshirt_size: colMap.tshirt_size || null,
diet: colMap.diet || null,
comment: colMap.comment || null,
skills: colMap.skills || null,
},