diff --git a/CLAUDE.md b/CLAUDE.md index c91beb8..5d98482 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `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 - (`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** (consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis 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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index ea8a3f4..2b3e4b8 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -174,6 +174,9 @@ "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.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.noShift": "Aucun créneau sur ce poste.", "mobileLead.badgePresent": "✓ PRÉSENT", diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx index 82ed64f..2d8fb86 100644 --- a/frontend/src/pages/MobileLeadPage.tsx +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -8,6 +8,7 @@ import { useShifts, useUpdatePosition, useVolunteer, + useVolunteerPlanning, useVolunteers, } from '@/api/hooks'; 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 // 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 // envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5). type Presence = Record; +type View = 'team' | 'planning'; const STATUS_KEY: Record = { done: 'mobileLead.status.done', @@ -54,6 +63,8 @@ export function MobileLeadPage() { const assignments = useAssignments(); const dayParts = useDayParts(); const me = useVolunteer(account?.volunteer_id); + const myPlanning = useVolunteerPlanning(account?.volunteer_id); + const [view, setView] = useState(null); const updatePosition = useUpdatePosition(); const now = useNowMinutes(); const [presence, setPresence] = useState({}); @@ -158,214 +169,251 @@ export function MobileLeadPage() { : 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 ? ( +
+ setView('team')}> + {t('mobileLead.view.team')} + + setView('planning')}> + {t('mobileLead.view.planning', { count: myEntries.length })} + +
+ ) : null; + + if (currentView === 'planning' && account?.volunteer_id) { + return ( +
+ {tabs} + +
+ ); + } + return ( -
-
- {myPositions.length > 1 ? ( -
- {myPositions.map((p) => ( - - ))} -
- ) : null} -
- {me.data ? ( - - ) : null} -
-
- {myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')} -
-
- {position.name.toUpperCase()} -
- {position.location ? ( -
- {position.location} -
- ) : null} -
-
- {positionShifts.length ? ( -
- {positionShifts.map((s) => { - const on = s.id === shift?.id; - const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now); - return ( +
+ {tabs} +
+
+ {myPositions.length > 1 ? ( +
+ {myPositions.map((p) => ( - ); - })} -
- ) : ( -
{t('mobileLead.noShift')}
- )} -
- - {shift ? ( - <> -
- - - -
- - {absentCount ? ( -
- {t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', { - count: absentCount, - })} + ))}
) : null} - -
-
- - {t('mobileLead.roll', { count: team.length })} - - - {shiftStatus === 'now' - ? t('mobileLead.when.now') - : shiftStatus === 'next' - ? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) }) - : t('mobileLead.when.done')} - -
- {team.map((member) => ( -
-
- - - - {member.name} - - {member.phone ? ( - - {member.phone} - - ) : null} - - {member.phone ? ( - - {t('mobileLead.call')} - - ) : null} -
-
- - -
+
+ {me.data ? ( + + ) : null} +
+
+ {myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
- ))} - {team.length === 0 ? ( -
- {t('mobileLead.emptyTeam')} +
+ {position.name.toUpperCase()} +
+ {position.location ? ( +
+ {position.location} +
+ ) : null} +
+
+ {positionShifts.length ? ( +
+ {positionShifts.map((s) => { + const on = s.id === shift?.id; + const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now); + return ( + + ); + })} +
+ ) : ( +
{t('mobileLead.noShift')}
+ )} +
+ + {shift ? ( + <> +
+ + + +
+ + {absentCount ? ( +
+ {t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', { + count: absentCount, + })}
) : null} -
- - ) : null} - {/* Message du poste : montré à chaque bénévole affecté, sur son - écran (créneau mis en avant et liste des créneaux). */} -
-
- - {t('mobileLead.message.title')} - - - {t('mobileLead.message.audience', { count: teamSize })} - +
+
+ + {t('mobileLead.roll', { count: team.length })} + + + {shiftStatus === 'now' + ? t('mobileLead.when.now') + : shiftStatus === 'next' + ? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) }) + : t('mobileLead.when.done')} + +
+ {team.map((member) => ( +
+
+ + + + {member.name} + {member.volunteerId === account?.volunteer_id ? ( + + {t('mobileLead.you')} + + ) : null} + + {member.phone ? ( + + {member.phone} + + ) : null} + + {member.phone ? ( + + {t('mobileLead.call')} + + ) : null} +
+
+ + +
+
+ ))} + {team.length === 0 ? ( +
+ {t('mobileLead.emptyTeam')} +
+ ) : null} +
+ + ) : null} + + {/* Message du poste : montré à chaque bénévole affecté, sur son + écran (créneau mis en avant et liste des créneaux). */} +
+
+ + {t('mobileLead.message.title')} + + + {t('mobileLead.message.audience', { count: teamSize })} + +
+

+ {t('mobileLead.message.help')} +

+ updatePosition.mutateAsync({ id: position.id, body: { message: text } })} + />
-

- {t('mobileLead.message.help')} -

- updatePosition.mutateAsync({ id: position.id, body: { message: text } })} - />
); @@ -379,3 +427,31 @@ function Counter({ value, label, className }: { value: number; label: string; cl
); } + +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 ( + + ); +}