Écrans bénévole et référent redessinés, messages aux bénévoles
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s

Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 23:17:14 +01:00
co-authored by Claude Opus 5.5
parent 4a3f93e137
commit 16d8fb44a7
21 changed files with 1305 additions and 274 deletions
+18 -1
View File
@@ -57,7 +57,14 @@ les tables sauf les tables d'association.
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`, - **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
`retention_days` (durée de rétention des données nominatives en jours, `retention_days` (durée de rétention des données nominatives en jours,
`NULL` = pas de purge). `NULL` = pas de purge), `volunteer_message` (**message de
l'organisation**, le même pour tous les bénévoles, affiché en bas de
l'écran bénévole — saisi par la coordination, onglet « Message aux
bénévoles » de la configuration, `ConfigMessage.tsx`, via `PATCH
/editions/{id}`) et `volunteer_message_updated_at` (date affichée sous le
message). Même règle que `Position.message` : `app/api/_common.py:
set_message` — bords retirés, vide ⇒ `NULL`, date posée seulement si le
texte change, 2 000 caractères au plus.
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition - **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time` (nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`, (`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
@@ -126,6 +133,14 @@ les tables sauf les tables d'association.
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le `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 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. (`VolunteerHomeView`) plutôt qu'un écran référent vide.
- **`Position.message`** / `message_updated_at` : **message du poste**
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
coordination (`ConfigMessage.tsx`), via `PATCH .../positions/{id}` (même
contrôle d'écriture que le reste du poste : COORDINATION ou son
référent). Montré à chaque bénévole affecté au poste : repris dans
`GET .../volunteers/{vid}/planning` (`position_message`,
`position_message_updated_at`).
- **`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`. **Aucun créneau (peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
@@ -991,6 +1006,8 @@ Aucune dépendance locale requise hors Docker.
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider | | 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider | | 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider | | 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+13 -1
View File
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
def owned_or_404(instance, edition_id: uuid.UUID): def owned_or_404(instance, edition_id: uuid.UUID):
@@ -119,3 +119,15 @@ async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
status_code=422, detail={"code": "form.photo_type_not_supported"} status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc ) from exc
return content, content_type return content, content_type
def set_message(target: object, field: str, value: str | None) -> None:
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
`Position.message`) : espaces de bord retirés, vide = plus de message.
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
aux bénévoles de quand date le message."""
text = (value or "").strip() or None
if text == getattr(target, field):
return
setattr(target, field, text)
setattr(target, f"{field}_updated_at", local_now() if text else None)
+5 -1
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session from sqlmodel import Session
from app.api._common import set_message
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import EditionRead, EditionUpdate from app.api.schemas import EditionRead, EditionUpdate
from app.auth.deps import current_account_in_edition, require_coordinator from app.auth.deps import current_account_in_edition, require_coordinator
@@ -27,7 +28,10 @@ def update_edition(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Edition: ) -> Edition:
for field, value in payload.model_dump(exclude_unset=True).items(): changes = payload.model_dump(exclude_unset=True)
if "volunteer_message" in changes:
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
for field, value in changes.items():
setattr(edition, field, value) setattr(edition, field, value)
if edition.end_date < edition.start_date: if edition.end_date < edition.start_date:
raise HTTPException( raise HTTPException(
+3 -1
View File
@@ -10,7 +10,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404, set_message
from app.api._shiftgen import generate_missing_shifts from app.api._shiftgen import generate_missing_shifts
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
@@ -103,6 +103,8 @@ def update_position(
ensure_can_write_position(account, position) ensure_can_write_position(account, position)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id")) _check_lead_account(session, edition, changes.get("lead_account_id"))
if "message" in changes:
set_message(position, "message", changes.pop("message"))
for field, value in changes.items(): for field, value in changes.items():
setattr(position, field, value) setattr(position, field, value)
session.commit() session.commit()
+16
View File
@@ -35,16 +35,25 @@ class EditionRead(ApiModel):
start_date: dt.date start_date: dt.date
end_date: dt.date end_date: dt.date
retention_days: int | None retention_days: int | None
volunteer_message: str | None
volunteer_message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
# document.
MESSAGE_MAX_LENGTH = 2000
class EditionUpdate(ApiModel): class EditionUpdate(ApiModel):
name: str | None = None name: str | None = None
year: int | None = None year: int | None = None
start_date: dt.date | None = None start_date: dt.date | None = None
end_date: dt.date | None = None end_date: dt.date | None = None
retention_days: int | None = None retention_days: int | None = None
# Vide ou blanc = plus de message.
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Volunteer # # Volunteer #
@@ -141,6 +150,9 @@ class VolunteerPlanningEntry(ApiModel):
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors). # pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None referent_name: str | None
referent_phone: str | None referent_phone: str | None
# Message du poste (voir `Position.message`).
position_message: str | None
position_message_updated_at: dt.datetime | None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -187,6 +199,8 @@ class PositionUpdate(ApiModel):
location: str | None = None location: str | None = None
category: PositionCategory | None = None category: PositionCategory | None = None
lead_account_id: uuid.UUID | None = None lead_account_id: uuid.UUID | None = None
# Vide ou blanc = plus de message.
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
class PositionRead(ApiModel): class PositionRead(ApiModel):
@@ -196,6 +210,8 @@ class PositionRead(ApiModel):
location: str | None location: str | None
category: PositionCategory category: PositionCategory
lead_account_id: uuid.UUID | None lead_account_id: uuid.UUID | None
message: str | None
message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
+2
View File
@@ -317,6 +317,8 @@ def get_volunteer_planning(
position_location=p.location, position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" if r else None, referent_name=f"{r.first_name} {r.last_name}" if r else None,
referent_phone=r.phone if r else None, referent_phone=r.phone if r else None,
position_message=p.message,
position_message_updated_at=p.message_updated_at,
) )
for a, s, p, r in rows for a, s, p, r in rows
] ]
@@ -0,0 +1,37 @@
"""Messages aux bénévoles
Revision ID: 0029_volunteer_messages
Revises: 0028_badge_field_z_order
Create Date: 2026-10-28
`edition.volunteer_message` : message de l'organisation, le même pour tous
les bénévoles, saisi par la coordination. `position.message` : message du
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
Chacun avec la date de sa dernière modification (`*_updated_at`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0029_volunteer_messages"
down_revision: str | None = "0028_badge_field_z_order"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
op.add_column(
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
)
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("position", "message_updated_at")
op.drop_column("position", "message")
op.drop_column("edition", "volunteer_message_updated_at")
op.drop_column("edition", "volunteer_message")
+9
View File
@@ -92,6 +92,11 @@ class Edition(TimestampMixin, table=True):
end_date: dt.date end_date: dt.date
# Durée de rétention des données nominatives, en jours. None = pas de purge. # Durée de rétention des données nominatives, en jours. None = pas de purge.
retention_days: int | None = None retention_days: int | None = None
# Message de l'organisation, le même pour tous les bénévoles (écran
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
# sous le message, posée par l'API à chaque changement de texte.
volunteer_message: str | None = None
volunteer_message_updated_at: dt.datetime | None = None
day_parts: list["DayPart"] = Relationship( day_parts: list["DayPart"] = Relationship(
back_populates="edition", sa_relationship_kwargs=_PASSIVE back_populates="edition", sa_relationship_kwargs=_PASSIVE
@@ -326,6 +331,10 @@ class Position(TimestampMixin, table=True):
ondelete="SET NULL", ondelete="SET NULL",
nullable=True, nullable=True,
) )
# Message du poste (consignes…), saisi par son référent ou la
# coordination, montré aux bénévoles affectés au poste.
message: str | None = None
message_updated_at: dt.datetime | None = None
shifts: list["Shift"] = Relationship( shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE back_populates="position", sa_relationship_kwargs=_PASSIVE
+88
View File
@@ -0,0 +1,88 @@
"""Messages aux bénévoles : celui de l'organisation (`Edition.
volunteer_message`, coordination) et celui de chaque poste (`Position.
message`, référent du poste ou coordination)."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def test_coordination_sets_the_organisation_message(
client, edition, session, make_account, as_account
):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " Repas à 18h "})
assert resp.status_code == 200
assert resp.json()["volunteer_message"] == "Repas à 18h"
assert resp.json()["volunteer_message_updated_at"] is not None
volunteer = make_volunteer(session, edition)
reader = as_account(make_account(Role.VOLUNTEER, volunteer_id=volunteer.id))
resp = reader.get(f"/editions/{edition.id}")
assert resp.json()["volunteer_message"] == "Repas à 18h"
def test_blank_message_clears_it(client, edition):
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"})
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " "})
assert resp.json()["volunteer_message"] is None
assert resp.json()["volunteer_message_updated_at"] is None
def test_unchanged_message_keeps_its_date(client, edition):
first = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"}).json()
again = client.patch(
f"/editions/{edition.id}", json={"volunteer_message": "Bonjour", "name": "X"}
).json()
assert again["volunteer_message_updated_at"] == first["volunteer_message_updated_at"]
def test_volunteer_and_lead_cannot_set_the_organisation_message(edition, make_account, as_account):
for role in (Role.VOLUNTEER, Role.LEAD):
resp = as_account(make_account(role)).patch(
f"/editions/{edition.id}", json={"volunteer_message": "Hack"}
)
assert resp.status_code == 403
def test_message_too_long_is_rejected(client, edition):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "x" * 2001})
assert resp.status_code == 422
def test_lead_sets_the_message_of_their_position(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
position = make_position(session, edition)
position.lead_account_id = lead.id
session.add(position)
session.commit()
resp = as_account(lead).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
assert resp.status_code == 200
assert resp.json()["message"] == "Gilet fourni"
assert resp.json()["message_updated_at"] is not None
def test_lead_cannot_set_the_message_of_another_position(
edition, session, make_account, as_account
):
position = make_position(session, edition)
resp = as_account(make_account(Role.LEAD)).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Hack"}
)
assert resp.status_code == 403
def test_position_message_reaches_the_volunteer_planning(client, edition, session):
volunteer = make_volunteer(session, edition)
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()
client.patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
(entry,) = client.get(f"/editions/{edition.id}/volunteers/{volunteer.id}/planning").json()
assert entry["position_message"] == "Gilet fourni"
assert entry["position_message_updated_at"] is not None
+11
View File
@@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
return `${n(firstName)}.${n(lastName)}@mail.fr`; return `${n(firstName)}.${n(lastName)}@mail.fr`;
} }
const DEMO_POSITION_MESSAGES: Record<string, string> = {
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
};
export function buildMockState(): MockState { export function buildMockState(): MockState {
const rnd = mulberry32(20260715); const rnd = mulberry32(20260715);
const edition_id = MOCK_EDITION_ID; const edition_id = MOCK_EDITION_ID;
@@ -206,6 +211,9 @@ export function buildMockState(): MockState {
location: p.location, location: p.location,
category: p.category, category: p.category,
lead_account_id: null, lead_account_id: null,
// Quelques consignes de démo (voir `PositionRead.message`).
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
})); }));
@@ -466,6 +474,9 @@ export function buildMockState(): MockState {
start_date: DAY_DATES[0], start_date: DAY_DATES[0],
end_date: DAY_DATES[DAY_DATES.length - 1], end_date: DAY_DATES[DAY_DATES.length - 1],
retention_days: 365, retention_days: 365,
volunteer_message:
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
volunteer_message_updated_at: NOW,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}; };
+27
View File
@@ -44,6 +44,7 @@ import type {
VolunteerRead, VolunteerRead,
EditionStats, EditionStats,
} from '../schema'; } from '../schema';
import { MESSAGE_MAX_LENGTH } from '../schema';
import { getAuthToken } from '../session'; import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types'; import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset'; import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
@@ -836,6 +837,14 @@ function runSolver(): SolverRunResult {
}; };
} }
/** Message (organisation, poste) comme l'API : bords retirés, vide = plus
* de message, 2000 caractères au plus. */
function checkMessage(value: string | null | undefined): string | null {
const text = (value ?? '').trim() || null;
if (text && text.length > MESSAGE_MAX_LENGTH) throw new ApiError(422, 'error.generic');
return text;
}
export function createMockApi(): PlanningApi { export function createMockApi(): PlanningApi {
return { return {
editions: { editions: {
@@ -847,6 +856,13 @@ export function createMockApi(): PlanningApi {
if (body.start_date) next.start_date = body.start_date; if (body.start_date) next.start_date = body.start_date;
if (body.end_date) next.end_date = body.end_date; if (body.end_date) next.end_date = body.end_date;
if ('retention_days' in body) next.retention_days = body.retention_days ?? null; if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
if ('volunteer_message' in body) {
const text = checkMessage(body.volunteer_message);
if (text !== next.volunteer_message) {
next.volunteer_message = text;
next.volunteer_message_updated_at = text ? touch() : null;
}
}
if (next.end_date < next.start_date) { if (next.end_date < next.start_date) {
throw new ApiError(422, 'edition.dates_inconsistent'); throw new ApiError(422, 'edition.dates_inconsistent');
} }
@@ -1124,6 +1140,8 @@ export function createMockApi(): PlanningApi {
position_location: position?.location ?? null, position_location: position?.location ?? null,
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null, referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
referent_phone: referent?.phone ?? null, referent_phone: referent?.phone ?? null,
position_message: position?.message ?? null,
position_message_updated_at: position?.message_updated_at ?? null,
}; };
}) })
.sort((a, b) => a.starts_at.localeCompare(b.starts_at)); .sort((a, b) => a.starts_at.localeCompare(b.starts_at));
@@ -1384,6 +1402,8 @@ export function createMockApi(): PlanningApi {
location: body.location ?? null, location: body.location ?? null,
category: body.category, category: body.category,
lead_account_id: body.lead_account_id ?? null, lead_account_id: body.lead_account_id ?? null,
message: null,
message_updated_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1395,6 +1415,13 @@ export function createMockApi(): PlanningApi {
update: (_edition, id, body) => { update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound(); const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id); if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
if ('message' in body) {
const text = checkMessage(body.message);
if (text !== p.message) {
p.message = text;
p.message_updated_at = text ? touch() : null;
}
}
Object.assign(p, { Object.assign(p, {
...('name' in body ? { name: body.name } : {}), ...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}), ...('location' in body ? { location: body.location ?? null } : {}),
+18
View File
@@ -39,16 +39,25 @@ export interface EditionRead {
start_date: string; start_date: string;
end_date: string; end_date: string;
retention_days: number | null; retention_days: number | null;
/** Message de l'organisation, le même pour tous les bénévoles (écran
* bénévole), et la date de sa dernière modification. */
volunteer_message: string | null;
volunteer_message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
export const MESSAGE_MAX_LENGTH = 2000;
export interface EditionUpdate { export interface EditionUpdate {
name?: string | null; name?: string | null;
year?: number; year?: number;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
retention_days?: number | null; retention_days?: number | null;
/** Vide = plus de message. */
volunteer_message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -185,6 +194,9 @@ export interface VolunteerPlanningEntry {
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */ * rattaché à une fiche bénévole (pas de téléphone à donner alors). */
referent_name: string | null; referent_name: string | null;
referent_phone: string | null; referent_phone: string | null;
/** Message du poste (voir `PositionRead.message`). */
position_message: string | null;
position_message_updated_at: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -290,6 +302,10 @@ export interface PositionRead {
location: string | null; location: string | null;
category: PositionCategory; category: PositionCategory;
lead_account_id: string | null; lead_account_id: string | null;
/** Message du poste (consignes…), saisi par son référent, montré aux
* bénévoles affectés au poste. */
message: string | null;
message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -306,6 +322,8 @@ export interface PositionUpdate {
location?: string | null; location?: string | null;
category?: PositionCategory; category?: PositionCategory;
lead_account_id?: string | null; lead_account_id?: string | null;
/** Vide = plus de message. */
message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { ApiError } from '@/api';
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
import { Btn } from '@/components/ui';
import { cx } from '@/lib/cx';
import { messageDateLabel } from '@/lib/slotTimeline';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
/**
* Saisie d'un message montré aux bénévoles — celui de l'organisation
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
* message.
*/
export function MessageEditor({
value,
updatedAt,
placeholder,
saving,
error,
onSave,
dark = false,
}: {
value: string | null;
updatedAt: string | null;
placeholder: string;
saving: boolean;
error: unknown;
onSave: (text: string) => Promise<unknown>;
/** Sur fond sombre (en-tête de l'écran référent). */
dark?: boolean;
}) {
const [draft, setDraft] = useState(value ?? '');
const [saved, setSaved] = useState(false);
const now = useNowMinutes();
// Le message enregistré change (autre poste choisi, autre appareil) :
// le brouillon repart de lui.
useEffect(() => setDraft(value ?? ''), [value]);
const dirty = draft.trim() !== (value ?? '');
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
return (
<div className="flex flex-col gap-[7px]">
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setSaved(false);
}}
rows={4}
placeholder={placeholder}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
/>
<div className="flex flex-wrap items-center gap-2">
<Btn
disabled={!dirty || tooLong || saving}
onClick={() => {
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
}}
>
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
</Btn>
<span
className={cx(
'font-mono text-[10px] leading-snug',
tooLong ? 'font-bold text-orange' : 'opacity-65',
)}
>
{saved && !dirty
? t('message.saved')
: value && updatedAt && !dirty
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{error ? (
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
{error instanceof ApiError ? t(error.code) : t('error.generic')}
</div>
) : null}
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
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>
);
}
+60 -11
View File
@@ -5,7 +5,6 @@
"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",
"app.error.reference": "Référence à communiquer : {id}", "app.error.reference": "Référence à communiquer : {id}",
"error.generic": "Erreur inattendue", "error.generic": "Erreur inattendue",
@@ -120,20 +119,63 @@
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", "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.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Oublié = aucune affectation.", "volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.greeting": "Salut {name}",
"mobileVol.greeting": "SALUT {name}", "mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
"mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
"mobileVol.hours": "{count} HEURES", "mobileVol.summary.none": "Pas encore de créneau.",
"mobileVol.location": "📍 {location}", "mobileVol.featured.now": "● EN COURS",
"mobileVol.referent": "👤 Responsable : {name}", "mobileVol.featured.next": "PROCHAIN CRÉNEAU",
"mobileVol.phone": "📞 {phone}", "mobileVol.featured.overlap": "⚠ Tu es aussi sur {position} de {start} à {end}. Vérifie avec la coordination.",
"mobileVol.featured.referent": "TON RÉFÉRENT",
"mobileVol.featured.call": "APPELER",
"mobileVol.allDone": "Festival terminé. Merci {name}, tu as assuré !",
"mobileVol.day.one": "{count} créneau · {duration}",
"mobileVol.day.other": "{count} créneaux · {duration}",
"mobileVol.slot.done": "TERMINÉ",
"mobileVol.slot.now": "EN COURS",
"mobileVol.slot.referent": " · réf. {name}",
"mobileVol.slot.overlap": "⚠ Chevauche {position} de {start} à {end}. Vérifie avec la coordination où tu dois être.",
"slot.duration.hours": "{h} h",
"slot.duration.hoursMinutes": "{h} h {m}",
"slot.countdown.endsAt": "FIN À {time}",
"slot.countdown.minutes": "DANS {count} MIN",
"slot.countdown.in": "DANS {duration}",
"slot.countdown.days": "DANS {count} J",
"mobileVol.orgMessage.title": "MESSAGE DE L'ORGANISATION",
"mobileVol.orgMessage.signature": "Coordination · {when}",
"message.save": "Enregistrer",
"message.remove": "Retirer le message",
"message.saving": "Enregistrement…",
"message.saved": "✓ Enregistré",
"message.updatedAt": "Publié {when}",
"message.length": "{count} / {max} caractères",
"message.date.today": "aujourd'hui {time}",
"message.date.yesterday": "hier {time}",
"message.date.other": "{day} · {time}",
"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": "Ton équipe",
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT", "mobileLead.kickerNamed": "{name} · ton équipe",
"mobileLead.status.done": "terminé",
"mobileLead.status.now": "en cours",
"mobileLead.status.next": "à venir",
"mobileLead.count.present": "PRÉSENTS",
"mobileLead.count.waiting": "ATTENDUS",
"mobileLead.count.absent": "ABSENTS",
"mobileLead.absentAlert.one": "⚠ 1 absent sur le créneau — il manque du monde",
"mobileLead.absentAlert.other": "⚠ {count} absents sur le créneau — il manque du monde",
"mobileLead.roll": "POINTAGE · {count} BÉNÉVOLE(S)",
"mobileLead.when.now": "Créneau en cours",
"mobileLead.when.next": "Début {time}",
"mobileLead.when.done": "Créneau terminé",
"mobileLead.call": "APPEL",
"mobileLead.callLabel": "Appeler {name}",
"mobileLead.message.title": "MESSAGE À TON ÉQUIPE",
"mobileLead.message.audience": "{count} bénévole(s) le verront",
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.", "mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.", "mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT", "mobileLead.markPresent": "PRÉSENT",
@@ -143,6 +185,13 @@
"config.reserved": "RÉSERVÉ À LA COORDINATION", "config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.grid": "POSTES & CRÉNEAUX",
"config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.tab.volunteers": "BÉNÉVOLES & IMPORT",
"config.tab.message": "MESSAGE AUX BÉNÉVOLES",
"config.message.orgTitle": "Message de l'organisation",
"config.message.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
"config.message.orgPlaceholder": "Ex. Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00.",
"config.message.positionsTitle": "Messages des postes",
"config.message.positionsHelp": "Saisis par chaque référent depuis son écran et montrés aux bénévoles du poste. Vous pouvez aussi les modifier ici.",
"config.message.none": "Pas de message",
"config.tab.mail": "MESSAGES", "config.tab.mail": "MESSAGES",
"config.tab.rules": "RÈGLES & ÉDITION", "config.tab.rules": "RÈGLES & ÉDITION",
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
+115
View File
@@ -0,0 +1,115 @@
// Aides des écrans bénévole et référent (`VolunteerHomeView`,
// `MobileLeadPage`) : statut d'un créneau par rapport à maintenant, durées,
// compte à rebours et frise horaire d'une journée. Tout en minutes
// « murales » (`naiveMinutes` / `nowNaiveMinutes`), jamais en `Date` locale.
import { t } from '@/i18n';
import { longDayLabel, naiveMinutes } from '@/lib/time';
export type SlotStatus = 'done' | 'now' | 'next';
export function slotStatus(start: number, end: number, now: number): SlotStatus {
if (now >= end) return 'done';
return now >= start ? 'now' : 'next';
}
/** `HH:MM` d'un instant en minutes naïves (passe minuit sans broncher). */
export function clockLabel(minutes: number): string {
const m = ((minutes % 1440) + 1440) % 1440;
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
}
/** « 9 h », « 2 h 30 ». */
export function durationLabel(minutes: number): string {
const total = Math.round(minutes);
const h = Math.floor(total / 60);
const m = total % 60;
return m ? t('slot.duration.hoursMinutes', { h, m: String(m).padStart(2, '0') }) : t('slot.duration.hours', { h });
}
/** Compte à rebours du créneau mis en avant : « FIN À 03:00 » s'il est en
* cours, sinon « DANS 20 MIN », « DANS 2 h 30 » ou « DANS 3 J ». */
export function countdownLabel(start: number, end: number, now: number): string {
if (now >= start) return t('slot.countdown.endsAt', { time: clockLabel(end) });
const diff = start - now;
if (diff < 60) return t('slot.countdown.minutes', { count: Math.max(1, Math.round(diff)) });
if (diff < 24 * 60) return t('slot.countdown.in', { duration: durationLabel(diff) });
return t('slot.countdown.days', { count: Math.round(diff / (24 * 60)) });
}
export interface Interval {
start: number;
end: number;
}
/** Plage commune à deux créneaux qui se chevauchent (`null` sinon) —
* jointifs (fin = début) : pas de chevauchement, comme `SHIFT_OVERLAP`. */
export function overlapOf(a: Interval, b: Interval): Interval | null {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
return start < end ? { start, end } : null;
}
export interface RibbonBar {
/** Position et largeur en % de la frise. */
left: number;
width: number;
/** Ligne de la frise : deux créneaux qui se chevauchent ne se masquent
* pas, le second descend d'une ligne. */
lane: number;
}
export interface Ribbon {
bars: RibbonBar[];
lanes: number;
/** Repères horaires, en % de la frise. */
ticks: Array<{ left: number; label: string }>;
/** Nombre d'heures couvertes (une graduation par heure). */
hours: number;
/** Position de maintenant en %, `null` hors de la frise. */
now: number | null;
}
const MIN_RIBBON_HOURS = 4;
const MAX_TICKS = 4;
/** Frise d'une journée : de l'heure pleine qui précède le premier créneau à
* celle qui suit le dernier (4 h au moins), `slots` triés par début. */
export function buildRibbon(slots: Interval[], now: number): Ribbon {
const first = Math.floor(Math.min(...slots.map((s) => s.start)) / 60) * 60;
const last = Math.ceil(Math.max(...slots.map((s) => s.end)) / 60) * 60;
const hours = Math.max(MIN_RIBBON_HOURS, (last - first) / 60);
const span = hours * 60;
const pct = (minutes: number) => ((minutes - first) / span) * 100;
const laneEnds: number[] = [];
const bars = slots.map((s) => {
let lane = 0;
while (laneEnds[lane] !== undefined && laneEnds[lane] > s.start) lane++;
laneEnds[lane] = s.end;
return { left: pct(s.start), width: ((s.end - s.start) / span) * 100, lane };
});
const step = Math.ceil(hours / (MAX_TICKS - 1));
const ticks = [];
for (let h = 0; h <= hours; h += step) ticks.push({ left: (h / hours) * 100, label: clockLabel(first + h * 60) });
return {
bars,
lanes: Math.max(1, laneEnds.length),
ticks,
hours,
now: now >= first && now <= first + span ? pct(now) : null,
};
}
/** Date d'un message (organisation, poste) : « AUJOURD'HUI 18:42 »,
* « HIER 18:42 », sinon « Samedi 31 · 18:42 ». */
export function messageDateLabel(iso: string, now: number): string {
const at = naiveMinutes(iso);
const days = Math.floor(now / 1440) - Math.floor(at / 1440);
const time = clockLabel(at);
if (days === 0) return t('message.date.today', { time });
if (days === 1) return t('message.date.yesterday', { time });
return t('message.date.other', { day: longDayLabel(iso), time });
}
+4 -1
View File
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
import { ConfigArtists } from '@/pages/config/ConfigArtists'; import { ConfigArtists } from '@/pages/config/ConfigArtists';
import { ConfigGrid } from '@/pages/config/ConfigGrid'; import { ConfigGrid } from '@/pages/config/ConfigGrid';
import { ConfigMail } from '@/pages/config/ConfigMail'; import { ConfigMail } from '@/pages/config/ConfigMail';
import { ConfigMessage } from '@/pages/config/ConfigMessage';
import { ConfigRules } from '@/pages/config/ConfigRules'; import { ConfigRules } from '@/pages/config/ConfigRules';
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers'; import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
import { t } from '@/i18n'; import { t } from '@/i18n';
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules'; type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'message' | 'mail' | 'rules';
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [ const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
{ id: 'grid', labelKey: 'config.tab.grid' }, { id: 'grid', labelKey: 'config.tab.grid' },
{ id: 'volunteers', labelKey: 'config.tab.volunteers' }, { id: 'volunteers', labelKey: 'config.tab.volunteers' },
{ id: 'artists', labelKey: 'config.tab.artists' }, { id: 'artists', labelKey: 'config.tab.artists' },
{ id: 'message', labelKey: 'config.tab.message' },
{ id: 'mail', labelKey: 'config.tab.mail' }, { id: 'mail', labelKey: 'config.tab.mail' },
{ id: 'rules', labelKey: 'config.tab.rules' }, { id: 'rules', labelKey: 'config.tab.rules' },
]; ];
@@ -38,6 +40,7 @@ export function ConfigPage() {
{tab === 'grid' ? <ConfigGrid /> : null} {tab === 'grid' ? <ConfigGrid /> : null}
{tab === 'volunteers' ? <ConfigVolunteers /> : null} {tab === 'volunteers' ? <ConfigVolunteers /> : null}
{tab === 'artists' ? <ConfigArtists /> : null} {tab === 'artists' ? <ConfigArtists /> : null}
{tab === 'message' ? <ConfigMessage /> : null}
{tab === 'mail' ? <ConfigMail /> : null} {tab === 'mail' ? <ConfigMail /> : null}
{tab === 'rules' ? <ConfigRules /> : null} {tab === 'rules' ? <ConfigRules /> : null}
</div> </div>
+264 -113
View File
@@ -1,60 +1,74 @@
import { Fragment, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ShiftRead } from '@/api/schema'; import type { PositionRead, ShiftRead } from '@/api/schema';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import {
useAssignments,
useDayParts,
usePositions,
useShifts,
useUpdatePosition,
useVolunteer,
useVolunteers,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { VolunteerAvatar } from '@/components/VolunteerAvatar';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { clockLabel, slotStatus, type SlotStatus } from '@/lib/slotTimeline';
import { 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 { VolunteerHomeView } from '@/pages/VolunteerHomeView'; 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`), d'après la maquette `design/Planning bénévole
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être // mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa // prochain par défaut), les compteurs présents / attendus / absents, puis
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec // l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier // plus large, la même colonne, centrée. Un compte peut être référent de
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait // plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à // par des onglets. Aucun repli vers « le premier poste de l'édition » si le
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu // compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un // pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
// écran référent vide (`VolunteerHomeView`). // courant : « Donner un accès de connexion » promu en référent), il voit
// alors sa vue de bénévole classique (`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'>;
const STATUS_KEY: Record<SlotStatus, string> = {
done: 'mobileLead.status.done',
now: 'mobileLead.status.now',
next: 'mobileLead.status.next',
};
export function MobileLeadPage() { export function MobileLeadPage() {
const { account } = useAuth(); const { account } = useAuth();
const positions = usePositions(); const positions = usePositions();
const shifts = useShifts(); const shifts = useShifts();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments(); const assignments = useAssignments();
const nowMinutes = useNowMinutes(); const dayParts = useDayParts();
const me = useVolunteer(account?.volunteer_id);
const updatePosition = useUpdatePosition();
const now = useNowMinutes();
const [presence, setPresence] = useState<Presence>({}); const [presence, setPresence] = useState<Presence>({});
const [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
const myPositions = (positions.data ?? []).filter( const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
(p) => account && p.lead_account_id === account.id,
);
const shiftsByPosition = useMemo(() => { const shiftsByPosition = useMemo(() => {
const map = new Map<string, ShiftRead[]>(); const map = new Map<string, ShiftRead[]>();
for (const s of shifts.data ?? []) { for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
const list = map.get(s.position_id) ?? [];
list.push(s);
map.set(s.position_id, list);
}
for (const list of map.values()) { for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at)); list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
} }
return map; return map;
}, [shifts.data]); }, [shifts.data]);
const volunteersById = useMemo( const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])), () => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data], [volunteers.data],
@@ -64,6 +78,10 @@ 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} />;
const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
if (myPositions.length === 0) { if (myPositions.length === 0) {
if (account?.volunteer_id) { if (account?.volunteer_id) {
return ( return (
@@ -82,149 +100,282 @@ export function MobileLeadPage() {
); );
} }
return ( // Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> const nextEnd = (p: PositionRead) =>
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7"> (shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div> const position =
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]"> myPositions.find((p) => p.id === positionId) ??
{t('mobileLead.positionsCount', { count: myPositions.length })} [...myPositions].sort((a, b) => {
</div> const na = nextEnd(a);
</div> const nb = nextEnd(b);
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6"> return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
{myPositions.map((position) => { })[0];
const positionShifts = shiftsByPosition.get(position.id) ?? []; const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes); // Par défaut : le créneau en cours ou le prochain, sinon le dernier.
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex; const shift =
positionShifts.find((s) => s.id === shiftId) ??
nextEnd(position) ??
positionShifts[positionShifts.length - 1] ??
null;
return ( const team = shift
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> ? (assignments.data ?? [])
<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>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">
{position.location}
</div>
) : null}
</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>
) : null}
{positionShifts.map((shift, i) => {
const team = (assignments.data ?? [])
.filter((a) => a.shift_id === shift.id) .filter((a) => a.shift_id === shift.id)
.map((a) => { .map((a) => {
const volunteer = volunteersById.get(a.volunteer_id); const volunteer = volunteersById.get(a.volunteer_id);
return { return {
id: a.id, id: a.id,
volunteerId: a.volunteer_id, volunteerId: a.volunteer_id,
name: volunteer name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
? `${volunteer.first_name} ${volunteer.last_name}` initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
: a.volunteer_id,
initials: volunteer
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
: '?',
hasPhoto: volunteer?.has_photo ?? false, hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '', phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present', state: presence[a.id] ?? null,
absent: presence[a.id] === 'absent',
}; };
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
: [];
const presentCount = team.filter((m) => m.state === 'present').length;
const absentCount = team.filter((m) => m.state === 'absent').length;
// Un second appui sur le même bouton annule le pointage.
const mark = (assignmentId: string, value: 'present' | 'absent') =>
setPresence((p) => {
const next = { ...p };
if (next[assignmentId] === value) delete next[assignmentId];
else next[assignmentId] = value;
return next;
}); });
const presentCount = team.filter((m) => m.present).length;
// Bénévoles distincts affectés à au moins un créneau du poste.
const positionShiftIds = new Set(positionShifts.map((s) => s.id));
const teamSize = new Set(
(assignments.data ?? []).filter((a) => positionShiftIds.has(a.shift_id)).map((a) => a.volunteer_id),
).size;
const shiftStatus = shift
? slotStatus(naiveMinutes(shift.starts_at), naiveMinutes(shift.ends_at), now)
: null;
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
return ( return (
<Fragment key={shift.id}> <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
{i === nowInsertAt ? <NowLine /> : null} <div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
<div className="border-2 border-ink"> {myPositions.length > 1 ? (
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]"> <div className="mb-3 flex flex-wrap gap-[6px]">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> {myPositions.map((p) => (
{longDayLabel(shift.starts_at)} ·{' '} <button
{hourRangeLabel(shift.starts_at, shift.ends_at)} key={p.id}
type="button"
onClick={() => {
setPositionId(p.id);
setShiftId(null);
}}
className={cx(
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
)}
>
{p.name}
</button>
))}
</div> </div>
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> ) : null}
{t('mobileLead.present', { present: presentCount, total: team.length })} <div className="flex items-center gap-3">
{me.data ? (
<VolunteerAvatar
volunteerId={me.data.id}
hasPhoto={me.data.has_photo}
initials={`${me.data.first_name[0]}${me.data.last_name[0]}`}
className="h-[60px] w-[60px] !border-blue text-[19px]"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
</div>
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
{position.location}
</div>
) : null}
</div> </div>
</div> </div>
<div className="flex flex-col gap-[11px] p-[11px]"> {positionShifts.length ? (
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
{positionShifts.map((s) => {
const on = s.id === shift?.id;
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
return (
<button
key={s.id}
type="button"
onClick={() => setShiftId(s.id)}
aria-pressed={on}
className={cx(
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
)}
>
<span className="block font-display text-[12px] font-black leading-tight">
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
</span>
<span className="block font-mono text-[11px] font-semibold leading-snug">
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
{t(STATUS_KEY[status])}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
)}
</div>
{shift ? (
<>
<div className="grid grid-cols-3 border-b-[3px] border-ink">
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
<Counter
value={team.length - presentCount - absentCount}
label={t('mobileLead.count.waiting')}
className="border-r-2 bg-paper"
/>
<Counter
value={absentCount}
label={t('mobileLead.count.absent')}
className={absentCount ? 'bg-orange' : 'bg-paper'}
/>
</div>
{absentCount ? (
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
count: absentCount,
})}
</div>
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.roll', { count: team.length })}
</span>
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
{shiftStatus === 'now'
? t('mobileLead.when.now')
: shiftStatus === 'next'
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
: t('mobileLead.when.done')}
</span>
</div>
{team.map((member) => ( {team.map((member) => (
<div <div
key={member.id} key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm" className={cx(
'border-[3px] border-ink shadow-brutal-md',
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
)}
> >
<div className="flex items-center gap-[10px] px-[11px] py-[10px]"> <div className="flex items-center gap-[10px] px-[10px] py-[9px]">
<VolunteerAvatar <VolunteerAvatar
volunteerId={member.volunteerId} volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto} hasPhoto={member.hasPhoto}
initials={member.initials} initials={member.initials}
className="h-[38px] w-[38px] text-[13px]" className="h-12 w-12 !border-2 text-[15px]"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight"> <span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name} {member.name}
</span> </span>
<span className="block font-mono text-[10.5px] opacity-65"> {member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone} {member.phone}
</span> </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} ) : 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> </span>
{member.phone ? (
<a
href={`tel:${member.phone.replace(/\s+/g, '')}`}
aria-label={t('mobileLead.callLabel', { name: member.name })}
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
>
{t('mobileLead.call')}
</a>
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 border-t-[3px] border-ink"> <div className="grid grid-cols-2 border-t-[3px] border-ink">
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member.id, 'present')}
setPresence((p) => ({ ...p, [member.id]: 'present' })) aria-pressed={member.state === 'present'}
}
className={cx( 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', 'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
)} )}
> >
{t('mobileLead.markPresent')} {t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member.id, 'absent')}
setPresence((p) => ({ ...p, [member.id]: 'absent' })) aria-pressed={member.state === 'absent'}
} className={cx(
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange" 'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
> >
{t('mobileLead.markAbsent')} {t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button> </button>
</div> </div>
</div> </div>
))} ))}
{team.length === 0 ? ( {team.length === 0 ? (
<div className="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('mobileLead.emptyTeam')} {t('mobileLead.emptyTeam')}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </>
</Fragment>
);
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null} ) : null}
{/* Message du poste : montré à chaque bénévole affecté, sur son
écran (créneau mis en avant et liste des créneaux). */}
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.message.title')}
</span>
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
{t('mobileLead.message.audience', { count: teamSize })}
</span>
</div> </div>
</div> <p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
); {t('mobileLead.message.help')}
})} </p>
<MessageEditor
key={position.id}
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={updatePosition.isPending}
error={updatePosition.error}
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div> </div>
</div> </div>
); );
} }
function Counter({ value, label, className }: { value: number; label: string; className: string }) {
return (
<div className={cx('border-ink px-3 py-[10px]', className)}>
<div className="font-mono text-[24px] font-bold leading-none">{value}</div>
<div className="mt-[3px] font-display text-[9.5px] font-extrabold leading-tight tracking-wider">{label}</div>
</div>
);
}
+372 -73
View File
@@ -1,20 +1,35 @@
import { Fragment, useMemo } from 'react'; import { useMemo } from 'react';
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks'; import { useDayParts, useEdition, useVolunteer, useVolunteerPlanning } from '@/api/hooks';
import type { VolunteerPlanningEntry } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { VolunteerAvatar } from '@/components/VolunteerAvatar';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import {
buildRibbon,
clockLabel,
countdownLabel,
durationLabel,
messageDateLabel,
overlapOf,
slotStatus,
type SlotStatus,
} from '@/lib/slotTimeline';
import { longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes'; import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
/** /**
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu, * Contenu de l'écran bénévole (maquette `design/Planning bénévole
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de * mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT * à rebours, lieu, référent à appeler), puis le planning par journée de
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce * festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent * avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
* vide (voir `MobileLeadPage.tsx`). * chevauchements signalés). Pensé pour le téléphone ; sur un écran plus
* large, la même colonne, centrée. 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 (voir `MobileLeadPage.tsx`).
*/ */
export function VolunteerHomeView({ export function VolunteerHomeView({
volunteerId, volunteerId,
@@ -26,13 +41,20 @@ export function VolunteerHomeView({
}) { }) {
const meVolunteer = useVolunteer(volunteerId); const meVolunteer = useVolunteer(volunteerId);
const planning = useVolunteerPlanning(volunteerId); const planning = useVolunteerPlanning(volunteerId);
const nowMinutes = useNowMinutes(); const dayParts = useDayParts();
const edition = useEdition();
const now = useNowMinutes();
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous. // Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
const slots = useMemo( const slots = useMemo(
() => () =>
[...(planning.data ?? [])].sort( (planning.data ?? [])
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at), .map((entry) => ({
), entry,
start: naiveMinutes(entry.starts_at),
end: naiveMinutes(entry.ends_at),
}))
.sort((a, b) => a.start - b.start),
[planning.data], [planning.data],
); );
@@ -44,88 +66,365 @@ export function VolunteerHomeView({
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}` ? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
: (firstName[0] ?? '?'); : (firstName[0] ?? '?');
const totalHours = const days = new Map<string, Slot[]>();
Math.round( for (const slot of slots) {
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) * const day = festivalDay(slot.entry.starts_at, boundary);
10, days.set(day, [...(days.get(day) ?? []), slot]);
) / 10; }
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
// Le repère « maintenant » se place juste avant le premier créneau pas const featured = slots.find((s) => now < s.end) ?? null;
// 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="flex items-center gap-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
<div className="flex items-center gap-[12px]">
<VolunteerAvatar <VolunteerAvatar
volunteerId={volunteerId} volunteerId={volunteerId}
hasPhoto={meVolunteer.data?.has_photo ?? false} hasPhoto={meVolunteer.data?.has_photo ?? false}
initials={initials} initials={initials}
className="h-[52px] w-[52px] text-[18px]" className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
/> />
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div> <div className="font-display text-[30px] font-black leading-none tracking-tight">
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]"> {t('mobileVol.greeting', { name: firstName })}
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div> </div>
</div> <div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
</div> {slots.length
<div className="mt-3 flex flex-wrap gap-2"> ? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> count: slots.length,
{t('mobileVol.slots', { count: slots.length })} duration: durationLabel(totalMinutes),
</div> days: days.size,
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> })
{t('mobileVol.hours', { count: totalHours })} : t('mobileVol.summary.none')}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6"> {slots.length ? (
{slots.map((entry, i) => ( <div className="px-[14px] pt-4 sm:px-6">
<Fragment key={entry.assignment_id}> {featured ? (
{i === nowInsertAt ? <NowLine /> : null} <FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
<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"> <div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
<span className="font-display text-[15px] font-black"> {t('mobileVol.allDone', { name: firstName })}
{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>
<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> </div>
) : null} ) : null}
{entry.referent_name ? (
<div className="mt-[9px] font-mono text-[12px]"> <div className="flex flex-1 flex-col gap-[22px] px-[14px] pb-7 pt-[22px] sm:px-6">
{t('mobileVol.referent', { name: entry.referent_name })} {[...days.entries()].map(([day, daySlots]) => (
</div> <DaySection
) : null} key={day}
{entry.referent_phone ? ( day={day}
<div className="mt-[3px] font-mono text-[12px]"> slots={daySlots}
{t('mobileVol.phone', { phone: entry.referent_phone })} all={slots}
</div> now={now}
) : null} featuredId={featured?.entry.assignment_id ?? null}
</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')}
</div> </div>
) : null} ) : null}
{edition.data?.volunteer_message ? (
<div className="border-[3px] border-ink bg-blue p-3">
<div className="font-display text-[12px] font-black leading-none tracking-wider">
{t('mobileVol.orgMessage.title')}
</div>
<div className="mt-[7px] whitespace-pre-line break-words font-display text-[14px] font-semibold leading-normal [text-wrap:pretty]">
{edition.data.volunteer_message}
</div>
{edition.data.volunteer_message_updated_at ? (
<div className="mt-2 font-mono text-[10.5px] font-medium uppercase leading-none">
{t('mobileVol.orgMessage.signature', {
when: messageDateLabel(edition.data.volunteer_message_updated_at, now),
})}
</div>
) : null}
</div>
) : null}
</div> </div>
</div> </div>
); );
} }
interface Slot {
entry: VolunteerPlanningEntry;
start: number;
end: number;
}
/** Premier autre créneau du bénévole qui chevauche `slot`, et sur quelle
* plage. */
function findOverlap(slot: Slot, all: Slot[]) {
for (const other of all) {
if (other.entry.assignment_id === slot.entry.assignment_id) continue;
const range = overlapOf(slot, other);
if (range) return { other, range };
}
return null;
}
function FeaturedSlot({
slot,
all,
now,
boundary,
}: {
slot: Slot;
all: Slot[];
now: number;
boundary: string;
}) {
const running = slotStatus(slot.start, slot.end, now) === 'now';
const overlap = findOverlap(slot, all.filter((s) => now < s.end));
const { entry } = slot;
return (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className={cx(
'flex items-center gap-2 border-b-[3px] border-ink px-3 py-[9px]',
running ? 'bg-orange' : 'bg-yellow',
)}
>
<span className="font-display text-[12px] font-black tracking-wider">
{t(running ? 'mobileVol.featured.now' : 'mobileVol.featured.next')}
</span>
<span className="ml-auto font-mono text-[12px] font-bold">
{countdownLabel(slot.start, slot.end, now)}
</span>
</div>
<div className="px-[13px] pb-[14px] pt-[13px]">
<div className="font-display text-[13px] font-bold opacity-70">
{longDayLabel(`${festivalDay(entry.starts_at, boundary)}T12:00`)}
</div>
<div className="mt-[5px] break-words font-display text-[30px] font-black leading-[1.05] tracking-tight">
{entry.position_name.toUpperCase()}
</div>
<div className="mt-2 font-mono text-[24px] font-bold leading-none">
{clockLabel(slot.start)} → {clockLabel(slot.end)}
</div>
{entry.position_location || entry.position_message ? (
<div className="mt-[11px] flex flex-col gap-1 border-t-2 border-ink pt-[10px]">
{entry.position_location ? (
<div className="font-display text-[14px] font-bold leading-snug">{entry.position_location}</div>
) : null}
{entry.position_message ? <PositionMessage entry={entry} /> : null}
</div>
) : null}
{overlap ? (
<div className="mt-[11px] border-2 border-ink bg-yellow-soft px-[10px] py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileVol.featured.overlap', {
position: overlap.other.entry.position_name,
start: clockLabel(overlap.range.start),
end: clockLabel(overlap.range.end),
})}
</div>
) : null}
{entry.referent_name ? <ReferentContact entry={entry} /> : null}
</div>
</div>
);
}
/** Message du poste (consignes du référent). */
function PositionMessage({ entry }: { entry: VolunteerPlanningEntry }) {
return (
<div className="whitespace-pre-line break-words font-mono text-[12px] font-medium leading-snug opacity-75">
{entry.position_message}
</div>
);
}
function ReferentContact({ entry }: { entry: VolunteerPlanningEntry }) {
const content = (
<>
<span className="min-w-0 flex-1">
<span className="block font-mono text-[10.5px] font-medium leading-snug opacity-70">
{t('mobileVol.featured.referent')}
</span>
<span className="block font-display text-[15px] font-extrabold leading-tight">
{entry.referent_name}
</span>
</span>
{entry.referent_phone ? (
<span className="flex-none bg-ink px-3 py-[10px] font-display text-[13px] font-black leading-none text-yellow">
{t('mobileVol.featured.call')}
</span>
) : null}
</>
);
const className =
'mt-3 flex min-h-[58px] items-center gap-[10px] border-[3px] border-ink bg-cream px-3 py-[6px]';
return entry.referent_phone ? (
<a href={`tel:${entry.referent_phone.replace(/\s+/g, '')}`} className={className}>
{content}
</a>
) : (
<div className={className}>{content}</div>
);
}
const BAR_COLOR: Record<SlotStatus, string> = {
done: 'bg-sand-mid',
now: 'bg-orange',
next: 'bg-ink',
};
const LANE_PX = 18;
function DaySection({
day,
slots,
all,
now,
featuredId,
}: {
day: string;
slots: Slot[];
all: Slot[];
now: number;
/** Créneau déjà mis en avant plus haut (son message y figure déjà). */
featuredId: string | null;
}) {
const ribbon = buildRibbon(slots, now);
const minutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
return (
<div>
<div className="flex items-baseline gap-2 border-b-[3px] border-ink pb-[6px]">
<span className="font-display text-[18px] font-black leading-none tracking-tight">
{longDayLabel(`${day}T12:00`)}
</span>
<span className="ml-auto font-mono text-[12px] font-semibold leading-none">
{t(slots.length > 1 ? 'mobileVol.day.other' : 'mobileVol.day.one', {
count: slots.length,
duration: durationLabel(minutes),
})}
</span>
</div>
<div
className="relative mb-1 mt-[10px]"
aria-hidden="true"
style={{
height: ribbon.lanes * LANE_PX,
// Une graduation par heure.
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 calc(100% / ${ribbon.hours} - 1px), #CFC7B4 calc(100% / ${ribbon.hours} - 1px) calc(100% / ${ribbon.hours}))`,
}}
>
{ribbon.bars.map((bar, i) => (
<span
key={slots[i].entry.assignment_id}
className={cx('absolute h-[14px] border-2 border-ink', BAR_COLOR[slotStatus(slots[i].start, slots[i].end, now)])}
style={{ left: `${bar.left}%`, width: `${bar.width}%`, top: bar.lane * LANE_PX }}
/>
))}
{ribbon.now !== null ? (
<span className="absolute -bottom-1 -top-1 w-[3px] bg-orange" style={{ left: `${ribbon.now}%` }} />
) : null}
</div>
<div className="relative mb-[10px] h-[10px] font-mono text-[9.5px] font-medium leading-none opacity-65" aria-hidden="true">
{ribbon.ticks.map((tick, i) => (
<span
key={tick.label + i}
className="absolute"
style={{
left: `${tick.left}%`,
transform: tick.left === 0 ? undefined : tick.left >= 100 ? 'translateX(-100%)' : 'translateX(-50%)',
}}
>
{tick.label}
</span>
))}
</div>
<div className="flex flex-col gap-[9px]">
{slots.map((slot) => (
<SlotCard
key={slot.entry.assignment_id}
slot={slot}
all={all}
now={now}
showMessage={slot.entry.assignment_id !== featuredId}
/>
))}
</div>
</div>
);
}
function SlotCard({
slot,
all,
now,
showMessage,
}: {
slot: Slot;
all: Slot[];
now: number;
showMessage: boolean;
}) {
const status = slotStatus(slot.start, slot.end, now);
const overlap = status === 'done' ? null : findOverlap(slot, all);
const { entry } = slot;
return (
<div
className={cx(
'border-[3px] border-ink',
status === 'now' ? 'bg-orange-pale' : 'bg-paper',
status === 'done' && 'opacity-55',
)}
>
<div className="flex items-stretch">
<div
className={cx(
'flex w-[76px] flex-none flex-col items-center justify-center gap-[5px] border-r-[3px] border-ink py-[10px] font-mono text-[16px] font-bold leading-none',
status === 'now' ? 'bg-orange text-ink' : status === 'done' ? 'bg-sand-dark text-ink' : 'bg-ink text-yellow',
)}
>
<span>{clockLabel(slot.start)}</span>
<span className="h-[9px] w-[2px] bg-current opacity-60" aria-hidden="true" />
<span>{clockLabel(slot.end)}</span>
</div>
<div className="min-w-0 flex-1 px-[11px] py-[10px]">
<div className="flex items-start gap-[6px]">
<span className="min-w-0 flex-1 break-words font-display text-[18px] font-black leading-tight">
{entry.position_name}
</span>
{status === 'done' ? (
<span className="flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
{t('mobileVol.slot.done')}
</span>
) : null}
{status === 'now' ? (
<span className="flex-none border-2 border-ink bg-orange px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
{t('mobileVol.slot.now')}
</span>
) : null}
</div>
{entry.position_location ? (
<div className="mt-1 font-display text-[13px] font-semibold leading-snug">{entry.position_location}</div>
) : null}
<div className="mt-[2px] font-mono text-[11px] font-medium leading-snug opacity-70">
{durationLabel(slot.end - slot.start)}
{entry.referent_name ? t('mobileVol.slot.referent', { name: entry.referent_name }) : null}
</div>
{showMessage && status !== 'done' && entry.position_message ? (
<div className="mt-[6px] border-l-[3px] border-ink pl-2">
<PositionMessage entry={entry} />
</div>
) : null}
</div>
</div>
{overlap ? (
<div className="border-t-[3px] border-ink bg-yellow-soft px-[11px] py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileVol.slot.overlap', {
position: overlap.other.entry.position_name,
start: clockLabel(overlap.range.start),
end: clockLabel(overlap.range.end),
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useEdition, usePositions, useUpdateEdition, useUpdatePosition } from '@/api/hooks';
import type { PositionRead } from '@/api/schema';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state';
import { Panel, PanelHeader } from '@/components/ui';
import { cx } from '@/lib/cx';
import { t } from '@/i18n';
// Messages montrés sur l'écran bénévole : celui de l'organisation, le même
// pour tous (`Edition.volunteer_message`), et, pour suivi, ceux des postes
// (`Position.message`) — saisis d'ordinaire par chaque référent depuis son
// écran, mais la coordination peut aussi les reprendre ici.
export function ConfigMessage() {
const edition = useEdition();
const positions = usePositions();
const updateEdition = useUpdateEdition();
if (edition.isLoading || positions.isLoading) return <Loading />;
const error = edition.error ?? positions.error;
if (error) return <ErrorNote error={error} />;
return (
<div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(360px,100%),1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.message.orgTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('config.message.orgHelp')}</p>
<MessageEditor
value={edition.data?.volunteer_message ?? null}
updatedAt={edition.data?.volunteer_message_updated_at ?? null}
placeholder={t('config.message.orgPlaceholder')}
saving={updateEdition.isPending}
error={updateEdition.error}
onSave={(text) => updateEdition.mutateAsync({ volunteer_message: text })}
/>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.message.positionsTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.message.positionsHelp')}
</p>
<div className="flex flex-col border-2 border-ink">
{(positions.data ?? []).map((position) => (
<PositionMessageRow key={position.id} position={position} />
))}
</div>
</div>
</Panel>
</div>
);
}
function PositionMessageRow({ position }: { position: PositionRead }) {
const [open, setOpen] = useState(false);
const update = useUpdatePosition();
return (
<div className="border-b-2 border-ink last:border-b-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className={cx(
'flex w-full cursor-pointer items-baseline gap-2 px-[10px] py-2 text-left hover:bg-yellow-soft',
open && 'bg-sand',
)}
>
<span className="min-w-0 flex-1">
<span className="block font-display text-[12.5px] font-extrabold">{position.name}</span>
<span className="block truncate font-mono text-[10px] opacity-65">
{position.message ?? t('config.message.none')}
</span>
</span>
<span aria-hidden className="font-mono text-[10px]">
{open ? '▲' : '▼'}
</span>
</button>
{open ? (
<div className="border-t-2 border-ink bg-paper p-[10px]">
<MessageEditor
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={update.isPending}
error={update.error}
onSave={(text) => update.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div>
) : null}
</div>
);
}
+5
View File
@@ -12,12 +12,17 @@ export default {
sand: '#EFE8D6', // barres secondaires sand: '#EFE8D6', // barres secondaires
'sand-line': '#E0D8C4', // séparateurs sur paper 'sand-line': '#E0D8C4', // séparateurs sur paper
'sand-dark': '#E7E0CE', 'sand-dark': '#E7E0CE',
'sand-mid': '#CFC7B4', // frise horaire : graduations, créneau terminé
yellow: '#FFD23F', yellow: '#FFD23F',
'yellow-soft': '#FFE68A', // état « incomplet » 'yellow-soft': '#FFE68A', // état « incomplet »
'yellow-hover': '#FFDF77', 'yellow-hover': '#FFDF77',
orange: '#FF5C39', orange: '#FF5C39',
'orange-dark': '#E0421F', 'orange-dark': '#E0421F',
green: '#0E9F6E', green: '#0E9F6E',
mint: '#C8F0DC', // pointé présent (compteur)
'mint-pale': '#EAF7EF', // carte d'un bénévole pointé présent
'orange-pale': '#FFF1EC', // créneau en cours
'orange-blush': '#FFE3DA', // carte d'un bénévole pointé absent
blue: '#A8C4FF', blue: '#A8C4FF',
'blue-strong': '#2A6BFF', // « non publié » 'blue-strong': '#2A6BFF', // « non publié »
mauve: '#CBA6E3', // état « sureffectif » mauve: '#CBA6E3', // état « sureffectif »