É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:
co-authored by
Claude Sonnet 5
parent
9e0be07726
commit
ade59b3dc4
@@ -101,7 +101,11 @@ les tables sauf les tables d'association.
|
|||||||
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
|
poste y crée ou met à niveau (`PATCH /accounts/{id}` : voir plus bas) son
|
||||||
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
|
compte en `LEAD` — à sa prochaine connexion il voit l'écran référent
|
||||||
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
|
(`/mobile-referent`) plutôt que l'écran bénévole. Décocher ne fait que
|
||||||
retirer le poste, sans jamais rétrograder le compte automatiquement.
|
retirer le poste, sans jamais rétrograder le compte automatiquement : un
|
||||||
|
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.
|
||||||
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
|
||||||
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
|
||||||
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
(peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
|
||||||
|
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
|
||||||
|
* correspondant à l'heure réelle. Purement visuel. */
|
||||||
|
export function NowLine() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-1" aria-hidden="true">
|
||||||
|
<div className="h-[3px] flex-1 bg-orange" />
|
||||||
|
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
|
||||||
|
{t('common.now')}
|
||||||
|
</div>
|
||||||
|
<div className="h-[3px] flex-1 bg-orange" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"action.remove": "Retirer",
|
"action.remove": "Retirer",
|
||||||
"action.add": "Ajouter",
|
"action.add": "Ajouter",
|
||||||
"app.loading": "Chargement…",
|
"app.loading": "Chargement…",
|
||||||
|
"common.now": "MAINTENANT",
|
||||||
"app.error.generic": "Une erreur est survenue",
|
"app.error.generic": "Une erreur est survenue",
|
||||||
"error.generic": "Erreur inattendue",
|
"error.generic": "Erreur inattendue",
|
||||||
"error.not_found": "Ressource introuvable",
|
"error.not_found": "Ressource introuvable",
|
||||||
@@ -123,7 +124,6 @@
|
|||||||
"mobileVol.location": "📍 {location}",
|
"mobileVol.location": "📍 {location}",
|
||||||
"mobileVol.referent": "👤 Responsable : {name}",
|
"mobileVol.referent": "👤 Responsable : {name}",
|
||||||
"mobileVol.phone": "📞 {phone}",
|
"mobileVol.phone": "📞 {phone}",
|
||||||
"mobileVol.now": "MAINTENANT",
|
|
||||||
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
"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.",
|
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
|
||||||
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
|
||||||
|
|||||||
@@ -1,41 +1,33 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { ShiftRead } from '@/api/schema';
|
import type { ShiftRead } from '@/api/schema';
|
||||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { NowLine } from '@/components/NowLine';
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
import { useNowMinutes } from '@/lib/useNowMinutes';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
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.
|
// 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 à
|
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à
|
||||||
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
|
// 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
|
// 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
|
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
|
||||||
// premier poste de l'édition » si le compte n'est référent d'aucun poste —
|
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
|
||||||
// ce serait montrer un poste qui n'est pas le sien.
|
// 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
|
// 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'>;
|
||||||
|
|
||||||
/** 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() {
|
export function MobileLeadPage() {
|
||||||
const { account } = useAuth();
|
const { account } = useAuth();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
@@ -56,6 +48,9 @@ export function MobileLeadPage() {
|
|||||||
list.push(s);
|
list.push(s);
|
||||||
map.set(s.position_id, list);
|
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;
|
return map;
|
||||||
}, [shifts.data]);
|
}, [shifts.data]);
|
||||||
|
|
||||||
@@ -68,6 +63,24 @@ export function MobileLeadPage() {
|
|||||||
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
|
||||||
if (error) return <ErrorNote error={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 (
|
return (
|
||||||
<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 py-5 text-cream sm:px-6 sm:py-7">
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
|
<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) => {
|
{myPositions.map((position) => {
|
||||||
const shift = pickRelevantShift(shiftsByPosition.get(position.id) ?? [], nowMinutes);
|
const positionShifts = shiftsByPosition.get(position.id) ?? [];
|
||||||
const team = shift
|
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||||
? (assignments.data ?? [])
|
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
|
||||||
.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 (
|
return (
|
||||||
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
<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">
|
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
|
||||||
{position.name.toUpperCase()}
|
{position.name.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,72 +107,112 @@ export function MobileLeadPage() {
|
|||||||
{position.location}
|
{position.location}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-[11px] px-[14px] pb-[18px] pt-[13px]">
|
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
|
||||||
{shift ? (
|
{positionShifts.length === 0 ? (
|
||||||
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="font-mono text-[11px] leading-relaxed opacity-70">
|
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
||||||
{t('mobileLead.noShift')}
|
{t('mobileLead.noShift')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{shift && team.length === 0 ? (
|
{positionShifts.map((shift, i) => {
|
||||||
<div className="font-mono text-[11px] leading-relaxed opacity-70">
|
const team = (assignments.data ?? [])
|
||||||
{t('mobileLead.emptyTeam')}
|
.filter((a) => a.shift_id === shift.id)
|
||||||
</div>
|
.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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,49 +1,19 @@
|
|||||||
import { Fragment, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
|
||||||
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time';
|
|
||||||
import { useNowMinutes } from '@/lib/useNowMinutes';
|
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
|
||||||
|
|
||||||
// Page réellement consultée par le bénévole (accueil de son rôle, cf.
|
// Page réellement consultée par le bénévole (accueil de son rôle, cf.
|
||||||
// `auth/roles.ts:homePath`) — sur son propre téléphone la plupart du temps,
|
// `auth/roles.ts:homePath`) — sur son propre téléphone la plupart du temps,
|
||||||
// mais aussi possible depuis un ordinateur : le rendu ci-dessous s'adapte
|
// mais aussi possible depuis un ordinateur : le rendu s'adapte (mobile
|
||||||
// (mobile d'abord, une seule colonne pleine largeur ; élargi et centré à
|
// d'abord, une seule colonne pleine largeur ; élargi et centré à partir de
|
||||||
// partir de `sm:`), sans jamais simuler un cadre de téléphone. Repères de
|
// `sm:`), sans jamais simuler un cadre de téléphone. Le contenu lui-même vit
|
||||||
// conception conservés : fonds opaques, texte noir sur jaune ou blanc (pas
|
// dans `VolunteerHomeView`, également montré à un compte RÉFÉRENT sans poste
|
||||||
// de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut,
|
// actuellement assigné (voir `MobileLeadPage.tsx`).
|
||||||
// une seule action par créneau.
|
|
||||||
|
|
||||||
function NowLine() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-1" aria-hidden="true">
|
|
||||||
<div className="h-[3px] flex-1 bg-orange" />
|
|
||||||
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
|
|
||||||
{t('mobileVol.now')}
|
|
||||||
</div>
|
|
||||||
<div className="h-[3px] flex-1 bg-orange" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobileVolunteerPage() {
|
export function MobileVolunteerPage() {
|
||||||
const { account } = useAuth();
|
const { account } = useAuth();
|
||||||
const volunteerId = account?.volunteer_id ?? null;
|
const volunteerId = account?.volunteer_id ?? null;
|
||||||
|
|
||||||
const meVolunteer = useVolunteer(volunteerId);
|
|
||||||
const planning = useVolunteerPlanning(volunteerId);
|
|
||||||
const nowMinutes = useNowMinutes();
|
|
||||||
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
|
|
||||||
const slots = useMemo(
|
|
||||||
() =>
|
|
||||||
[...(planning.data ?? [])].sort(
|
|
||||||
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
|
|
||||||
),
|
|
||||||
[planning.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!volunteerId) {
|
if (!volunteerId) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[640px] p-4 sm:p-6">
|
<div className="mx-auto w-full max-w-[640px] p-4 sm:p-6">
|
||||||
@@ -53,83 +23,8 @@ export function MobileVolunteerPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (planning.isLoading) return <Loading />;
|
|
||||||
if (planning.error) return <ErrorNote error={planning.error} />;
|
|
||||||
|
|
||||||
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
|
|
||||||
|
|
||||||
const totalHours =
|
|
||||||
Math.round(
|
|
||||||
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
|
|
||||||
10,
|
|
||||||
) / 10;
|
|
||||||
|
|
||||||
// Le repère « maintenant » se place juste avant le premier créneau pas
|
|
||||||
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
|
|
||||||
// passés.
|
|
||||||
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
|
||||||
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
<VolunteerHomeView volunteerId={volunteerId} fallbackName={account?.email.split('@')[0]} />
|
||||||
<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('mobileVol.kicker')}</div>
|
|
||||||
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
|
||||||
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
|
||||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
|
||||||
{t('mobileVol.slots', { count: slots.length })}
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
|
||||||
{t('mobileVol.hours', { count: totalHours })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
|
|
||||||
{slots.map((entry, i) => (
|
|
||||||
<Fragment key={entry.assignment_id}>
|
|
||||||
{i === nowInsertAt ? <NowLine /> : null}
|
|
||||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
|
||||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
|
||||||
<span className="font-display text-[15px] font-black">
|
|
||||||
{longDayLabel(entry.starts_at)}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
|
||||||
{hourRangeLabel(entry.starts_at, entry.ends_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="p-[11px]">
|
|
||||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
|
||||||
{entry.position_name.toUpperCase()}
|
|
||||||
</div>
|
|
||||||
{entry.position_location ? (
|
|
||||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
|
||||||
{t('mobileVol.location', { location: entry.position_location })}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{entry.referent_name ? (
|
|
||||||
<div className="mt-[9px] font-mono text-[12px]">
|
|
||||||
{t('mobileVol.referent', { name: entry.referent_name })}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{entry.referent_phone ? (
|
|
||||||
<div className="mt-[3px] font-mono text-[12px]">
|
|
||||||
{t('mobileVol.phone', { phone: entry.referent_phone })}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
|
|
||||||
{slots.length === 0 ? (
|
|
||||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
|
||||||
{t('mobileVol.noSlots')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Fragment, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
|
||||||
|
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
|
||||||
|
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
|
||||||
|
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
|
||||||
|
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
|
||||||
|
* vide (voir `MobileLeadPage.tsx`).
|
||||||
|
*/
|
||||||
|
export function VolunteerHomeView({
|
||||||
|
volunteerId,
|
||||||
|
fallbackName,
|
||||||
|
}: {
|
||||||
|
volunteerId: string;
|
||||||
|
/** Utilisé si la fiche bénévole n'est pas encore chargée (ex. email). */
|
||||||
|
fallbackName?: string;
|
||||||
|
}) {
|
||||||
|
const meVolunteer = useVolunteer(volunteerId);
|
||||||
|
const planning = useVolunteerPlanning(volunteerId);
|
||||||
|
const nowMinutes = useNowMinutes();
|
||||||
|
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
|
||||||
|
const slots = useMemo(
|
||||||
|
() =>
|
||||||
|
[...(planning.data ?? [])].sort(
|
||||||
|
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
|
||||||
|
),
|
||||||
|
[planning.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (planning.isLoading) return <Loading />;
|
||||||
|
if (planning.error) return <ErrorNote error={planning.error} />;
|
||||||
|
|
||||||
|
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
|
||||||
|
|
||||||
|
const totalHours =
|
||||||
|
Math.round(
|
||||||
|
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
|
||||||
|
10,
|
||||||
|
) / 10;
|
||||||
|
|
||||||
|
// Le repère « maintenant » se place juste avant le premier créneau pas
|
||||||
|
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
|
||||||
|
// passés.
|
||||||
|
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
|
||||||
|
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
|
||||||
|
|
||||||
|
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('mobileVol.kicker')}</div>
|
||||||
|
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
|
||||||
|
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
|
{t('mobileVol.slots', { count: slots.length })}
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||||
|
{t('mobileVol.hours', { count: totalHours })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
|
||||||
|
{slots.map((entry, i) => (
|
||||||
|
<Fragment key={entry.assignment_id}>
|
||||||
|
{i === nowInsertAt ? <NowLine /> : null}
|
||||||
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||||
|
<span className="font-display text-[15px] font-black">
|
||||||
|
{longDayLabel(entry.starts_at)}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
||||||
|
{hourRangeLabel(entry.starts_at, entry.ends_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-[11px]">
|
||||||
|
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||||
|
{entry.position_name.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{entry.position_location ? (
|
||||||
|
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||||
|
{t('mobileVol.location', { location: entry.position_location })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{entry.referent_name ? (
|
||||||
|
<div className="mt-[9px] font-mono text-[12px]">
|
||||||
|
{t('mobileVol.referent', { name: entry.referent_name })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{entry.referent_phone ? (
|
||||||
|
<div className="mt-[3px] font-mono text-[12px]">
|
||||||
|
{t('mobileVol.phone', { phone: entry.referent_phone })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
|
||||||
|
{slots.length === 0 ? (
|
||||||
|
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||||
|
{t('mobileVol.noSlots')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user