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,247 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
|
||||
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 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]);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
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 })}
|
||||
</div>
|
||||
{state.positions.map((entry) => {
|
||||
const active = entry.id === position.id;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setPositionId(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',
|
||||
)}
|
||||
>
|
||||
<div className="font-display text-[12.5px] font-bold leading-tight">
|
||||
{entry.name} <span className="opacity-60">{entry.zone}</span>
|
||||
</div>
|
||||
<div className="font-mono text-[9px] opacity-60">
|
||||
{t('config.grid.slotsCount', { count: entry.needs.length })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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-mono text-[10.5px] opacity-65">
|
||||
{t(`position.category.${position.category.toLowerCase()}`)} · {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>
|
||||
</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>
|
||||
</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]"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</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">
|
||||
{t('config.grid.note')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedStepper({
|
||||
label,
|
||||
value,
|
||||
accent = false,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
accent?: boolean;
|
||||
onChange: (delta: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="font-display text-[8.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(-1)}
|
||||
className={cx(
|
||||
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-orange',
|
||||
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span className="min-w-[18px] text-center font-mono text-[14px] font-bold">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(1)}
|
||||
className={cx(
|
||||
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-green hover:text-white',
|
||||
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
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}',
|
||||
];
|
||||
|
||||
export function ConfigMail() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const { config } = state;
|
||||
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||
|
||||
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">
|
||||
<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 })}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<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 })}
|
||||
</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}
|
||||
</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>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 { t } from '@/i18n';
|
||||
|
||||
export function ConfigRules() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const { config } = state;
|
||||
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||
|
||||
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]);
|
||||
|
||||
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>
|
||||
</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>
|
||||
<div className="font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.overlapNote')}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.rolesTitle')}</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>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThresholdRow({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
onDecrement: () => void;
|
||||
onIncrement: () => 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}
|
||||
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function RoleCard({ tone, title, desc }: { tone: string; title: string; desc: string }) {
|
||||
return (
|
||||
<div className={`border-2 border-ink p-[9px] ${tone}`}>
|
||||
<div className="font-display text-[13px] font-extrabold leading-tight">{title}</div>
|
||||
<div className="font-mono text-[10px] leading-relaxed">{desc}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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