diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b7a7e83..66d452d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -116,7 +116,6 @@ "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", "volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».", "volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.", - "design.noteTitle": "CHOIX DE CONCEPTION", "mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.greeting": "SALUT {name}", "mobileVol.slots": "{count} CRÉNEAUX", @@ -127,19 +126,16 @@ "mobileVol.now": "MAINTENANT", "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.", - "mobileLead.statusTime": "18:07", "mobileLead.kicker": "RÉFÉRENT DE POSTE", - "mobileLead.inProgress": "CRÉNEAU · {hours}", + "mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT", + "mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.", + "mobileLead.noShift": "Aucun créneau sur ce poste.", "mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS", - "mobileLead.absenceAlert": "⚠ {count} ABSENCE(S) SUR LE CRÉNEAU", - "mobileLead.requestBackup": "DEMANDER UN RENFORT À LA COORDINATION", - "mobileLead.teamTitle": "POINTAGE DE L'ÉQUIPE", "mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.markPresent": "PRÉSENT", "mobileLead.markAbsent": "ABSENT", "mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.", - "mobileLead.designNote": "Le référent ne voit que son poste et un créneau. Le pointage est un geste unique par personne. Il est local à cette maquette : le pointage sur site est hors périmètre (CLAUDE.md §4).", "config.admin": "ADMINISTRATION", "config.reserved": "RÉSERVÉ À LA COORDINATION", "config.tab.grid": "POSTES & CRÉNEAUX", diff --git a/frontend/src/lib/useNowMinutes.ts b/frontend/src/lib/useNowMinutes.ts new file mode 100644 index 0000000..20651d3 --- /dev/null +++ b/frontend/src/lib/useNowMinutes.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +import { nowNaiveMinutes } from '@/lib/time'; + +// Rafraîchit « maintenant » assez souvent pour qu'un repère qui en dépend +// bouge de façon perceptible sans re-rendre à outrance. +const REFRESH_MS = 30_000; + +/** Minutes « murales » actuelles (cf. `nowNaiveMinutes`), retenues en state et + * rafraîchies périodiquement pour rester réactif sans requête réseau. */ +export function useNowMinutes(): number { + const [minutes, setMinutes] = useState(() => nowNaiveMinutes()); + useEffect(() => { + const id = setInterval(() => setMinutes(nowNaiveMinutes()), REFRESH_MS); + return () => clearInterval(id); + }, []); + return minutes; +} diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx index f27f646..95da0ec 100644 --- a/frontend/src/pages/MobileLeadPage.tsx +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -1,33 +1,63 @@ import { useMemo, useState } from 'react'; +import type { ShiftRead } from '@/api/schema'; import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import { useAuth } from '@/auth/auth-context'; import { ErrorNote, Loading } from '@/components/state'; -import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; -import { hourRangeLabel } from '@/lib/time'; +import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; +import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; +// Page réellement consultée par le référent (accueil de son rôle LEAD, cf. +// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à +// partir de `sm:`), plus de maquette de téléphone. Un compte peut être +// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa +// propre carte, avec le créneau en cours ou à venir. Aucun repli vers « le +// premier poste de l'édition » si le compte n'est référent d'aucun poste — +// ce serait montrer un poste qui n'est pas le sien. +// +// 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; +/** Créneau à afficher pour un poste : celui en cours, sinon le prochain à + * venir, sinon (plus aucun à venir) le dernier passé. */ +function pickRelevantShift(shifts: ShiftRead[], nowMinutes: number): ShiftRead | null { + const sorted = [...shifts].sort( + (a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at), + ); + const current = sorted.find( + (s) => naiveMinutes(s.starts_at) <= nowMinutes && nowMinutes < naiveMinutes(s.ends_at), + ); + if (current) return current; + const next = sorted.find((s) => naiveMinutes(s.starts_at) > nowMinutes); + return next ?? sorted[sorted.length - 1] ?? null; +} + export function MobileLeadPage() { const { account } = useAuth(); const positions = usePositions(); const shifts = useShifts(); const volunteers = useVolunteers(); + const assignments = useAssignments(); + const nowMinutes = useNowMinutes(); const [presence, setPresence] = useState({}); - // Poste du référent connecté (repli : le premier, pour la démo coordination). const myPositions = (positions.data ?? []).filter( (p) => account && p.lead_account_id === account.id, ); - const position = myPositions[0] ?? (positions.data ?? [])[0]; - const positionShifts = useMemo( - () => (shifts.data ?? []).filter((s) => s.position_id === position?.id), - [shifts.data, position?.id], - ); - const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0]; - const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined); + + const shiftsByPosition = useMemo(() => { + const map = new Map(); + for (const s of shifts.data ?? []) { + const list = map.get(s.position_id) ?? []; + list.push(s); + map.set(s.position_id, list); + } + return map; + }, [shifts.data]); const volunteersById = useMemo( () => new Map((volunteers.data ?? []).map((v) => [v.id, v])), @@ -35,120 +65,126 @@ export function MobileLeadPage() { ); if (positions.isLoading || shifts.isLoading) return ; - const error = positions.error ?? shifts.error ?? volunteers.error; + const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error; if (error) return ; - if (!position || !shift) return null; - - const team = (shiftAssignments.data ?? []).map((a) => { - const volunteer = volunteersById.get(a.volunteer_id); - return { - id: a.id, - name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, - initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', - phone: volunteer?.phone ?? '', - present: presence[a.id] === 'present', - absent: presence[a.id] === 'absent', - }; - }); - - const presentCount = team.filter((m) => m.present).length; - const absentCount = team.filter((m) => m.absent).length; return ( -
- -
-
{t('mobileLead.kicker')}
-
- {position.name.toUpperCase()} -
- {position.location ? ( -
{position.location}
- ) : null} -
-
- {t('mobileLead.inProgress', { - hours: hourRangeLabel(shift.starts_at, shift.ends_at), - })} -
-
- {t('mobileLead.present', { present: presentCount, total: team.length })} -
-
+
+
+
{t('mobileLead.kicker')}
+
+ {t('mobileLead.positionsCount', { count: myPositions.length })}
+
- {absentCount > 0 ? ( -
-
- {t('mobileLead.absenceAlert', { count: absentCount })} -
- +
+ {myPositions.length === 0 ? ( +
+ {t('mobileLead.noPosition')}
) : null} + {myPositions.map((position) => { + const shift = pickRelevantShift(shiftsByPosition.get(position.id) ?? [], nowMinutes); + const team = shift + ? (assignments.data ?? []) + .filter((a) => a.shift_id === shift.id) + .map((a) => { + const volunteer = volunteersById.get(a.volunteer_id); + return { + id: a.id, + name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id, + initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?', + phone: volunteer?.phone ?? '', + present: presence[a.id] === 'present', + absent: presence[a.id] === 'absent', + }; + }) + : []; + const presentCount = team.filter((m) => m.present).length; -
-
- {t('mobileLead.teamTitle')} -
- {team.map((member) => ( -
-
- - {member.initials} - - - - {member.name} - - - {member.phone} - - - {member.present ? ( - - {t('mobileLead.badgePresent')} - + return ( +
+
+
+ {position.name.toUpperCase()} +
+ {position.location ? ( +
+ {position.location} +
) : null} - {member.absent ? ( - - {t('mobileLead.badgeAbsent')} - + {shift ? ( +
+
+ {longDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)} +
+
+ {t('mobileLead.present', { present: presentCount, total: team.length })} +
+
) : null}
-
- - + +
+ {shift ? ( + team.map((member) => ( +
+
+ + {member.initials} + + + + {member.name} + + {member.phone} + + {member.present ? ( + + {t('mobileLead.badgePresent')} + + ) : null} + {member.absent ? ( + + {t('mobileLead.badgeAbsent')} + + ) : null} +
+
+ + +
+
+ )) + ) : ( +
+ {t('mobileLead.noShift')} +
+ )} + {shift && team.length === 0 ? ( +
+ {t('mobileLead.emptyTeam')} +
+ ) : null}
- ))} - {team.length === 0 ? ( -
- {t('mobileLead.emptyTeam')} -
- ) : null} -
- - - {t('mobileLead.designNote')} + ); + })} +
); } diff --git a/frontend/src/pages/MobileVolunteerPage.tsx b/frontend/src/pages/MobileVolunteerPage.tsx index 098d5cb..ba30efd 100644 --- a/frontend/src/pages/MobileVolunteerPage.tsx +++ b/frontend/src/pages/MobileVolunteerPage.tsx @@ -1,9 +1,10 @@ -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useMemo } from 'react'; import { useVolunteer, useVolunteerPlanning } from '@/api/hooks'; import { useAuth } from '@/auth/auth-context'; import { ErrorNote, Loading } from '@/components/state'; -import { hourRangeLabel, longDayLabel, naiveMinutes, nowNaiveMinutes } from '@/lib/time'; +import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; +import { useNowMinutes } from '@/lib/useNowMinutes'; import { t } from '@/i18n'; // Page réellement consultée par le bénévole (accueil de son rôle, cf. @@ -15,19 +16,6 @@ import { t } from '@/i18n'; // de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut, // une seule action par créneau. -// Rafraîchit « maintenant » assez souvent pour que le repère bouge de façon -// perceptible sans re-rendre à outrance. -const NOW_REFRESH_MS = 30_000; - -function useNowMinutes(): number { - const [minutes, setMinutes] = useState(() => nowNaiveMinutes()); - useEffect(() => { - const id = setInterval(() => setMinutes(nowNaiveMinutes()), NOW_REFRESH_MS); - return () => clearInterval(id); - }, []); - return minutes; -} - function NowLine() { return (