Écran référent : vraie page responsive, référent correctement calculé
Deux bugs sur l'écran référent (/mobile-referent) : - Il était resté à l'étape maquette (`PhoneFrame`/`DesignNote`, cadre de téléphone à taille fixe) alors que la vue bénévole avait déjà été convertie en vraie page responsive. Repli sur `positions.data[0]` en l'absence de poste géré par le compte connecté : un référent qui n'est encore désigné sur aucun poste se voyait quand même affiché comme référent du premier poste de l'édition (« Accueil artistes » dans les données de démo) au lieu de voir qu'il n'en a aucun. Réécriture de `MobileLeadPage.tsx` en page responsive (mobile d'abord, `sm:` pour l'élargissement, comme `MobileVolunteerPage.tsx`) : plus de maquette de téléphone, une carte par poste réellement géré (`Position.lead_account_id === account.id`, sans repli), créneau en cours ou à venir calculé sur l'heure réelle (nouveau hook partagé `useNowMinutes`, extrait de la vue bénévole), équipe affectée avec pointage présent/absent (toujours local à l'écran : le pointage sur site reste hors périmètre, CLAUDE.md §5). État vide explicite si le compte n'est référent d'aucun poste. `PhoneFrame`/`DesignNote` n'avaient plus d'autre utilisateur : supprimés avec leurs clés i18n devenues mortes. 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:
co-authored by
Claude Sonnet 5
parent
a78aad58e2
commit
9e0be07726
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<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();
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const nowMinutes = useNowMinutes();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
// 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<string, ShiftRead[]>();
|
||||
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,11 +65,30 @@ export function MobileLeadPage() {
|
||||
);
|
||||
|
||||
if (positions.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error;
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!position || !shift) return null;
|
||||
|
||||
const team = (shiftAssignments.data ?? []).map((a) => {
|
||||
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">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||
{t('mobileLead.positionsCount', { count: myPositions.length })}
|
||||
</div>
|
||||
</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,
|
||||
@@ -49,54 +98,37 @@ export function MobileLeadPage() {
|
||||
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 (
|
||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||
<PhoneFrame time={t('mobileLead.statusTime')}>
|
||||
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
|
||||
<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="font-display text-[19px] font-black leading-tight tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||
{position.location}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex items-stretch gap-2">
|
||||
{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">
|
||||
{t('mobileLead.inProgress', {
|
||||
hours: hourRangeLabel(shift.starts_at, shift.ends_at),
|
||||
})}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{absentCount > 0 ? (
|
||||
<div className="border-b-[3px] border-ink bg-hatch-orange px-[14px] py-[11px]">
|
||||
<div className="font-display text-[15px] font-black leading-tight">
|
||||
{t('mobileLead.absenceAlert', { count: absentCount })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[9px] min-h-[52px] w-full cursor-pointer border-[3px] border-ink bg-ink font-display text-[14px] font-black text-cream hover:bg-yellow hover:text-ink"
|
||||
>
|
||||
{t('mobileLead.requestBackup')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-[11px] px-[14px] pb-[26px] pt-[13px]">
|
||||
<div className="font-display text-[12px] font-black uppercase tracking-widest">
|
||||
{t('mobileLead.teamTitle')}
|
||||
</div>
|
||||
{team.map((member) => (
|
||||
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||
|
||||
<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}
|
||||
@@ -105,9 +137,7 @@ export function MobileLeadPage() {
|
||||
<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 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">
|
||||
@@ -125,7 +155,7 @@ export function MobileLeadPage() {
|
||||
type="button"
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
|
||||
className={cx(
|
||||
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
|
||||
'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')}
|
||||
@@ -133,22 +163,28 @@ export function MobileLeadPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
|
||||
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
|
||||
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="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||
))
|
||||
) : (
|
||||
<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}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
<DesignNote>{t('mobileLead.designNote')}</DesignNote>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 px-1" aria-hidden="true">
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { t } from '@/i18n';
|
||||
|
||||
/** Maquette de téléphone (mêmes proportions que le design). */
|
||||
export function PhoneFrame({ time, children }: { time: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex-none rounded-[36px] border-[3px] border-ink bg-ink p-[9px] shadow-[9px_9px_0_rgba(20,17,14,0.22)]">
|
||||
<div className="h-[772px] w-[376px] overflow-auto rounded-[27px] bg-cream">
|
||||
<div className="flex justify-between bg-ink px-5 pt-[7px] font-mono text-[10px] font-medium text-cream">
|
||||
<span>{time}</span>
|
||||
<span>◗ ▮▮▮</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesignNote({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-[250px] max-w-[420px] flex-1 border-[3px] border-ink bg-paper p-[15px] shadow-brutal-lg">
|
||||
<div className="mb-[9px] font-display text-[13px] font-black uppercase tracking-widest">
|
||||
{t('design.noteTitle')}
|
||||
</div>
|
||||
<p className="font-display text-[12.5px] leading-relaxed">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user