Référent aussi bénévole : onglets Mon équipe / Mon planning
ci-cd / build-backend (push) Successful in 7s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 12s

Un référent rattaché à une fiche bénévole voit aussi ses propres créneaux
(vue bénévole) dans un second onglet de son écran, ouvert d'office quand il
tient un créneau de bénévole hors de ses postes ; « TOI » dans l'équipe
pointée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 07:42:26 +01:00
co-authored by Claude Opus 5.5
parent 16d8fb44a7
commit a37f6c2c41
3 changed files with 281 additions and 195 deletions
+8 -1
View File
@@ -132,7 +132,13 @@ les tables sauf les tables d'association.
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le `LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
compte est rattaché à une fiche bénévole, sa vue de bénévole classique compte est rattaché à une fiche bénévole, sa vue de bénévole classique
(`VolunteerHomeView`) plutôt qu'un écran référent vide. (`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
aussi être affecté comme bénévole (même soirée comprise) : si son compte
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
planning » quand il tient en ce moment un créneau de bénévole hors de ses
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
- **`Position.message`** / `message_updated_at` : **message du poste** - **`Position.message`** / `message_updated_at` : **message du poste**
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis (consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
@@ -1008,6 +1014,7 @@ Aucune dépendance locale requise hors Docker.
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider | | 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider | | 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider | | 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+3
View File
@@ -174,6 +174,9 @@
"mobileLead.message.audience": "{count} bénévole(s) le verront", "mobileLead.message.audience": "{count} bénévole(s) le verront",
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.", "mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.", "mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
"mobileLead.view.team": "Mon équipe",
"mobileLead.view.planning": "Mon planning · {count}",
"mobileLead.you": "TOI",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.", "mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.", "mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgePresent": "✓ PRÉSENT",
+270 -194
View File
@@ -8,6 +8,7 @@ import {
useShifts, useShifts,
useUpdatePosition, useUpdatePosition,
useVolunteer, useVolunteer,
useVolunteerPlanning,
useVolunteers, useVolunteers,
} from '@/api/hooks'; } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
@@ -35,10 +36,18 @@ import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// courant : « Donner un accès de connexion » promu en référent), il voit // courant : « Donner un accès de connexion » promu en référent), il voit
// alors sa vue de bénévole classique (`VolunteerHomeView`). // alors sa vue de bénévole classique (`VolunteerHomeView`).
// //
// Un référent peut aussi être affecté comme bénévole, y compris la même
// soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
//
// Le pointage présent/absent reste local à l'écran (état React, jamais // Le pointage présent/absent reste local à l'écran (état React, jamais
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5). // envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
type Presence = Record<string, 'present' | 'absent'>; type Presence = Record<string, 'present' | 'absent'>;
type View = 'team' | 'planning';
const STATUS_KEY: Record<SlotStatus, string> = { const STATUS_KEY: Record<SlotStatus, string> = {
done: 'mobileLead.status.done', done: 'mobileLead.status.done',
@@ -54,6 +63,8 @@ export function MobileLeadPage() {
const assignments = useAssignments(); const assignments = useAssignments();
const dayParts = useDayParts(); const dayParts = useDayParts();
const me = useVolunteer(account?.volunteer_id); const me = useVolunteer(account?.volunteer_id);
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
const [view, setView] = useState<View | null>(null);
const updatePosition = useUpdatePosition(); const updatePosition = useUpdatePosition();
const now = useNowMinutes(); const now = useNowMinutes();
const [presence, setPresence] = useState<Presence>({}); const [presence, setPresence] = useState<Presence>({});
@@ -158,214 +169,251 @@ export function MobileLeadPage() {
: null; : null;
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null; const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
const inProgress = (start: string, end: string) =>
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
const myEntries = myPlanning.data ?? [];
const volunteeringNow = myEntries.some(
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
);
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
const tabs = account?.volunteer_id ? (
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
{t('mobileLead.view.team')}
</ViewTab>
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
{t('mobileLead.view.planning', { count: myEntries.length })}
</ViewTab>
</div>
) : null;
if (currentView === 'planning' && account?.volunteer_id) {
return (
<div className="flex flex-1 flex-col">
{tabs}
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
</div>
);
}
return ( return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6"> {tabs}
{myPositions.length > 1 ? ( <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="mb-3 flex flex-wrap gap-[6px]"> <div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
{myPositions.map((p) => ( {myPositions.length > 1 ? (
<button <div className="mb-3 flex flex-wrap gap-[6px]">
key={p.id} {myPositions.map((p) => (
type="button"
onClick={() => {
setPositionId(p.id);
setShiftId(null);
}}
className={cx(
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
)}
>
{p.name}
</button>
))}
</div>
) : null}
<div className="flex items-center gap-3">
{me.data ? (
<VolunteerAvatar
volunteerId={me.data.id}
hasPhoto={me.data.has_photo}
initials={`${me.data.first_name[0]}${me.data.last_name[0]}`}
className="h-[60px] w-[60px] !border-blue text-[19px]"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
</div>
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
{position.location}
</div>
) : null}
</div>
</div>
{positionShifts.length ? (
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
{positionShifts.map((s) => {
const on = s.id === shift?.id;
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
return (
<button <button
key={s.id} key={p.id}
type="button" type="button"
onClick={() => setShiftId(s.id)} onClick={() => {
aria-pressed={on} setPositionId(p.id);
setShiftId(null);
}}
className={cx( className={cx(
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left', 'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
on ? 'bg-blue text-ink' : 'bg-transparent text-cream', p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
)} )}
> >
<span className="block font-display text-[12px] font-black leading-tight"> {p.name}
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
</span>
<span className="block font-mono text-[11px] font-semibold leading-snug">
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
{t(STATUS_KEY[status])}
</span>
</button> </button>
); ))}
})}
</div>
) : (
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
)}
</div>
{shift ? (
<>
<div className="grid grid-cols-3 border-b-[3px] border-ink">
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
<Counter
value={team.length - presentCount - absentCount}
label={t('mobileLead.count.waiting')}
className="border-r-2 bg-paper"
/>
<Counter
value={absentCount}
label={t('mobileLead.count.absent')}
className={absentCount ? 'bg-orange' : 'bg-paper'}
/>
</div>
{absentCount ? (
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
count: absentCount,
})}
</div> </div>
) : null} ) : null}
<div className="flex items-center gap-3">
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6"> {me.data ? (
<div className="flex items-baseline gap-2"> <VolunteerAvatar
<span className="font-display text-[12px] font-black tracking-wider"> volunteerId={me.data.id}
{t('mobileLead.roll', { count: team.length })} hasPhoto={me.data.has_photo}
</span> initials={`${me.data.first_name[0]}${me.data.last_name[0]}`}
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65"> className="h-[60px] w-[60px] !border-blue text-[19px]"
{shiftStatus === 'now' />
? t('mobileLead.when.now') ) : null}
: shiftStatus === 'next' <div className="min-w-0 flex-1">
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) }) <div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
: t('mobileLead.when.done')} {myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
</span>
</div>
{team.map((member) => (
<div
key={member.id}
className={cx(
'border-[3px] border-ink shadow-brutal-md',
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
)}
>
<div className="flex items-center gap-[10px] px-[10px] py-[9px]">
<VolunteerAvatar
volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto}
initials={member.initials}
className="h-12 w-12 !border-2 text-[15px]"
/>
<span className="min-w-0 flex-1">
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name}
</span>
{member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone}
</span>
) : null}
</span>
{member.phone ? (
<a
href={`tel:${member.phone.replace(/\s+/g, '')}`}
aria-label={t('mobileLead.callLabel', { name: member.name })}
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
>
{t('mobileLead.call')}
</a>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => mark(member.id, 'present')}
aria-pressed={member.state === 'present'}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
)}
>
{t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() => mark(member.id, 'absent')}
aria-pressed={member.state === 'absent'}
className={cx(
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
>
{t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button>
</div>
</div> </div>
))} <div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
{team.length === 0 ? ( {position.name.toUpperCase()}
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70"> </div>
{t('mobileLead.emptyTeam')} {position.location ? (
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
{position.location}
</div>
) : null}
</div>
</div>
{positionShifts.length ? (
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
{positionShifts.map((s) => {
const on = s.id === shift?.id;
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
return (
<button
key={s.id}
type="button"
onClick={() => setShiftId(s.id)}
aria-pressed={on}
className={cx(
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
)}
>
<span className="block font-display text-[12px] font-black leading-tight">
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
</span>
<span className="block font-mono text-[11px] font-semibold leading-snug">
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
{t(STATUS_KEY[status])}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
)}
</div>
{shift ? (
<>
<div className="grid grid-cols-3 border-b-[3px] border-ink">
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
<Counter
value={team.length - presentCount - absentCount}
label={t('mobileLead.count.waiting')}
className="border-r-2 bg-paper"
/>
<Counter
value={absentCount}
label={t('mobileLead.count.absent')}
className={absentCount ? 'bg-orange' : 'bg-paper'}
/>
</div>
{absentCount ? (
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
count: absentCount,
})}
</div> </div>
) : null} ) : null}
</div>
</>
) : null}
{/* Message du poste : montré à chaque bénévole affecté, sur son <div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
écran (créneau mis en avant et liste des créneaux). */} <div className="flex items-baseline gap-2">
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6"> <span className="font-display text-[12px] font-black tracking-wider">
<div className="flex items-baseline gap-2"> {t('mobileLead.roll', { count: team.length })}
<span className="font-display text-[12px] font-black tracking-wider"> </span>
{t('mobileLead.message.title')} <span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
</span> {shiftStatus === 'now'
<span className="ml-auto font-mono text-[10px] font-medium opacity-65"> ? t('mobileLead.when.now')
{t('mobileLead.message.audience', { count: teamSize })} : shiftStatus === 'next'
</span> ? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
: t('mobileLead.when.done')}
</span>
</div>
{team.map((member) => (
<div
key={member.id}
className={cx(
'border-[3px] border-ink shadow-brutal-md',
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
)}
>
<div className="flex items-center gap-[10px] px-[10px] py-[9px]">
<VolunteerAvatar
volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto}
initials={member.initials}
className="h-12 w-12 !border-2 text-[15px]"
/>
<span className="min-w-0 flex-1">
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name}
{member.volunteerId === account?.volunteer_id ? (
<span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
{t('mobileLead.you')}
</span>
) : null}
</span>
{member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone}
</span>
) : null}
</span>
{member.phone ? (
<a
href={`tel:${member.phone.replace(/\s+/g, '')}`}
aria-label={t('mobileLead.callLabel', { name: member.name })}
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
>
{t('mobileLead.call')}
</a>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => mark(member.id, 'present')}
aria-pressed={member.state === 'present'}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
)}
>
{t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() => mark(member.id, 'absent')}
aria-pressed={member.state === 'absent'}
className={cx(
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
>
{t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button>
</div>
</div>
))}
{team.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</>
) : null}
{/* Message du poste : montré à chaque bénévole affecté, sur son
écran (créneau mis en avant et liste des créneaux). */}
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.message.title')}
</span>
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
{t('mobileLead.message.audience', { count: teamSize })}
</span>
</div>
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('mobileLead.message.help')}
</p>
<MessageEditor
key={position.id}
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={updatePosition.isPending}
error={updatePosition.error}
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div> </div>
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('mobileLead.message.help')}
</p>
<MessageEditor
key={position.id}
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={updatePosition.isPending}
error={updatePosition.error}
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div> </div>
</div> </div>
); );
@@ -379,3 +427,31 @@ function Counter({ value, label, className }: { value: number; label: string; cl
</div> </div>
); );
} }
function ViewTab({
active,
live,
onClick,
children,
}: {
active: boolean;
/** Un créneau de cette vue est en cours : pastille orange. */
live: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cx(
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
)}
>
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
{children}
</button>
);
}