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
133 lines
5.9 KiB
TypeScript
133 lines
5.9 KiB
TypeScript
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>
|
|
);
|
|
}
|