Files
benevoles/frontend/src/pages/config/ConfigArtists.tsx
T
AdamThomasandClaude Sonnet 5 fb6e26eddb Front : les indications d'usage passent en bulle d'aide (icône « ? »)
Les explications sur comment utiliser un écran (grille des besoins, vue
bénévoles, artistes, import, disponibilités, créneaux types, seuils,
notification par email…) étaient affichées en permanence dans la page,
prenant de la place pour un contenu qu'on ne lit qu'une fois. Elles passent
dans une bulle affichée au survol (ou au focus clavier/tactile) d'un petit
repère « ? » posé à côté du titre ou du champ concerné.

- `HelpTip` (frontend/src/components/ui.tsx) : nouveau composant partagé,
  cohérent avec le design (bordures franches, pas d'arrondi) ; `side`
  ('top'/'bottom') pour éviter que la bulle ne sorte du cadre visible selon
  que le repère est en haut ou en bas de son conteneur.
- Appliqué à GrillePage, VolunteerViewPage, ConfigArtists, ConfigVolunteers
  (import, ajout manuel, disponibilités), ConfigGrid, ConfigRules, ConfigMail.
- Aucun texte déplacé n'a changé : mêmes clés i18n, seul l'endroit où elles
  s'affichent change.

Laissé en l'état (pas des indications d'usage mais des messages d'état) :
bannières identifiants de démo, liens de connexion en dev, états vides, et
la description des rôles (contenu propre des cartes, pas un mode d'emploi).

Vérifié : typecheck, lint, build, smoke (7 routes) OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-15 20:03:41 +02:00

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(320px,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>
);
}