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",
+76
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,7 +169,38 @@ 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 ( return (
<div className="flex flex-1 flex-col">
{tabs}
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
</div>
);
}
return (
<div className="flex flex-1 flex-col">
{tabs}
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6"> <div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
{myPositions.length > 1 ? ( {myPositions.length > 1 ? (
@@ -291,6 +333,11 @@ export function MobileLeadPage() {
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block break-words font-display text-[16px] font-extrabold leading-tight"> <span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name} {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> </span>
{member.phone ? ( {member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70"> <span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
@@ -368,6 +415,7 @@ export function MobileLeadPage() {
/> />
</div> </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>
);
}