Écran référent : repli sur la vue bénévole, tous les créneaux du poste

Deux bugs remontés sur l'écran référent (/mobile-referent), tous deux
révélés par le repli documenté « décocher un poste ne rétrograde jamais
le compte » (§2) :

- Un compte LEAD qui ne référence plus aucun poste (ex. décoché) reste
  LEAD et atterrit donc sur /mobile-referent après son lien magique —
  qui affichait un écran référent vide au lieu de sa vue de bénévole
  classique. `MobileLeadPage` détecte maintenant ce cas et affiche,
  quand le compte est rattaché à une fiche bénévole, le même contenu
  que `/mobile-benevole` (planning : poste, jour, horaire, lieu,
  responsable, téléphone) — extrait dans un composant partagé
  `VolunteerHomeView`.

- Un poste avec plusieurs créneaux n'en montrait qu'un seul (le plus
  proche de l'heure réelle) : le référent ne pouvait pas voir son
  équipe sur les autres créneaux. La carte de chaque poste liste
  maintenant tous ses créneaux, chacun avec sa propre équipe, avec un
  repère « MAINTENANT » (composant partagé `NowLine`, déjà utilisé côté
  bénévole) pour se situer parmi eux.

Non inclus dans ce commit (édités en parallèle par l'utilisateur, laissés
en l'état) : backend/app/seed.py, frontend/tailwind.config.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-16 07:50:00 +02:00
co-authored by Claude Sonnet 5
parent 9e0be07726
commit ade59b3dc4
6 changed files with 282 additions and 216 deletions
+136 -102
View File
@@ -1,41 +1,33 @@
import { useMemo, useState } from 'react';
import { Fragment, 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 { NowLine } from '@/components/NowLine';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// 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.
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
// un repère « MAINTENANT » pour se situer. 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 ; s'il est par ailleurs rattaché à
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
// écran référent vide (`VolunteerHomeView`).
//
// 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<string, 'present' | 'absent'>;
/** 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();
@@ -56,6 +48,9 @@ export function MobileLeadPage() {
list.push(s);
map.set(s.position_id, list);
}
for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
}
return map;
}, [shifts.data]);
@@ -68,6 +63,24 @@ export function MobileLeadPage() {
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
if (error) return <ErrorNote error={error} />;
if (myPositions.length === 0) {
if (account?.volunteer_id) {
return (
<VolunteerHomeView
volunteerId={account.volunteer_id}
fallbackName={account.email.split('@')[0]}
/>
);
}
return (
<div className="mx-auto w-full max-w-[640px] p-4 sm:p-6">
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noPosition')}
</div>
</div>
);
}
return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
@@ -78,33 +91,14 @@ export function MobileLeadPage() {
</div>
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
{myPositions.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noPosition')}
</div>
) : 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;
const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
return (
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[14px] pb-[15px] pt-[11px] text-cream">
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
@@ -113,72 +107,112 @@ export function MobileLeadPage() {
{position.location}
</div>
) : null}
{shift ? (
<div className="mt-3 flex flex-wrap items-stretch gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{longDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
</div>
</div>
) : null}
</div>
<div className="flex flex-col gap-[11px] px-[14px] pb-[18px] pt-[13px]">
{shift ? (
team.map((member) => (
<div key={member.id} className="border-[3px] border-ink bg-cream shadow-brutal-sm">
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight">
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">{member.phone}</span>
</span>
{member.present ? (
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
{t('mobileLead.badgePresent')}
</span>
) : null}
{member.absent ? (
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
{t('mobileLead.badgeAbsent')}
</span>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
)}
>
{t('mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
</button>
</div>
</div>
))
) : (
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
{positionShifts.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noShift')}
</div>
)}
{shift && team.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
{positionShifts.map((shift, i) => {
const team = (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;
return (
<Fragment key={shift.id}>
{i === nowInsertAt ? <NowLine /> : null}
<div className="border-2 border-ink">
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{longDayLabel(shift.starts_at)} ·{' '}
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
</div>
</div>
<div className="flex flex-col gap-[11px] p-[11px]">
{team.map((member) => (
<div
key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
>
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials}
</span>
<span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight">
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">
{member.phone}
</span>
</span>
{member.present ? (
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
{t('mobileLead.badgePresent')}
</span>
) : null}
{member.absent ? (
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
{t('mobileLead.badgeAbsent')}
</span>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() =>
setPresence((p) => ({ ...p, [member.id]: 'present' }))
}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
)}
>
{t('mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() =>
setPresence((p) => ({ ...p, [member.id]: 'absent' }))
}
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
</button>
</div>
</div>
))}
{team.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</div>
</Fragment>
);
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null}
</div>
</div>