Frontend : consommation de l'API CRUD (matrice, affectations, validation)

Remplace le magasin d'état fabriqué par une vraie couche API typée, alignée
sur le back-end (Étape 5) :

- src/api/ : schema.ts (miroir de backend/app/api/schemas.py), client HTTP,
  implémentation mock en mémoire (même surface + portage du moteur de
  validation), sélection mock/http via VITE_API_MODE, hooks React Query
  (lectures + mutations avec invalidation).
- Grille des besoins ← GET /matrix ; treillis de colonnes dérivé des
  créneaux réels (plus de plages figées).
- Panneau d'affectation ← GET /assignments?shift_id, POST /assignments/validate
  par candidat (alertes bloquantes / avertissements du moteur),
  POST / PATCH / DELETE /assignments.
- Vue par bénévole ← volunteers + shifts + assignments (surcharge / oubliés
  calculés côté client).
- Confirmations (ex-« Publication ») ← affectations PROPOSED ; publication
  versionnée et emails signalés hors périmètre.
- Mobiles ← assignments filtrées ; confirmation = PATCH ; pointage référent
  local (hors périmètre).
- Configuration ← positions & shifts CRUD (min/idéal, ajout/retrait),
  volunteers CRUD dont suppression RGPD ; messages = maquette locale.
- i18n mis à jour, aucun texte d'interface en dur.
- Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (API mock +
  rendu headless des 6 écrans avec données préchargées).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent ac469f0cd1
commit c9dda65361
38 changed files with 2930 additions and 2414 deletions
+154 -144
View File
@@ -1,69 +1,93 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
import {
useCreatePosition,
useCreateShift,
useDeleteShift,
usePositions,
useShifts,
useUpdateShift,
} from '@/api/hooks';
import type { ShiftRead } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { categoryLabel } from '@/lib/planning';
import { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function ConfigGrid() {
const { state, dispatch } = usePlanning();
const [positionId, setPositionId] = useState(state.positions[0]?.id ?? '');
const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0];
const positions = usePositions();
const allShifts = useShifts();
const createPosition = useCreatePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
const position =
(positions.data ?? []).find((p) => p.id === selectedId) ?? (positions.data ?? [])[0];
const positionShifts = useShifts(position?.id);
const updateShift = useUpdateShift();
const deleteShift = useDeleteShift();
const createShift = useCreateShift();
const totals = useMemo(() => {
let slots = 0;
let min = 0;
let ideal = 0;
state.positions.forEach((p) =>
p.needs.forEach((need) => {
slots += 1;
min += need.minStaff * dayPartHours(need.dayPartCode);
ideal += need.idealStaff * dayPartHours(need.dayPartCode);
}),
);
return { slots, min, ideal };
}, [state.positions]);
for (const s of allShifts.data ?? []) {
const h = hoursBetween(s.starts_at, s.ends_at);
min += s.min_staff * h;
ideal += s.ideal_staff * h;
}
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
}, [allShifts.data]);
const positionMin = position.needs.reduce(
(sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode),
0,
);
const positionIdeal = position.needs.reduce(
(sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode),
0,
);
if (positions.isLoading) return <Loading />;
if (positions.error) return <ErrorNote error={positions.error} />;
if (!position) {
return (
<div className="p-6">
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
>
{t('config.grid.addPosition')}
</button>
</div>
);
}
const byDay = groupByDay(positionShifts.data ?? []);
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.slots })}
{t('config.grid.positionsHeader', { count: totals.count })}
</div>
{state.positions.map((entry) => {
{(positions.data ?? []).map((entry) => {
const active = entry.id === position.id;
const n = (allShifts.data ?? []).filter((s) => s.position_id === entry.id).length;
return (
<button
key={entry.id}
type="button"
onClick={() => setPositionId(entry.id)}
onClick={() => setSelectedId(entry.id)}
className={cx(
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
active
? 'border-l-[6px] bg-yellow'
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
active ? 'border-l-[6px] bg-yellow' : 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
)}
>
<div className="font-display text-[12.5px] font-bold leading-tight">
{entry.name} <span className="opacity-60">{entry.zone}</span>
<div className="truncate font-display text-[12.5px] font-bold leading-tight">
{entry.name}
</div>
<div className="font-mono text-[9px] opacity-60">
{t('config.grid.slotsCount', { count: entry.needs.length })}
{categoryLabel(entry.category)} · {t('config.grid.slotsCount', { count: n })}
</div>
</button>
);
})}
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addPosition')}
@@ -73,128 +97,89 @@ export function ConfigGrid() {
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div>
<div className="font-display text-[26px] font-black tracking-tight">
{position.name} — {position.zone}
</div>
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10.5px] opacity-65">
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
{categoryLabel(position.category)}
{position.location ? ` · ${position.location}` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionMin })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.minTotal')}
</div>
</div>
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionIdeal })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.idealTotal')}
</div>
</div>
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
</div>
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{FESTIVAL_DAYS.map((day) => (
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
{positionShifts.isLoading ? <Loading /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
{byDay.map(([day, shifts]) => (
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)}
</div>
{DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
if (!need) {
return (
<div
key={part.code}
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
>
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
{part.label}
</span>
<span className="font-display text-[10px] font-semibold opacity-55">
{t('config.grid.closed')}
</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
>
{t('config.grid.open')}
</button>
</div>
);
}
return (
<div
key={part.code}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
{shifts.map((shift) => (
<div
key={shift.id}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[92px] flex-none font-mono text-[11px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
<Stepper
label={t('config.grid.min')}
value={shift.min_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { min_staff: Math.max(0, shift.min_staff + delta) },
})
}
/>
<Stepper
label={t('config.grid.ideal')}
accent
value={shift.ideal_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
})
}
/>
<button
type="button"
onClick={() => deleteShift.mutate(shift.id)}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
<NeedStepper
label={t('config.grid.min')}
value={need.minStaff}
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'min',
delta,
})
}
/>
<NeedStepper
label={t('config.grid.ideal')}
value={need.idealStaff}
accent
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'ideal',
delta,
})
}
/>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.close')}
>
✕
</button>
</div>
);
})}
✕
</button>
</div>
))}
<button
type="button"
onClick={() => {
const start = `${day}T12:00:00`;
const end = `${day}T17:00:00`;
createShift.mutate({
position_id: position.id,
starts_at: start,
ends_at: end,
min_staff: 2,
ideal_staff: 3,
});
}}
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addShift')}
</button>
</div>
))}
{byDay.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.grid.noShifts')}
</div>
) : null}
</div>
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
@@ -205,7 +190,32 @@ export function ConfigGrid() {
);
}
function NeedStepper({
function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> {
const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
const day = datePart(s.starts_at);
map.set(day, [...(map.get(day) ?? []), s]);
}
return [...map.entries()];
}
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
return (
<div
className={cx(
'border-[3px] border-ink px-[11px] py-2 shadow-brutal-md',
accent ? 'bg-yellow' : 'bg-paper',
)}
>
<div className="font-mono text-[20px] font-bold">{value}</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{label}
</div>
</div>
);
}
function Stepper({
label,
value,
accent = false,
+59 -176
View File
@@ -1,112 +1,67 @@
import { usePlanning } from '@/app/planning-store';
import { useState } from 'react';
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const MAIL_VARIABLES = [
'{prenom}',
'{nom}',
'{nb_creneaux}',
'{nb_heures}',
'{poste}',
'{referent}',
'{lien_magique}',
];
const MAIL_VARIABLES = ['{prenom}', '{nom}', '{nb_creneaux}', '{nb_heures}', '{poste}', '{referent}', '{lien_magique}'];
// Écran maquette : l'envoi d'emails et la publication versionnée sont hors
// périmètre (CLAUDE.md §4). Les réglages ci-dessous ne sont pas persistés.
export function ConfigMail() {
const { state, dispatch } = usePlanning();
const { config } = state;
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
const [from, setFrom] = useState('coordination@lessonsdautomne.fr');
const [replyTo, setReplyTo] = useState('marie.bertin@lessonsdautomne.fr');
const [subject, setSubject] = useState('Ton planning bénévole a été mis à jour');
const [body, setBody] = useState(
"Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nMerci de confirmer chaque créneau depuis ton planning personnel.",
);
return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.mail.from')}</FieldLabel>
<TextInput mono value={config.mailFrom} onChange={(v) => setConfig({ mailFrom: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
<TextInput
mono
value={config.mailReplyTo}
onChange={(v) => setConfig({ mailReplyTo: v })}
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
<input
value={config.mailSubject}
onChange={(e) => setConfig({ mailSubject: e.target.value })}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.body')}</FieldLabel>
<textarea
rows={7}
value={config.mailBody}
onChange={(e) => setConfig({ mailBody: e.target.value })}
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
/>
</label>
<div>
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{MAIL_VARIABLES.map((variable) => (
<span
key={variable}
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
>
{variable}
</span>
))}
</div>
</div>
</div>
</Panel>
<div className="flex flex-col gap-4">
<div className="p-4">
<div className="mb-4 border-[3px] border-dashed border-ink bg-yellow-soft p-3 font-display text-[12px] font-bold">
{t('config.mail.draftBanner')}
</div>
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.mail.sendsTitle')}</PanelHeader>
<div className="flex flex-col gap-[11px] p-[13px]">
<ToggleRow
label={t('config.mail.reminderToggle')}
on={config.reminderEnabled}
onLabel={t('config.mail.on')}
offLabel={t('config.mail.off')}
onToggle={() => setConfig({ reminderEnabled: !config.reminderEnabled })}
/>
<StepperRow
label={t('config.mail.reminderDelay')}
value={t('unit.hours', { count: config.reminderHours })}
onDecrement={() =>
setConfig({ reminderHours: Math.max(12, config.reminderHours - 12) })
}
onIncrement={() =>
setConfig({ reminderHours: Math.min(120, config.reminderHours + 12) })
}
/>
<StepperRow
label={t('config.mail.magicLinkValidity')}
value={t('unit.days', { count: config.magicLinkDays })}
onDecrement={() =>
setConfig({ magicLinkDays: Math.max(15, config.magicLinkDays - 15) })
}
onIncrement={() =>
setConfig({ magicLinkDays: Math.min(90, config.magicLinkDays + 15) })
}
/>
<ToggleRow
label={t('config.mail.smsToggle')}
hint={t('config.mail.smsCost')}
on={config.smsEnabled}
onLabel={t('config.mail.onM')}
offLabel={t('config.mail.offM')}
onToggle={() => setConfig({ smsEnabled: !config.smsEnabled })}
/>
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.mail.from')}</FieldLabel>
<TextInput mono value={from} onChange={setFrom} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
<TextInput mono value={replyTo} onChange={setReplyTo} />
</label>
<label className="block">
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.body')}</FieldLabel>
<textarea
rows={7}
value={body}
onChange={(e) => setBody(e.target.value)}
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
/>
</label>
<div>
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{MAIL_VARIABLES.map((variable) => (
<span
key={variable}
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
>
{variable}
</span>
))}
</div>
</div>
</div>
</Panel>
@@ -114,14 +69,12 @@ export function ConfigMail() {
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
<div className="p-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('config.mail.previewFrom', { from: config.mailFrom })}
{t('config.mail.previewFrom', { from })}
</div>
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
{config.mailSubject}
</div>
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">
{config.mailBody}
{subject}
</div>
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">{body}</div>
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
{t('config.mail.cta')}
</div>
@@ -131,73 +84,3 @@ export function ConfigMail() {
</div>
);
}
function ToggleRow({
label,
hint,
on,
onLabel,
offLabel,
onToggle,
}: {
label: string;
hint?: string;
on: boolean;
onLabel: string;
offLabel: string;
onToggle: () => void;
}) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
{label}
{hint ? (
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
) : null}
</span>
<button
type="button"
onClick={onToggle}
className={cx(
'flex-none cursor-pointer border-2 border-ink px-[11px] py-2 font-display text-[11px] font-extrabold uppercase',
on ? 'bg-green text-white' : 'bg-cream',
)}
>
{on ? onLabel : offLabel}
</button>
</div>
);
}
function StepperRow({
label,
value,
onDecrement,
onIncrement,
}: {
label: string;
value: string;
onDecrement: () => void;
onIncrement: () => void;
}) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px]">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">{label}</span>
<button
type="button"
onClick={onDecrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
>
–
</button>
<span className="min-w-[54px] text-center font-mono text-[16px] font-bold">{value}</span>
<button
type="button"
onClick={onIncrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
>
+
</button>
</div>
);
}
+50 -106
View File
@@ -1,138 +1,82 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
import { indexPlanning, isOverloaded } from '@/domain/selectors';
import { API_MODE, EDITION_ID } from '@/api';
import { usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
export function ConfigRules() {
const { state, dispatch } = usePlanning();
const { config } = state;
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
const RULE_CODES = [
'SHIFT_OVERLAP',
'OUTSIDE_AVAILABILITY',
'DAILY_HOURS_EXCEEDED',
'INSUFFICIENT_REST',
'MISSING_REQUIRED_SKILL',
] as const;
const overCount = useMemo(() => {
const index = indexPlanning(state.assignments, state.volunteers);
return state.volunteers.filter((v) => isOverloaded(v, index, config)).length;
}, [state.assignments, state.volunteers, config]);
// Écran informatif : les seuils du moteur sont figés (CLAUDE.md §3) et il n'y a
// pas encore d'endpoint de configuration d'édition.
export function ConfigRules() {
const volunteers = useVolunteers();
const positions = usePositions();
const shifts = useShifts();
return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.rules.festivalName')}</FieldLabel>
<input
value={config.festivalName}
onChange={(e) => setConfig({ festivalName: e.target.value })}
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[15px] font-black"
/>
</label>
<label className="block">
<FieldLabel>{t('config.rules.dates')}</FieldLabel>
<TextInput value={config.datesLabel} onChange={(v) => setConfig({ datesLabel: v })} />
</label>
<div className="border-t-2 border-ink pt-[11px] font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.datesNote')}
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.editionId')} value={EDITION_ID} mono />
<InfoRow label={t('config.rules.apiMode')} value={API_MODE.toUpperCase()} />
<InfoRow label={t('config.rules.volunteersCount')} value={String(volunteers.data?.length ?? '…')} />
<InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} />
<InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} />
<div className="mt-1 border-t-2 border-ink pt-2 font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.editionNote')}
</div>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.rules.thresholdsTitle')}</PanelHeader>
<div className="flex flex-col gap-[11px] p-[13px]">
<ThresholdRow
label={t('config.rules.maxDaily')}
hint={t('config.rules.maxDailyNote')}
value={t('unit.hours', { count: config.maxDailyHours })}
onDecrement={() => setConfig({ maxDailyHours: Math.max(4, config.maxDailyHours - 1) })}
onIncrement={() => setConfig({ maxDailyHours: Math.min(14, config.maxDailyHours + 1) })}
/>
<ThresholdRow
label={t('config.rules.minRest')}
hint={t('config.rules.minRestNote')}
value={t('unit.hours', { count: config.minRestHours })}
onDecrement={() => setConfig({ minRestHours: Math.max(0, config.minRestHours - 1) })}
onIncrement={() => setConfig({ minRestHours: Math.min(14, config.minRestHours + 1) })}
/>
<div className="flex items-center gap-[11px] border-t-2 border-ink pt-[11px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-orange font-display text-[15px] font-extrabold">
▲
</span>
<span className="font-display text-[13px] font-bold leading-snug">
{t('config.rules.overCount', { count: overCount })}
</span>
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
<InfoRow label={t('config.rules.minRest')} value="8 h" />
<div className="mt-1 font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.thresholdsNote')}
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.overlapNote')}
<div className="mt-2 border-t-2 border-ink pt-2">
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
{t('config.rules.rulesTitle')}
</div>
{RULE_CODES.map((code) => (
<div key={code} className="flex items-baseline gap-2 py-[3px]">
<span className="font-mono text-[9px] opacity-55">{code}</span>
<span className="font-display text-[11px] font-semibold leading-snug">
{t(`validation.${code}.rule`)}
</span>
</div>
))}
</div>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.rules.rolesTitle')}</PanelHeader>
<PanelHeader>{t('config.rules.rolesTitle2')}</PanelHeader>
<div className="flex flex-col gap-[9px] p-[13px]">
<RoleCard
tone="bg-yellow"
title={t('config.rules.roleCoordinator')}
desc={t('config.rules.roleCoordinatorDesc')}
/>
<RoleCard
tone="bg-blue"
title={t('config.rules.roleLead')}
desc={t('config.rules.roleLeadDesc')}
/>
<RoleCard
tone="bg-cream"
title={t('config.rules.roleVolunteer', { count: state.volunteers.length })}
desc={t('config.rules.roleVolunteerDesc')}
/>
<button
type="button"
className="mt-[3px] cursor-pointer border-[3px] border-ink bg-cream p-[11px] font-display text-[11.5px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-yellow"
>
{t('config.rules.designateLead')}
</button>
<RoleCard tone="bg-yellow" title={t('config.rules.roleCoordinator')} desc={t('config.rules.roleCoordinatorDesc')} />
<RoleCard tone="bg-blue" title={t('config.rules.roleLead')} desc={t('config.rules.roleLeadDesc')} />
<RoleCard tone="bg-cream" title={t('config.rules.roleVolunteer')} desc={t('config.rules.roleVolunteerDesc')} />
</div>
</Panel>
</div>
);
}
function ThresholdRow({
label,
hint,
value,
onDecrement,
onIncrement,
}: {
label: string;
hint: string;
value: string;
onDecrement: () => void;
onIncrement: () => void;
}) {
function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
{label}
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
<div className="flex items-baseline justify-between gap-3">
<span className="font-display text-[11px] font-bold">{label}</span>
<span className={mono ? 'font-mono text-[10px] opacity-75' : 'font-display text-[12px] font-extrabold'}>
{value}
</span>
<button
type="button"
onClick={onDecrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
>
–
</button>
<span className="min-w-[48px] text-center font-mono text-[18px] font-bold">{value}</span>
<button
type="button"
onClick={onIncrement}
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
>
+
</button>
</div>
);
}
+154 -128
View File
@@ -1,35 +1,50 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import {
useCreateVolunteer,
useDeleteVolunteer,
useUpdateVolunteer,
useVolunteers,
} from '@/api/hooks';
import type { TshirtSize } from '@/api/schema';
import { FieldLabel, TextInput } from '@/components/ui';
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { ErrorNote, Loading } from '@/components/state';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const TABLE_COLUMNS = 'minmax(0,2fr) minmax(0,1.3fr) minmax(0,1.6fr) minmax(0,1fr)';
function demoEmail(firstName: string, lastName: string): string {
const normalize = (s: string) =>
s
.toLowerCase()
.normalize('NFD')
.replace(/[^a-z]/g, '');
return `${normalize(firstName)}.${normalize(lastName)}@mail.fr`;
}
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
const IMPORT_SAMPLE: Array<[string, string]> = [
['Océane', 'Thibault'],
['Samir', 'Amrani'],
['Lucie', 'Charpentier'],
['Noé', 'Delaunay'],
];
export function ConfigVolunteers() {
const { state, dispatch } = usePlanning();
const [selectedId, setSelectedId] = useState(state.volunteers[0]?.id ?? '');
const selected = state.volunteers.find((v) => v.id === selectedId) ?? state.volunteers[0];
const volunteers = useVolunteers();
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const createVolunteer = useCreateVolunteer();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [skillDraft, setSkillDraft] = useState('');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const selected = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === selectedId) ?? list[0];
}, [volunteers.data, selectedId]);
const rows = state.volunteers.slice(0, 26);
const assignedHours = index.totalHours.get(selected.id) ?? 0;
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
const rows = (volunteers.data ?? []).slice(0, 40);
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
selected && updateVolunteer.mutate({ id: selected.id, body });
const runImport = () => {
for (const [first_name, last_name] of IMPORT_SAMPLE) {
createVolunteer.mutate({ first_name, last_name });
}
};
return (
<div className="flex min-h-0 flex-1 items-stretch">
@@ -49,40 +64,33 @@ export function ConfigVolunteers() {
</span>
<button
type="button"
onClick={() => dispatch({ type: 'runImport' })}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
onClick={runImport}
disabled={createVolunteer.isPending}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.importButton')}
</button>
</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">{state.volunteers.length}</div>
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.profiles')}
</div>
</div>
{state.importedCount > 0 ? (
<div className="flex-none border-[3px] border-ink bg-green px-[14px] py-3 text-white shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">+{state.importedCount}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.imported')}
</div>
</div>
) : null}
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colWanted')}</span>
<span>{t('config.vol.colAvailability')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected.id;
const active = volunteer.id === selected?.id;
return (
<button
key={volunteer.id}
@@ -92,29 +100,23 @@ export function ConfigVolunteers() {
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.initials}
{volunteer.first_name[0]}
{volunteer.last_name[0]}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.fullName}
{volunteer.first_name} {volunteer.last_name}
</span>
{volunteer.imported ? (
<span className="flex-none border-[1.5px] border-ink bg-green px-1 py-[2px] font-display text-[8px] font-extrabold text-white">
{t('config.vol.new')}
</span>
) : null}
</span>
<span className="font-mono text-[11px] opacity-80">{volunteer.phone}</span>
<span className="truncate font-display text-[11px] font-semibold opacity-80">
{volunteer.preferredCategories
.map((c) => t(categoryLabelKey(c)))
.join(' · ')}
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
{volunteer.email ?? '—'}
</span>
<span className="font-mono text-[10.5px] opacity-65">
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
</span>
</button>
);
@@ -122,95 +124,119 @@ export function ConfigVolunteers() {
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.initials}
</span>
<span className="font-display text-[19px] font-black tracking-tight">
{selected.fullName}
</span>
</div>
</div>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.vol.fullName')}</FieldLabel>
<TextInput
value={selected.fullName}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'fullName', value })
}
/>
</label>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput
mono
value={selected.phone}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'phone', value })
}
/>
</label>
<div>
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<div className="truncate border-2 border-ink bg-sand p-[9px] font-mono text-[12px]">
{demoEmail(selected.firstName, selected.lastName)}
{selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.first_name[0]}
{selected.last_name[0]}
</span>
<span className="font-display text-[18px] font-black tracking-tight">
{selected.first_name} {selected.last_name}
</span>
</div>
</div>
<div>
<FieldLabel>{t('config.vol.wanted')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{DEMO_CATEGORIES.map((category) => {
const on = selected.preferredCategories.includes(category);
return (
<div className="flex flex-col gap-3 p-[13px]">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
</label>
</div>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.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>
<div>
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.skill_codes.map((code) => (
<button
key={category}
key={code}
type="button"
onClick={() =>
dispatch({ type: 'togglePreference', id: selected.id, category })
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
}
className={cx(
'cursor-pointer border-2 border-ink px-2 py-[6px] font-display text-[10.5px]',
on ? 'bg-blue font-extrabold' : 'bg-cream font-semibold hover:bg-yellow',
)}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{on ? '★ ' : ''}
{t(categoryLabelKey(category))}
{code} ✕
</button>
);
})}
</div>
</div>
<div className="flex gap-[9px] border-t-2 border-ink pt-[11px]">
<div className="flex-1 border-2 border-ink bg-cream p-2">
<div className="font-mono text-[17px] font-bold">
{t('unit.hours', { count: assignedHours })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.hoursAssigned')}
))}
{selected.skill_codes.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
) : null}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const code = skillDraft.trim().toUpperCase();
if (code && !selected.skill_codes.includes(code)) {
patch({ skill_codes: [...selected.skill_codes, code] });
}
setSkillDraft('');
}}
className="flex gap-2"
>
<input
value={skillDraft}
onChange={(e) => setSkillDraft(e.target.value)}
placeholder={t('config.vol.skillPlaceholder')}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
/>
<button
type="submit"
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
>
{t('action.add')}
</button>
</form>
</div>
<div
className="flex-1 border-2 border-ink bg-dots-sand p-2"
style={{ backgroundSize: '5px 5px' }}
<button
type="button"
onClick={() => {
if (window.confirm(t('config.vol.deleteConfirm'))) {
deleteVolunteer.mutate(selected.id);
setSelectedId(null);
}
}}
className="mt-2 cursor-pointer border-[3px] border-ink bg-cream p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-orange"
>
<div className="font-mono text-[11px] leading-tight">
{t('config.vol.availabilityValue', { count: selected.availability.size })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.availabilityDeclared')}
</div>
{t('config.vol.delete')}
</button>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
</aside>
</aside>
) : null}
</div>
);
}