Frontend : intégration du design Claude Design (6 écrans)
Portage du projet « Bénévoles Festival.dc.html » en implémentation statique pilotée par un jeu de démonstration déterministe (portage du générateur du design : 15 postes, 64 bénévoles, ~209 affectations). - Système visuel néo-brutaliste : palette + primitives Tailwind, polices Archivo / DM Mono, primitives dans components/ui.tsx. - Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel de publication. - État applicatif : PlanningProvider (reducer) — affecter / désaffecter / publier / confirmer / éditer besoins et bénévoles / import simulé / pointage. - Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés, alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole, 05 Mobile référent, 06 Configuration (4 sous-onglets). - i18n : aucun texte d'interface en dur, locales/fr.json. - Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans (npm run smoke). 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
5154a3c61b
commit
ac469f0cd1
@@ -0,0 +1,216 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { FieldLabel, TextInput } from '@/components/ui';
|
||||
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
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`;
|
||||
}
|
||||
|
||||
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 index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
);
|
||||
|
||||
const rows = state.volunteers.slice(0, 26);
|
||||
const assignedHours = index.totalHours.get(selected.id) ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
||||
<div className="flex flex-1 items-center gap-[14px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[16px] font-extrabold">
|
||||
↓
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-display text-[15px] font-black">
|
||||
{t('config.vol.importTitle')}
|
||||
</span>
|
||||
<span className="block font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.vol.importHint')}
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
{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="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 }}
|
||||
>
|
||||
<span>{t('config.vol.colName')}</span>
|
||||
<span>{t('config.vol.colPhone')}</span>
|
||||
<span>{t('config.vol.colWanted')}</span>
|
||||
<span>{t('config.vol.colAvailability')}</span>
|
||||
</div>
|
||||
{rows.map((volunteer) => {
|
||||
const active = volunteer.id === selected.id;
|
||||
return (
|
||||
<button
|
||||
key={volunteer.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(volunteer.id)}
|
||||
className={cx(
|
||||
'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 }}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<span className="truncate font-display text-[12.5px] font-bold">
|
||||
{volunteer.fullName}
|
||||
</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>
|
||||
<span className="font-mono text-[10.5px] opacity-65">
|
||||
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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)}
|
||||
</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 (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'togglePreference', id: selected.id, category })
|
||||
}
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{on ? '★ ' : ''}
|
||||
{t(categoryLabelKey(category))}
|
||||
</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')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 border-2 border-ink bg-dots-sand p-2"
|
||||
style={{ backgroundSize: '5px 5px' }}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||
{t('config.vol.note')}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user