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:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent 5154a3c61b
commit ac469f0cd1
35 changed files with 4267 additions and 125 deletions
+42
View File
@@ -0,0 +1,42 @@
import { useState } from 'react';
import { Chip } from '@/components/ui';
import { ConfigGrid } from '@/pages/config/ConfigGrid';
import { ConfigMail } from '@/pages/config/ConfigMail';
import { ConfigRules } from '@/pages/config/ConfigRules';
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
import { t } from '@/i18n';
type ConfigTab = 'grid' | 'volunteers' | 'mail' | 'rules';
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
{ id: 'grid', labelKey: 'config.tab.grid' },
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
{ id: 'mail', labelKey: 'config.tab.mail' },
{ id: 'rules', labelKey: 'config.tab.rules' },
];
export function ConfigPage() {
const [tab, setTab] = useState<ConfigTab>('grid');
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
<span className="mr-1 font-mono text-[9px] opacity-60">{t('config.admin')}</span>
{TABS.map((entry) => (
<Chip key={entry.id} active={tab === entry.id} onClick={() => setTab(entry.id)}>
{t(entry.labelKey)}
</Chip>
))}
<span className="ml-auto border-2 border-ink bg-blue px-2 py-[5px] font-display text-[9.5px] font-extrabold uppercase">
{t('config.reserved')}
</span>
</div>
{tab === 'grid' ? <ConfigGrid /> : null}
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
{tab === 'mail' ? <ConfigMail /> : null}
{tab === 'rules' ? <ConfigRules /> : null}
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { t } from '@/i18n';
export function DashboardPage() {
return (
<section>
<h2 className="text-base font-medium text-slate-700">{t('nav.dashboard')}</h2>
<p className="mt-2 text-sm text-slate-500">
{t('app.title')} — {import.meta.env.MODE}
</p>
</section>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Chip } from '@/components/ui';
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
import {
DAY_PARTS,
DEMO_CATEGORIES,
FESTIVAL_DAYS,
categoryLabelKey,
slotKey,
} from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import type { PositionCategory } from '@/domain/types';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type DayFilter = 'all' | string;
type CategoryFilter = 'all' | PositionCategory;
export function GrillePage() {
const { state, dispatch } = usePlanning();
const [dayFilter, setDayFilter] = useState<DayFilter>('all');
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const shownDays =
dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter);
const shownPositions =
categoryFilter === 'all'
? state.positions
: state.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`;
return (
<div className="flex min-h-0 flex-1 flex-col">
{/* Barre de filtres + légende */}
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')}
</Chip>
{FESTIVAL_DAYS.map((day) => (
<Chip key={day.id} active={dayFilter === day.id} onClick={() => setDayFilter(day.id)}>
{day.shortLabel}
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.position')}</span>
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
{t('filter.all')}
</Chip>
{DEMO_CATEGORIES.map((category) => (
<Chip
key={category}
active={categoryFilter === category}
onClick={() => setCategoryFilter(category)}
>
{t(categoryLabelKey(category)).toUpperCase()}
</Chip>
))}
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('grille.legend.title')}</span>
<LegendItem swatchClass="bg-hatch-orange" glyph="▲">
{t('grille.legend.critical')}
</LegendItem>
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
{t('grille.legend.incomplete')}
</LegendItem>
<LegendItem swatchClass="bg-paper" glyph="✓">
{t('grille.legend.complete')}
</LegendItem>
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span className="h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
{t('grille.legend.unpublished')}
</span>
</div>
</div>
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div
className="grid w-max border-b-[3px] border-ink"
style={{ gridTemplateColumns }}
>
{/* Ligne 1 : coin + entêtes de journée */}
<div className="sticky left-0 top-0 z-[6] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('grille.positionsCount', { count: state.positions.length })}
</div>
{shownDays.map((day) => (
<div
key={day.id}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: 'span 4' }}
>
<span className="font-display text-[12px] font-black tracking-tight">
{day.shortLabel}
</span>
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
</div>
))}
{/* Ligne 2 : légende de colonne + libellés de plage */}
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
{t('grille.assignedRequired')}
</div>
{shownDays.map((day) =>
DAY_PARTS.map((part) => (
<div
key={`${day.id}-${part.code}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{part.label}
</div>
)),
)}
{/* Lignes de postes */}
{shownPositions.map((position) => (
<PositionRow
key={position.id}
position={position}
shownDays={shownDays}
bySlot={index.bySlot}
selectedSlot={state.selectedSlot}
onSelect={(slot) => dispatch({ type: 'selectSlot', slot })}
/>
))}
</div>
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('grille.footnote')}
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{state.selectedSlot ? (
<AssignmentPanel slot={state.selectedSlot} index={index} />
) : (
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
<div className="font-display text-[14px] font-extrabold leading-tight">
{t('grille.panel.empty.title')}
</div>
<div className="font-mono text-[10.5px] leading-relaxed opacity-65">
{t('grille.panel.empty.body')}
</div>
</div>
)}
</aside>
</div>
</div>
);
}
function LegendItem({
swatchClass,
glyph,
children,
}: {
swatchClass: string;
glyph: string;
children: React.ReactNode;
}) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span
className={cx(
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
swatchClass,
)}
>
{glyph}
</span>
{children}
</span>
);
}
function PositionRow({
position,
shownDays,
bySlot,
selectedSlot,
onSelect,
}: {
position: import('@/domain/types').Position;
shownDays: typeof FESTIVAL_DAYS;
bySlot: Map<string, import('@/domain/types').Assignment[]>;
selectedSlot: string | null;
onSelect: (slot: string) => void;
}) {
return (
<>
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
<span className="font-display text-[12px] font-bold tracking-tight">{position.name}</span>
<span className="font-mono text-[8.5px] opacity-55">{position.zone}</span>
</div>
{shownDays.map((day) =>
DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
const key = slotKey(position.id, day.id, part.code);
if (!need) {
return (
<div
key={key}
className="h-9 border-b border-r border-ink bg-hatch-sand"
/>
);
}
const list = bySlot.get(key) ?? [];
const count = list.length;
const isCrit = count < need.minStaff;
const isInc = count >= need.minStaff && count < need.idealStaff;
const pending = list.some((a) => a.pending);
const selected = selectedSlot === key;
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
return (
<div
key={key}
onClick={() => onSelect(key)}
className={cx(
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
isCrit && 'bg-hatch-orange',
isInc && 'bg-yellow-soft hover:bg-yellow',
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
)}
>
<span className="font-mono text-[14px] font-bold">{count}</span>
<span className="font-mono text-[10px] opacity-60">/{need.idealStaff}</span>
<span
className={cx(
'font-mono text-[10px] font-bold',
!isCrit && !isInc && 'text-green',
)}
>
{glyph}
</span>
{pending ? (
<span className="absolute right-0 top-0 h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
) : null}
{selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null}
</div>
);
}),
)}
</>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { slotKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const LEAD_SLOT = slotKey('BAR', 'ven', 'S');
export function MobileLeadPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const position = state.positions.find((p) => p.id === 'BAR')!;
const leadDemo = state.volunteers[1];
const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!;
const presence = state.presence[assignment.id];
return {
assignmentId: assignment.id,
name: volunteer.fullName,
initials: volunteer.initials,
phone: volunteer.phone,
status:
assignment.status === 'CONFIRMED'
? t('mobileLead.statusConfirmed')
: t('mobileLead.statusProposed'),
present: presence === 'present',
absent: presence === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
<PhoneFrame time={t('mobileLead.statusTime')}>
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">
{t('mobileLead.kicker', { name: leadDemo.fullName })}
</div>
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
{`${position.name} — ${position.zone}`.toUpperCase()}
</div>
<div className="font-display text-[13px] font-semibold opacity-80">
{position.zone} · {position.location}
</div>
<div className="mt-3 flex items-stretch gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
</div>
</div>
</div>
{absentCount > 0 ? (
<div className="border-b-[3px] border-ink bg-hatch-orange px-[14px] py-[11px]">
<div className="font-display text-[15px] font-black leading-tight">
{t('mobileLead.absenceAlert', { count: absentCount })}
</div>
<button
type="button"
className="mt-[9px] min-h-[52px] w-full cursor-pointer border-[3px] border-ink bg-ink font-display text-[14px] font-black text-cream hover:bg-yellow hover:text-ink"
>
{t('mobileLead.requestBackup')}
</button>
</div>
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-[26px] pt-[13px]">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileLead.teamTitle')}
</div>
{team.map((member) => (
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight">
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">
{member.status} · {member.phone}
</span>
</span>
{member.present ? (
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
{t('mobileLead.badgePresent')}
</span>
) : null}
{member.absent ? (
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
{t('mobileLead.badgeAbsent')}
</span>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
}
className={cx(
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
)}
>
{t('mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
}
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
</button>
</div>
</div>
))}
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.nextSlot')}
</div>
</div>
</PhoneFrame>
<DesignNote>{t('mobileLead.designNote')}</DesignNote>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { t } from '@/i18n';
export function MobileVolunteerPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const me = state.volunteers[0];
const slots = (index.byVolunteer.get(me.id) ?? [])
.slice()
.sort(
(a, b) =>
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
)
.map((assignment) => {
const position = state.positions.find((p) => p.id === assignment.positionId)!;
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
return {
assignmentId: assignment.id,
day: festivalDay(assignment.dayId).longLabel,
hours: dayPart(assignment.dayPartCode).label,
position: position.name.toUpperCase(),
zone: position.zone,
location: position.location,
leadName: lead.fullName,
leadPhone: lead.phone,
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
confirmed: assignment.status === 'CONFIRMED',
};
});
const toConfirm = slots.filter((s) => !s.confirmed).length;
const totalHours = index.totalHours.get(me.id) ?? 0;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
<PhoneFrame time={t('mobileVol.statusTime')}>
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
</div>
</div>
</div>
{toConfirm > 0 ? (
<div className="border-b-[3px] border-ink bg-orange px-4 py-[11px] font-display text-[14px] font-black leading-snug">
{t('mobileVol.toConfirm', { count: toConfirm })}
</div>
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{slots.map((slot) => (
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">{slot.day}</span>
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
{slot.hours}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
{slot.position}
</div>
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
<div className="font-mono text-[12px]">
{t('mobileVol.location', { location: slot.location })}
</div>
<div className="font-mono text-[12px]">
{t('mobileVol.lead', { name: slot.leadName })}
</div>
</div>
<div className="mt-[11px] flex gap-[9px]">
<a
href={`tel:${slot.leadPhoneRaw}`}
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
>
{t('mobileVol.call', { phone: slot.leadPhone })}
</a>
</div>
{slot.confirmed ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
</div>
))}
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileVol.orgMessageTitle')}
</div>
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
{t('mobileVol.orgMessageBody')}
</p>
<div className="mt-2 font-mono text-[10px] opacity-70">
{t('mobileVol.orgMessageMeta')}
</div>
</div>
</div>
</PhoneFrame>
<DesignNote>{t('mobileVol.designNote')}</DesignNote>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Panel, PanelHeader, StatCard } from '@/components/ui';
import { buildDiff, countConcernedVolunteers } from '@/domain/publication';
import { indexPlanning } from '@/domain/selectors';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function PublicationPage() {
const { state, dispatch } = usePlanning();
const diff = useMemo(() => buildDiff(state), [state]);
const concerned = countConcernedVolunteers(diff);
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const me = state.volunteers[0];
const sampleVolunteer =
diff.length > 0
? state.volunteers.find((v) => v.fullName === diff[0].volunteerName) ?? me
: me;
const sampleFirstName = sampleVolunteer.firstName;
const sampleSlots = (index.byVolunteer.get(sampleVolunteer.id) ?? []).length;
const sampleHours = index.totalHours.get(sampleVolunteer.id) ?? 0;
const when = state.published ? t('time.now') : t('time.yesterday');
return (
<div className="grid items-start gap-[18px] p-[18px] lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div>
<div className="mb-[14px] grid grid-cols-3 gap-[10px]">
<StatCard value={diff.length} label={t('publication.stat.changes')} tone="yellow" />
<StatCard value={concerned} label={t('publication.stat.concerned')} />
<StatCard value={`v${state.version}`} label={t('publication.stat.version', { when })} />
</div>
<Panel shadow="lg">
<div className="flex items-center gap-[10px] bg-hatch-ink px-3 py-[9px] text-yellow">
<span className="font-display text-[13px] font-black tracking-wide">
{t('publication.draftTitle')}
</span>
<span className="ml-auto font-mono text-[9.5px] opacity-90">
{t('publication.diffWith', { version: state.version })}
</span>
</div>
{diff.length === 0 ? (
<div className="flex items-center gap-3 px-[14px] py-[22px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-extrabold text-white">
✓
</span>
<span className="font-display text-[13px] font-extrabold leading-snug">
{t('publication.noDiff')}
</span>
</div>
) : (
diff.map((entry, i) => (
<div
key={i}
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
>
<span
className={cx(
'flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide',
entry.kind === 'ADD' ? 'bg-green text-white' : 'bg-orange text-ink',
)}
>
{entry.kind === 'ADD' ? t('publication.add') : t('publication.remove')}
</span>
<span className="min-w-0 flex-1 font-display text-[12.5px] font-bold">
{entry.volunteerName}
<span className="block font-mono text-[9.5px] opacity-65">
{entry.positionName} {entry.zone} · {entry.dayShortLabel} · {entry.dayPartLabel}
</span>
</span>
</div>
))
)}
</Panel>
</div>
<div>
<Panel shadow="lg" className="mb-[14px]">
<PanelHeader>{t('publication.mailPreviewTitle')}</PanelHeader>
<div className="px-[14px] py-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailFrom', { from: state.config.mailFrom })}
</div>
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailTo', { count: concerned })}
</div>
<div className="my-2 border-t-2 border-ink pt-[9px] font-display text-[13.5px] font-extrabold leading-snug">
{t('publication.mailSubjectPrefix', { subject: state.config.mailSubject })}
</div>
<p className="font-display text-[12.5px] leading-relaxed">
{t('publication.mailGreeting', { name: sampleFirstName })}
<br />
<br />
{t('publication.mailBody', { slots: sampleSlots, hours: sampleHours })}
<br />
<br />
{t('publication.mailBody2')}
</p>
<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('publication.mailCta')}
</div>
<div className="mt-[11px] font-mono text-[9px] leading-relaxed opacity-55">
{t('publication.magicLinkNote', { days: state.config.magicLinkDays })}
</div>
</div>
</Panel>
{diff.length > 0 ? (
<>
<button
type="button"
onClick={() => dispatch({ type: 'publish' })}
className="w-full cursor-pointer border-[3px] border-ink bg-orange p-4 font-display text-[16px] font-black uppercase tracking-wide shadow-brutal-lg hover:bg-yellow"
>
{t('publication.publishButton', { count: concerned })}
</button>
<div className="mt-[9px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.publishNote', { version: state.version + 1 })}
</div>
</>
) : (
<div className="border-[3px] border-ink bg-green p-4 font-display text-[15px] font-black leading-snug text-white shadow-brutal-lg">
{t('publication.publishedState', { version: state.version, when })}
</div>
)}
</div>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Chip } from '@/components/ui';
import { DAY_PARTS, FESTIVAL_DAYS } from '@/domain/festival';
import { indexPlanning, isOverloaded } from '@/domain/selectors';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type VolunteerFilter = 'all' | 'overloaded' | 'unassigned';
export function VolunteerViewPage() {
const { state } = usePlanning();
const [filter, setFilter] = useState<VolunteerFilter>('all');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const positionsById = useMemo(
() => new Map(state.positions.map((p) => [p.id, p])),
[state.positions],
);
const rows = useMemo(() => {
return state.volunteers
.filter((volunteer) => {
if (filter === 'overloaded') return isOverloaded(volunteer, index, state.config);
if (filter === 'unassigned') return (index.byVolunteer.get(volunteer.id) ?? []).length === 0;
return true;
})
.map((volunteer) => {
const own = index.byVolunteer.get(volunteer.id) ?? [];
const over = isOverloaded(volunteer, index, state.config);
const cells = FESTIVAL_DAYS.flatMap((day) =>
DAY_PARTS.map((part) => {
const assignment = own.find(
(a) => a.dayId === day.id && a.dayPartCode === part.code,
);
if (assignment) {
const position = positionsById.get(assignment.positionId);
return {
kind: assignment.status === 'CONFIRMED' ? ('confirmed' as const) : ('proposed' as const),
code: (position?.id ?? assignment.positionId).slice(0, 4),
title: `${position?.name ?? ''} ${position?.zone ?? ''} · ${
assignment.status === 'CONFIRMED'
? t('assignment.status.confirmed')
: t('assignment.status.proposed')
}`.trim(),
};
}
if (volunteer.availability.has(`${day.id}.${part.code}`)) {
return { kind: 'free' as const, code: '', title: '' };
}
return { kind: 'none' as const, code: '', title: '' };
}),
);
return {
volunteer,
cells,
total: index.totalHours.get(volunteer.id) ?? 0,
over,
empty: own.length === 0,
};
});
}, [state.volunteers, state.config, filter, index, positionsById]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
{t('volunteers.filter.all')}
</Chip>
<Chip active={filter === 'overloaded'} onClick={() => setFilter('overloaded')}>
{t('volunteers.filter.overloaded')}
</Chip>
<Chip active={filter === 'unassigned'} onClick={() => setFilter('unassigned')}>
{t('volunteers.filter.unassigned')}
</Chip>
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatchClass="bg-blue border-ink">
{t('volunteers.legend.confirmed')}
</LegendItem>
<LegendItem swatchClass="bg-paper border-ink border-dashed">
{t('volunteers.legend.proposed')}
</LegendItem>
<LegendItem swatchClass="bg-dots-sand border-sand-line">
{t('volunteers.legend.availability')}
</LegendItem>
</div>
</div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div
className="grid w-max border-b-[3px] border-ink"
style={{ gridTemplateColumns: '210px repeat(16, 62px) 84px' }}
>
{/* Ligne 1 */}
<div className="sticky left-0 top-0 z-[7] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('volunteers.count', { count: rows.length })}
</div>
{FESTIVAL_DAYS.map((day) => (
<div
key={day.id}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: 'span 4' }}
>
<span className="font-display text-[12px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
</div>
))}
<div className="sticky right-0 top-0 z-[7] flex items-center border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider">
{t('volunteers.totalHours')}
</div>
{/* Ligne 2 */}
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
{t('volunteers.name')}
</div>
{FESTIVAL_DAYS.map((day) =>
DAY_PARTS.map((part) => (
<div
key={`${day.id}-${part.code}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{part.label}
</div>
)),
)}
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
{t('volunteers.cumulative')}
</div>
{/* Lignes bénévoles */}
{rows.map((row) => (
<VolunteerRow key={row.volunteer.id} row={row} />
))}
</div>
{rows.length === 0 ? (
<div className="m-[18px] flex max-w-[640px] items-center gap-[14px] border-[3px] border-dashed border-ink bg-paper p-[22px]">
<span className="h-9 w-9 flex-none border-[3px] border-ink bg-yellow" />
<span className="font-display text-[14px] font-extrabold leading-snug">
{t('volunteers.empty')}
</span>
</div>
) : null}
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('volunteers.footnote', {
maxHours: state.config.maxDailyHours,
restHours: state.config.minRestHours,
})}
</div>
</div>
</div>
);
}
function LegendItem({
swatchClass,
children,
}: {
swatchClass: string;
children: React.ReactNode;
}) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span className={cx('h-4 w-[30px] border-[1.5px]', swatchClass)} />
{children}
</span>
);
}
type Row = {
volunteer: import('@/domain/types').Volunteer;
cells: Array<{ kind: 'confirmed' | 'proposed' | 'free' | 'none'; code: string; title: string }>;
total: number;
over: boolean;
empty: boolean;
};
function VolunteerRow({ row }: { row: Row }) {
return (
<>
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.fullName}
</span>
{row.over ? (
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.overloaded')}
</span>
) : null}
{row.empty ? (
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.unassigned')}
</span>
) : null}
</div>
{row.cells.map((cell, i) => {
if (cell.kind === 'confirmed') {
return (
<div
key={i}
title={cell.title}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
{cell.code}
</div>
);
}
if (cell.kind === 'proposed') {
return (
<div
key={i}
title={cell.title}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
>
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
{cell.code}
</span>
</div>
);
}
if (cell.kind === 'free') {
return (
<div
key={i}
className="h-8 border-b border-r border-ink bg-dots-sand"
style={{ backgroundSize: '5px 5px' }}
/>
);
}
return <div key={i} className="h-8 border-b border-r border-ink bg-sand" />;
})}
<div
className={cx(
'sticky right-0 z-[2] flex items-center justify-center gap-[3px] border-b border-l-[3px] border-ink font-mono text-[13px] font-bold',
row.over ? 'bg-orange' : 'bg-paper',
)}
>
{row.total}
{row.over ? <span className="font-display text-[9px] font-bold">▲</span> : null}
</div>
</>
);
}
+247
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
@@ -0,0 +1,251 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { Badge, Btn } from '@/components/ui';
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
import type { PlanningIndex } from '@/domain/selectors';
import { t } from '@/i18n';
function violationText(violation: Violation): string {
return t(`validation.${violation.code}`, violation.params);
}
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
const { state, dispatch } = usePlanning();
const [positionId, dayId, dayPartCode] = slot.split('|');
const positionsById = useMemo(
() => new Map(state.positions.map((p) => [p.id, p])),
[state.positions],
);
const position = positionsById.get(positionId);
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
const durationHours = dayPartHours(dayPartCode);
const candidates = useMemo(() => {
if (!position || !need) return [];
return state.volunteers
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
.map((volunteer) => {
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
const violations = evaluateAssignment({
volunteer,
target: { position, dayId, dayPartCode },
currentAssignments,
positionsById,
thresholds: {
maxDailyHours: state.config.maxDailyHours,
minRestHours: state.config.minRestHours,
},
});
const blocked = hasBlocking(violations);
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
const warnings = violations.filter((v) => v.severity === 'WARNING');
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
const wanted = volunteer.preferredCategories.includes(position.category);
const hours = index.totalHours.get(volunteer.id) ?? 0;
const score =
(available ? 100 : 0) +
(wanted ? 40 : 0) -
warnings.length * 12 -
hours * 2 -
(blocked ? 1000 : 0);
return {
volunteer,
blocked,
blocking,
warnings,
available,
wanted,
hours,
slots: currentAssignments.length,
score,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, 9);
}, [
position,
need,
state.volunteers,
state.config.maxDailyHours,
state.config.minRestHours,
assignedList,
index,
positionsById,
dayId,
dayPartCode,
]);
if (!position || !need) {
return (
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
{t('grille.panel.empty.body')}
</div>
);
}
const day = festivalDay(dayId);
const part = dayPart(dayPartCode);
return (
<div>
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-start gap-2">
<div className="flex-1">
<div className="font-display text-[16px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10px] text-yellow">
{position.zone} · {position.location}
</div>
</div>
<button
type="button"
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
aria-label={t('action.close')}
>
✕
</button>
</div>
<div className="mt-[9px] flex items-stretch gap-2">
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
{day.shortLabel} · {part.label}
</div>
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
{t('grille.panel.slotDuration', { hours: durationHours })}
</div>
</div>
<div className="mt-[9px] flex gap-[14px] font-mono text-[10px]">
<span>
{t('grille.panel.assigned')}{' '}
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
</span>
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
</div>
</div>
<div className="border-b-2 border-ink px-[13px] py-3">
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.assignmentsTitle', { count: assignedList.length })}
</div>
{assignedList.length === 0 ? (
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
) : (
assignedList.map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
if (!volunteer) return null;
return (
<div
key={assignment.id}
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
>
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
{volunteer.initials}
</span>
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
{volunteer.fullName}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursTotal', {
hours: index.totalHours.get(volunteer.id) ?? 0,
})}
</span>
</span>
{assignment.status === 'CONFIRMED' ? (
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
) : (
<Badge tone="proposed">{t('assignment.status.proposed')}</Badge>
)}
<button
type="button"
onClick={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
✕
</button>
</div>
);
})
)}
</div>
<div className="px-[13px] py-3">
<div className="mb-[9px] flex items-baseline gap-2">
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
{t('grille.panel.candidates')}
</span>
<span className="font-mono text-[9px] opacity-60">
{t('grille.panel.candidatesHint')}
</span>
</div>
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
<div
key={volunteer.id}
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
>
<div className="flex items-center gap-2 p-[7px]">
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
{volunteer.initials}
</span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.fullName}
<span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursAssigned', { hours, slots })}
</span>
</span>
{blocked ? (
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
{t('grille.panel.blocked')}
</span>
) : (
<Btn onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
{t('grille.panel.assign')}
</Btn>
)}
</div>
{(available || wanted) && (
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
{available ? (
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.availableTag')}
</span>
) : null}
{wanted ? (
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
{t('grille.panel.wantedTag')}
</span>
) : null}
</div>
)}
{blocked && blocking ? (
<div className="flex items-stretch border-t-2 border-ink">
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
⛔
</span>
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('grille.panel.blockingPrefix', { reason: violationText(blocking) })}
</span>
</div>
) : null}
{!blocked && warnings.length > 0 ? (
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
{t('grille.panel.warningsTitle')}
</div>
{warnings.map((warning, i) => (
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
· {violationText(warning)}
</div>
))}
</div>
) : null}
</div>
))}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { t } from '@/i18n';
/** Maquette de téléphone (mêmes proportions que le design). */
export function PhoneFrame({ time, children }: { time: string; children: ReactNode }) {
return (
<div className="flex-none rounded-[36px] border-[3px] border-ink bg-ink p-[9px] shadow-[9px_9px_0_rgba(20,17,14,0.22)]">
<div className="h-[772px] w-[376px] overflow-auto rounded-[27px] bg-cream">
<div className="flex justify-between bg-ink px-5 pt-[7px] font-mono text-[10px] font-medium text-cream">
<span>{time}</span>
<span>◗ ▮▮▮</span>
</div>
{children}
</div>
</div>
);
}
export function DesignNote({ children }: { children: ReactNode }) {
return (
<div className="min-w-[250px] max-w-[420px] flex-1 border-[3px] border-ink bg-paper p-[15px] shadow-brutal-lg">
<div className="mb-[9px] font-display text-[13px] font-black uppercase tracking-widest">
{t('design.noteTitle')}
</div>
<p className="font-display text-[12.5px] leading-relaxed">{children}</p>
</div>
);
}