Vue bénévole mobile : responsable, téléphone et repère « maintenant »

La vue bénévole reprenait les infos essentielles (poste, jour, horaire,
lieu) mais affichait un badge de validation factice (« TU ES ATTENDU
SUR CE CRÉNEAU ») sans plus-value, sans jamais donner le nom ni le
téléphone du référent du poste à contacter — pourtant déjà modélisés
(`Position.lead_account_id`) depuis la gestion des référents.

Un compte VOLUNTEER n'a pas accès à `positions`/`shifts` (403, cf. § 2
matrice des accès) : ces infos ne pouvaient donc pas être jointes côté
front à partir des listes existantes. Nouvel endpoint dédié et
minimal, réservé à la lecture du planning propre du bénévole :

  GET /editions/{id}/volunteers/{vid}/planning

Renvoie, par affectation : poste, lieu, et référent (nom + téléphone,
`null` si le poste n'a pas de référent ou si son compte n'est pas
rattaché à une fiche bénévole) — sans exposer la liste complète des
postes/créneaux de l'édition. Tests : accès (soi-même / autre
bénévole 403 / COORD+LEAD), présence/absence du référent, tri par
heure de début.

Front : retrait du badge de validation, ajout des lignes responsable/
téléphone par créneau, et un repère horizontal « MAINTENANT » qui se
positionne dans la liste triée des créneaux selon l'heure réelle (juste
avant le prochain créneau à venir) pour situer le bénévole dans son
planning.

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-15 23:33:06 +02:00
co-authored by Claude Sonnet 5
parent 8fcc22115f
commit a78aad58e2
12 changed files with 391 additions and 78 deletions
+6
View File
@@ -120,6 +120,12 @@ les tables sauf les tables d'association.
défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`, défaut `MANUAL`), `locked` (défaut `false`). Unique (`volunteer_id`,
`shift_id`). **Pas de statut** : l'affectation est valide dès sa création `shift_id`). **Pas de statut** : l'affectation est valide dès sa création
(aucune étape de confirmation) ; la retirer, c'est supprimer la ligne. (aucune étape de confirmation) ; la retirer, c'est supprimer la ligne.
`GET /editions/{id}/volunteers/{vid}/planning` (lecture : COORD / LEAD ou le
bénévole lui-même) renvoie son planning enrichi (poste, lieu, référent du
poste + son téléphone si son compte est rattaché à une fiche bénévole) sans
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique). `volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
+18
View File
@@ -84,6 +84,24 @@ class VolunteerRead(ApiModel):
updated_at: dt.datetime updated_at: dt.datetime
class VolunteerPlanningEntry(ApiModel):
"""Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour
l'écran bénévole (`GET .../volunteers/{id}/planning`), sans jamais
exposer la liste complète des postes/créneaux de l'édition à un compte
VOLUNTEER (lecture restreinte à sa propre fiche, cf. § 2 matrice)."""
assignment_id: uuid.UUID
shift_id: uuid.UUID
starts_at: dt.datetime
ends_at: dt.datetime
position_name: str
position_location: str | None
# None si le poste n'a pas (encore) de référent, ou si son compte n'est
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None
referent_phone: str | None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Artist (programmation) # # Artist (programmation) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+54 -1
View File
@@ -6,11 +6,17 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links from app.api._common import owned_or_404, replace_links
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import VolunteerCreate, VolunteerRead, VolunteerUpdate from app.api.schemas import (
VolunteerCreate,
VolunteerPlanningEntry,
VolunteerRead,
VolunteerUpdate,
)
from app.auth.deps import ( from app.auth.deps import (
current_account_in_edition, current_account_in_edition,
require_coordinator, require_coordinator,
@@ -20,9 +26,11 @@ from app.db import get_session
from app.models import ( from app.models import (
Account, Account,
Artist, Artist,
Assignment,
Edition, Edition,
Position, Position,
Role, Role,
Shift,
Volunteer, Volunteer,
VolunteerArtist, VolunteerArtist,
VolunteerCompanionWish, VolunteerCompanionWish,
@@ -177,6 +185,8 @@ def create_volunteer(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> VolunteerRead: ) -> VolunteerRead:
payload.first_name = payload.first_name.title()
payload.last_name = payload.last_name.title()
volunteer = Volunteer( volunteer = Volunteer(
edition_id=edition.id, edition_id=edition.id,
**payload.model_dump( **payload.model_dump(
@@ -219,6 +229,49 @@ def get_volunteer(
return _read(session, volunteer) return _read(session, volunteer)
@router.get("/{volunteer_id}/planning", response_model=list[VolunteerPlanningEntry])
def get_volunteer_planning(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> list[VolunteerPlanningEntry]:
"""Planning enrichi (poste, lieu, référent + téléphone à contacter) d'un
bénévole — permet à l'écran bénévole (`/mobile-benevole`) d'afficher ces
infos sans jamais donner à un compte VOLUNTEER un accès en lecture aux
postes/créneaux de toute l'édition (réservé à COORDINATOR/LEAD)."""
_ensure_can_read(account, volunteer_id)
owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
Referent = aliased(Volunteer)
rows = session.exec(
select(Assignment, Shift, Position, Referent)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(
Assignment.volunteer_id == volunteer_id,
Assignment.edition_id == edition.id,
)
.order_by(Shift.starts_at)
).all()
return [
VolunteerPlanningEntry(
assignment_id=a.id,
shift_id=s.id,
starts_at=s.starts_at,
ends_at=s.ends_at,
position_name=p.name,
position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" if r else None,
referent_phone=r.phone if r else None,
)
for a, s, p, r in rows
]
@router.patch("/{volunteer_id}", response_model=VolunteerRead) @router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer( def update_volunteer(
volunteer_id: uuid.UUID, volunteer_id: uuid.UUID,
+158 -1
View File
@@ -3,7 +3,7 @@ import uuid
from sqlmodel import select from sqlmodel import select
from app.models import Assignment, Availability, Volunteer from app.models import Assignment, Availability, Role, Volunteer
from tests.factories import ( from tests.factories import (
dt_at, dt_at,
make_artist, make_artist,
@@ -232,3 +232,160 @@ def test_delete_is_a_full_cascade(client, edition, session):
from app.models import Shift from app.models import Shift
assert session.get(Shift, shift.id) is not None assert session.get(Shift, shift.id) is not None
# --------------------------------------------------------------------------- #
# Planning enrichi (poste, lieu, référent) — écran bénévole #
# --------------------------------------------------------------------------- #
def test_planning_is_empty_without_assignment(client, edition, session):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
assert resp.status_code == 200
assert resp.json() == []
def test_planning_includes_position_and_referent(client, edition, session):
from app.models import Account
volunteer = make_volunteer(session, edition, email="v@ex.fr")
referent_volunteer = make_volunteer(
session, edition, first_name="Rita", last_name="Referente", email="r@ex.fr"
)
referent_volunteer.phone = "0600000000"
session.add(referent_volunteer)
referent_account = Account(
edition_id=edition.id,
email="rita@ex.fr",
role=Role.LEAD,
volunteer_id=referent_volunteer.id,
)
session.add(referent_account)
session.commit()
session.refresh(referent_account)
position = make_position(session, edition, name="Bar central")
position.location = "Chapiteau"
position.lead_account_id = referent_account.id
session.add(position)
session.commit()
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
entry = body[0]
assert entry["shift_id"] == str(shift.id)
assert entry["position_name"] == "Bar central"
assert entry["position_location"] == "Chapiteau"
assert entry["referent_name"] == "Rita Referente"
assert entry["referent_phone"] == "0600000000"
def test_planning_referent_fields_are_none_without_referent(
client, edition, session
):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
position = make_position(session, edition)
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
entry = resp.json()[0]
assert entry["referent_name"] is None
assert entry["referent_phone"] is None
def test_planning_referent_fields_are_none_when_lead_has_no_volunteer_record(
client, edition, session
):
"""Un référent peut être un compte LEAD sans fiche bénévole associée :
pas de nom/téléphone à afficher dans ce cas."""
from app.models import Account
volunteer = make_volunteer(session, edition, email="v@ex.fr")
referent_account = Account(
edition_id=edition.id, email="lead@ex.fr", role=Role.LEAD
)
session.add(referent_account)
session.commit()
session.refresh(referent_account)
position = make_position(session, edition)
position.lead_account_id = referent_account.id
session.add(position)
session.commit()
shift = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
entry = resp.json()[0]
assert entry["referent_name"] is None
assert entry["referent_phone"] is None
def test_planning_is_ordered_by_start_time(client, edition, session):
volunteer = make_volunteer(session, edition, email="v@ex.fr")
position = make_position(session, edition)
late = make_shift(
session, edition, position, start=dt_at(11, 14), end=dt_at(11, 18)
)
early = make_shift(
session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12)
)
session.add_all(
[
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=late.id),
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=early.id),
]
)
session.commit()
resp = client.get(_url(edition, f"/{volunteer.id}/planning"))
body = resp.json()
assert [e["shift_id"] for e in body] == [str(early.id), str(late.id)]
def test_planning_is_404_for_unknown_volunteer(client, edition):
resp = client.get(_url(edition, f"/{uuid.uuid4()}/planning"))
assert resp.status_code == 404
def test_volunteer_can_read_own_planning_but_not_anothers(
session, edition, make_account, as_account
):
me = make_volunteer(session, edition, email="me@e.fr")
other = make_volunteer(session, edition, email="other@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=me.id, email="me@e.fr")
client = as_account(account)
assert client.get(_url(edition, f"/{me.id}/planning")).status_code == 200
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 403
def test_lead_can_read_any_volunteers_planning(
session, edition, make_account, as_account
):
other = make_volunteer(session, edition, email="other@e.fr")
lead_account = make_account(Role.LEAD)
client = as_account(lead_account)
assert client.get(_url(edition, f"/{other.id}/planning")).status_code == 200
+9
View File
@@ -83,6 +83,15 @@ export function useVolunteer(id: string | null | undefined) {
}); });
} }
/** Planning enrichi (poste, lieu, référent) d'un bénévole — écran bénévole. */
export function useVolunteerPlanning(volunteerId: string | null | undefined) {
return useQuery({
queryKey: ['volunteer-planning', edition, volunteerId ?? null],
queryFn: () => api.volunteers.planning(edition, volunteerId as string),
enabled: Boolean(volunteerId),
});
}
export function useShifts(positionId?: string) { export function useShifts(positionId?: string) {
return useQuery({ return useQuery({
queryKey: queryKeys.shifts(positionId), queryKey: queryKeys.shifts(positionId),
+2
View File
@@ -110,6 +110,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`),
}, },
accounts: { accounts: {
list: (editionId) => request(`${base(editionId)}/accounts`), list: (editionId) => request(`${base(editionId)}/accounts`),
+27
View File
@@ -835,6 +835,33 @@ export function createMockApi(): PlanningApi {
}); });
return delay(undefined); return delay(undefined);
}, },
planning: (_edition, id) => {
if (!state.volunteers.some((v) => v.id === id)) return notFound();
const entries = state.assignments
.filter((a) => a.volunteer_id === id)
.map((a) => {
const shift = state.shifts.find((s) => s.id === a.shift_id);
const position = shift ? state.positions.find((p) => p.id === shift.position_id) : undefined;
const referentAccount = position?.lead_account_id
? state.accounts.find((acc) => acc.id === position.lead_account_id)
: undefined;
const referent = referentAccount?.volunteer_id
? state.volunteers.find((v) => v.id === referentAccount.volunteer_id)
: undefined;
return {
assignment_id: a.id,
shift_id: a.shift_id,
starts_at: shift?.starts_at ?? '',
ends_at: shift?.ends_at ?? '',
position_name: position?.name ?? '',
position_location: position?.location ?? null,
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
referent_phone: referent?.phone ?? null,
};
})
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
return delay(entries);
},
}, },
accounts: { accounts: {
+18
View File
@@ -135,6 +135,24 @@ export interface VolunteerUpdate {
wished_companion_ids?: string[] | null; wished_companion_ids?: string[] | null;
} }
/**
* Un créneau affecté, enrichi (poste, lieu, référent à contacter) — pour
* l'écran bénévole. Évite de donner à un compte VOLUNTEER un accès en
* lecture aux postes/créneaux de toute l'édition (réservé à COORD/LEAD).
*/
export interface VolunteerPlanningEntry {
assignment_id: string;
shift_id: string;
starts_at: string;
ends_at: string;
position_name: string;
position_location: string | null;
/** null si le poste n'a pas de référent, ou si son compte n'est pas
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */
referent_name: string | null;
referent_phone: string | null;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Availability (disponibilités déclarées par le bénévole) // // Availability (disponibilités déclarées par le bénévole) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+3
View File
@@ -38,6 +38,7 @@ import type {
VerifyRequest, VerifyRequest,
Violation, Violation,
VolunteerCreate, VolunteerCreate,
VolunteerPlanningEntry,
VolunteerRead, VolunteerRead,
VolunteerUpdate, VolunteerUpdate,
} from './schema'; } from './schema';
@@ -69,6 +70,8 @@ export interface PlanningApi {
create(editionId: string, body: VolunteerCreate): Promise<VolunteerRead>; create(editionId: string, body: VolunteerCreate): Promise<VolunteerRead>;
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>; update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
}; };
accounts: { accounts: {
/** Comptes de connexion (mot de passe ou lien magique) de l'édition. */ /** Comptes de connexion (mot de passe ou lien magique) de l'édition. */
+3 -2
View File
@@ -122,10 +122,11 @@
"mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.slots": "{count} CRÉNEAUX",
"mobileVol.hours": "{count} HEURES", "mobileVol.hours": "{count} HEURES",
"mobileVol.location": "📍 {location}", "mobileVol.location": "📍 {location}",
"mobileVol.slotAssigned": "✓ TU ES ATTENDU SUR CE CRÉNEAU", "mobileVol.referent": "👤 Responsable : {name}",
"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.",
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
"mobileLead.statusTime": "18:07", "mobileLead.statusTime": "18:07",
"mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.kicker": "RÉFÉRENT DE POSTE",
"mobileLead.inProgress": "CRÉNEAU · {hours}", "mobileLead.inProgress": "CRÉNEAU · {hours}",
+11
View File
@@ -51,3 +51,14 @@ export function longDayLabel(iso: string): string {
export function hourRangeLabel(startIso: string, endIso: string): string { export function hourRangeLabel(startIso: string, endIso: string): string {
return `${timePart(startIso)} → ${timePart(endIso)}`; return `${timePart(startIso)} → ${timePart(endIso)}`;
} }
/** Instant présent, dans la même unité que `naiveMinutes` (minutes « murales »
* sans fuseau) — suppose que l'appareil est à l'heure Europe/Paris, ce qui
* est le cas sur place pendant le festival. Sert à situer « maintenant » par
* rapport aux créneaux naïfs de l'API, jamais à un vrai instant UTC. */
export function nowNaiveMinutes(): number {
const d = new Date();
return (
Date.UTC(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes()) / 60000
);
}
+82 -74
View File
@@ -1,10 +1,9 @@
import { useMemo } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { ApiError } from '@/api'; import { useVolunteer, useVolunteerPlanning } from '@/api/hooks';
import { useAssignments, usePositions, useShifts, useVolunteer } 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 { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time'; import { hourRangeLabel, longDayLabel, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
// 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.
@@ -16,24 +15,45 @@ import { t } from '@/i18n';
// de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut, // de gris clair, lisibilité plein soleil), cibles tactiles ≥ 52 px de haut,
// une seule action par créneau. // 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">
<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 meVolunteer = useVolunteer(volunteerId);
const assignments = useAssignments(volunteerId ? { volunteerId } : undefined, { const planning = useVolunteerPlanning(volunteerId);
enabled: Boolean(volunteerId), const nowMinutes = useNowMinutes();
}); // Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
const shifts = useShifts(); const slots = useMemo(
const positions = usePositions(); () =>
[...(planning.data ?? [])].sort(
const shiftsById = useMemo( (a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
() => new Map((shifts.data ?? []).map((s) => [s.id, s])), ),
[shifts.data], [planning.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
); );
if (!volunteerId) { if (!volunteerId) {
@@ -45,32 +65,23 @@ export function MobileVolunteerPage() {
</div> </div>
); );
} }
if (assignments.isLoading) return <Loading />; if (planning.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />; if (planning.error) return <ErrorNote error={planning.error} />;
const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? ''; const firstName = meVolunteer.data?.first_name ?? account?.email.split('@')[0] ?? '';
// shifts / positions peuvent être refusés (403) à un bénévole côté back-end réel :
// on affiche alors les créneaux sans détail plutôt que de tomber en erreur.
const detailsUnavailable =
(shifts.error instanceof ApiError && shifts.error.status === 403) ||
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? [])
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.sort((a, b) => {
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
const bv = b.shift ? naiveMinutes(b.shift.starts_at) : 0;
return av - bv;
});
const totalHours = const totalHours =
Math.round( Math.round(
slots.reduce( slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
(sum, s) => sum + (s.shift ? hoursBetween(s.shift.starts_at, s.shift.ends_at) : 0), 10,
0,
) * 10,
) / 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"> <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">
@@ -82,52 +93,49 @@ export function MobileVolunteerPage() {
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> <div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })} {t('mobileVol.slots', { count: slots.length })}
</div> </div>
{!detailsUnavailable ? ( <div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> {t('mobileVol.hours', { count: totalHours })}
{t('mobileVol.hours', { count: totalHours })} </div>
</div>
) : null}
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6"> <div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
{detailsUnavailable ? ( {slots.map((entry, i) => (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed"> <Fragment key={entry.assignment_id}>
{t('mobileVol.detailsUnavailable')} {i === nowInsertAt ? <NowLine /> : null}
</div> <div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
) : null} <div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
{slots.map(({ assignment, shift }) => { <span className="font-display text-[15px] font-black">
const position = shift ? positionsById.get(shift.position_id) : undefined; {longDayLabel(entry.starts_at)}
return ( </span>
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> <span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{shift ? ( {hourRangeLabel(entry.starts_at, entry.ends_at)}
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream"> </span>
<span className="font-display text-[15px] font-black"> </div>
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
) : null}
<div className="p-[11px]"> <div className="p-[11px]">
{position ? ( <div className="font-display text-[23px] font-black leading-tight tracking-tight">
<div className="font-display text-[23px] font-black leading-tight tracking-tight"> {entry.position_name.toUpperCase()}
{position.name.toUpperCase()}
</div>
) : null}
{position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
<div className="mt-[11px] flex min-h-[52px] items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-black text-white">
{t('mobileVol.slotAssigned')}
</div> </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>
</div> </div>
); </Fragment>
})} ))}
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
{slots.length === 0 ? ( {slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70"> <div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')} {t('mobileVol.noSlots')}