From 16d8fb44a7e15f270ed76e75700eede4e822c1ca Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 23:17:14 +0100 Subject: [PATCH] =?UTF-8?q?=C3=89crans=20b=C3=A9n=C3=A9vole=20et=20r=C3=A9?= =?UTF-8?q?f=C3=A9rent=20redessin=C3=A9s,=20messages=20aux=20b=C3=A9n?= =?UTF-8?q?=C3=A9voles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- CLAUDE.md | 19 +- backend/app/api/_common.py | 14 +- backend/app/api/editions.py | 6 +- backend/app/api/positions.py | 4 +- backend/app/api/schemas.py | 16 + backend/app/api/volunteers.py | 2 + .../versions/0029_volunteer_messages.py | 37 ++ backend/app/models/domain.py | 9 + backend/tests/test_api_messages.py | 88 ++++ frontend/src/api/mock/dataset.ts | 11 + frontend/src/api/mock/mock-client.ts | 27 + frontend/src/api/schema.ts | 18 + frontend/src/components/MessageEditor.tsx | 86 ++++ frontend/src/components/NowLine.tsx | 16 - frontend/src/i18n/locales/fr.json | 71 ++- frontend/src/lib/slotTimeline.ts | 115 +++++ frontend/src/pages/ConfigPage.tsx | 5 +- frontend/src/pages/MobileLeadPage.tsx | 475 ++++++++++++------ frontend/src/pages/VolunteerHomeView.tsx | 459 ++++++++++++++--- frontend/src/pages/config/ConfigMessage.tsx | 96 ++++ frontend/tailwind.config.js | 5 + 21 files changed, 1305 insertions(+), 274 deletions(-) create mode 100644 backend/app/migrations/versions/0029_volunteer_messages.py create mode 100644 backend/tests/test_api_messages.py create mode 100644 frontend/src/components/MessageEditor.tsx delete mode 100644 frontend/src/components/NowLine.tsx create mode 100644 frontend/src/lib/slotTimeline.ts create mode 100644 frontend/src/pages/config/ConfigMessage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a347e63..c91beb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,14 @@ les tables sauf les tables d'association. - **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`, `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 (nombre et bornes libres). `code`, `label`, `start_time`, `end_time` (`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 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. +- **`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 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 @@ -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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/_common.py b/backend/app/api/_common.py index 3bb4d17..0c977af 100644 --- a/backend/app/api/_common.py +++ b/backend/app/api/_common.py @@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError from sqlalchemy import delete 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): @@ -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"} ) from exc 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) diff --git a/backend/app/api/editions.py b/backend/app/api/editions.py index 9ae8953..cfdf105 100644 --- a/backend/app/api/editions.py +++ b/backend/app/api/editions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session +from app.api._common import set_message from app.api.deps import get_edition from app.api.schemas import EditionRead, EditionUpdate from app.auth.deps import current_account_in_edition, require_coordinator @@ -27,7 +28,10 @@ def update_edition( _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> 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) if edition.end_date < edition.start_date: raise HTTPException( diff --git a/backend/app/api/positions.py b/backend/app/api/positions.py index f483472..540b366 100644 --- a/backend/app/api/positions.py +++ b/backend/app/api/positions.py @@ -10,7 +10,7 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, Response, status 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.deps import get_edition from app.api.schemas import ( @@ -103,6 +103,8 @@ def update_position( ensure_can_write_position(account, position) changes = payload.model_dump(exclude_unset=True) _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(): setattr(position, field, value) session.commit() diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 9da2cdb..1b6c68d 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -35,16 +35,25 @@ class EditionRead(ApiModel): start_date: dt.date end_date: dt.date retention_days: int | None + volunteer_message: str | None + volunteer_message_updated_at: dt.datetime | None created_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): name: str | None = None year: int | None = None start_date: dt.date | None = None end_date: dt.date | 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 # @@ -141,6 +150,9 @@ class VolunteerPlanningEntry(ApiModel): # pas rattaché à une fiche bénévole (pas de téléphone à donner alors). referent_name: 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 category: PositionCategory | 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): @@ -196,6 +210,8 @@ class PositionRead(ApiModel): location: str | None category: PositionCategory lead_account_id: uuid.UUID | None + message: str | None + message_updated_at: dt.datetime | None created_at: dt.datetime updated_at: dt.datetime diff --git a/backend/app/api/volunteers.py b/backend/app/api/volunteers.py index defb447..26ac717 100644 --- a/backend/app/api/volunteers.py +++ b/backend/app/api/volunteers.py @@ -317,6 +317,8 @@ def get_volunteer_planning( 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, + position_message=p.message, + position_message_updated_at=p.message_updated_at, ) for a, s, p, r in rows ] diff --git a/backend/app/migrations/versions/0029_volunteer_messages.py b/backend/app/migrations/versions/0029_volunteer_messages.py new file mode 100644 index 0000000..2188414 --- /dev/null +++ b/backend/app/migrations/versions/0029_volunteer_messages.py @@ -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") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 2077333..1603369 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -92,6 +92,11 @@ class Edition(TimestampMixin, table=True): end_date: dt.date # Durée de rétention des données nominatives, en jours. None = pas de purge. 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( back_populates="edition", sa_relationship_kwargs=_PASSIVE @@ -326,6 +331,10 @@ class Position(TimestampMixin, table=True): ondelete="SET NULL", 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( back_populates="position", sa_relationship_kwargs=_PASSIVE diff --git a/backend/tests/test_api_messages.py b/backend/tests/test_api_messages.py new file mode 100644 index 0000000..ff2368c --- /dev/null +++ b/backend/tests/test_api_messages.py @@ -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 diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 71be10a..70acf07 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string { return `${n(firstName)}.${n(lastName)}@mail.fr`; } +const DEMO_POSITION_MESSAGES: Record = { + '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 { const rnd = mulberry32(20260715); const edition_id = MOCK_EDITION_ID; @@ -206,6 +211,9 @@ export function buildMockState(): MockState { location: p.location, category: p.category, 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, updated_at: NOW, })); @@ -466,6 +474,9 @@ export function buildMockState(): MockState { start_date: DAY_DATES[0], end_date: DAY_DATES[DAY_DATES.length - 1], 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, updated_at: NOW, }; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index f93ef32..0d0188a 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -44,6 +44,7 @@ import type { VolunteerRead, EditionStats, } from '../schema'; +import { MESSAGE_MAX_LENGTH } from '../schema'; import { getAuthToken } from '../session'; import { ApiError, type PlanningApi } from '../types'; 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 { return { editions: { @@ -847,6 +856,13 @@ export function createMockApi(): PlanningApi { if (body.start_date) next.start_date = body.start_date; if (body.end_date) next.end_date = body.end_date; 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) { throw new ApiError(422, 'edition.dates_inconsistent'); } @@ -1124,6 +1140,8 @@ export function createMockApi(): PlanningApi { position_location: position?.location ?? null, referent_name: referent ? `${referent.first_name} ${referent.last_name}` : 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)); @@ -1384,6 +1402,8 @@ export function createMockApi(): PlanningApi { location: body.location ?? null, category: body.category, lead_account_id: body.lead_account_id ?? null, + message: null, + message_updated_at: null, created_at: now, updated_at: now, }; @@ -1395,6 +1415,13 @@ export function createMockApi(): PlanningApi { update: (_edition, id, body) => { const p = state.positions.find((x) => x.id === id) ?? notFound(); 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, { ...('name' in body ? { name: body.name } : {}), ...('location' in body ? { location: body.location ?? null } : {}), diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 9060e36..b44a004 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -39,16 +39,25 @@ export interface EditionRead { start_date: string; end_date: string; 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; updated_at: string; } +/** Longueur maximale d'un message (organisation, poste) — comme l'API. */ +export const MESSAGE_MAX_LENGTH = 2000; + export interface EditionUpdate { name?: string | null; year?: number; start_date?: string; end_date?: string; 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). */ referent_name: 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; category: PositionCategory; 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; updated_at: string; } @@ -306,6 +322,8 @@ export interface PositionUpdate { location?: string | null; category?: PositionCategory; lead_account_id?: string | null; + /** Vide = plus de message. */ + message?: string | null; } // --------------------------------------------------------------------------- // diff --git a/frontend/src/components/MessageEditor.tsx b/frontend/src/components/MessageEditor.tsx new file mode 100644 index 0000000..f063cc0 --- /dev/null +++ b/frontend/src/components/MessageEditor.tsx @@ -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; + /** 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 ( +
+