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:
co-authored by
Claude Sonnet 5
parent
ac469f0cd1
commit
c9dda65361
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user