En-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous lg: (plein écran en mobile), colonne des postes de la grille rétrécie, liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
7.6 KiB
TypeScript
213 lines
7.6 KiB
TypeScript
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 { HelpTip, 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;
|
|
}
|
|
|
|
/** « HH:MM:SS » -> « HH:MM » (pour un `<input type="time">`). */
|
|
const hm = (time: string): string => time.slice(0, 5);
|
|
|
|
// 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.
|
|
// L'heure de passage est prise en compte par le solveur (vœux d'artistes).
|
|
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.start_time.localeCompare(b.start_time) || 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="flex items-center gap-[7px] font-display text-[15px] font-black">
|
|
{t('config.artists.title')}
|
|
<HelpTip text={t('config.artists.hint')} side="bottom" />
|
|
</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(min(320px,100%),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 [startDraft, setStartDraft] = useState('20:00');
|
|
const [endDraft, setEndDraft] = useState('21:00');
|
|
|
|
const add = () => {
|
|
const name = draft.trim();
|
|
if (!name) return;
|
|
create.mutate(
|
|
{
|
|
name,
|
|
performance_date: date,
|
|
start_time: `${startDraft}:00`,
|
|
end_time: `${endDraft}:00`,
|
|
},
|
|
{ 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 flex-wrap 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"
|
|
/>
|
|
<input
|
|
type="time"
|
|
aria-label={t('config.artists.startTime')}
|
|
defaultValue={hm(a.start_time)}
|
|
onBlur={(e) =>
|
|
e.target.value &&
|
|
update.mutate({ id: a.id, body: { start_time: `${e.target.value}:00` } })
|
|
}
|
|
className="w-[76px] flex-none border-2 border-ink bg-paper px-1 py-1 font-mono text-[10.5px]"
|
|
/>
|
|
<input
|
|
type="time"
|
|
aria-label={t('config.artists.endTime')}
|
|
defaultValue={hm(a.end_time)}
|
|
onBlur={(e) =>
|
|
e.target.value &&
|
|
update.mutate({ id: a.id, body: { end_time: `${e.target.value}:00` } })
|
|
}
|
|
className="w-[76px] flex-none border-2 border-ink bg-paper px-1 py-1 font-mono text-[10.5px]"
|
|
/>
|
|
<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 flex-wrap 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"
|
|
/>
|
|
<input
|
|
type="time"
|
|
aria-label={t('config.artists.startTime')}
|
|
value={startDraft}
|
|
onChange={(e) => setStartDraft(e.target.value)}
|
|
className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]"
|
|
/>
|
|
<input
|
|
type="time"
|
|
aria-label={t('config.artists.endTime')}
|
|
value={endDraft}
|
|
onChange={(e) => setEndDraft(e.target.value)}
|
|
className="w-[76px] flex-none border-2 border-ink bg-yellow px-1 py-1 font-mono text-[10.5px]"
|
|
/>
|
|
<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>
|
|
);
|
|
}
|