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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user