É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
+11
View File
@@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
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 {
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,
};
+27
View File
@@ -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 } : {}),
+18
View File
@@ -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;
}
// --------------------------------------------------------------------------- //
+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.add": "Ajouter",
"app.loading": "Chargement…",
"common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue",
"app.error.reference": "Référence à communiquer : {id}",
"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.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
"mobileVol.greeting": "SALUT {name}",
"mobileVol.slots": "{count} CRÉNEAUX",
"mobileVol.hours": "{count} HEURES",
"mobileVol.location": "📍 {location}",
"mobileVol.referent": "👤 Responsable : {name}",
"mobileVol.phone": "📞 {phone}",
"mobileVol.greeting": "Salut {name}",
"mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
"mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
"mobileVol.summary.none": "Pas encore de créneau.",
"mobileVol.featured.now": "● EN COURS",
"mobileVol.featured.next": "PROCHAIN CRÉNEAU",
"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.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileLead.kicker": "RÉFÉRENT DE POSTE",
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT",
"mobileLead.kicker": "Ton équipe",
"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.noShift": "Aucun créneau sur ce poste.",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT",
@@ -143,6 +185,13 @@
"config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "POSTES & CRÉNEAUX",
"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.rules": "RÈGLES & ÉDITION",
"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 { ConfigGrid } from '@/pages/config/ConfigGrid';
import { ConfigMail } from '@/pages/config/ConfigMail';
import { ConfigMessage } from '@/pages/config/ConfigMessage';
import { ConfigRules } from '@/pages/config/ConfigRules';
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
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 }> = [
{ id: 'grid', labelKey: 'config.tab.grid' },
{ id: 'volunteers', labelKey: 'config.tab.volunteers' },
{ id: 'artists', labelKey: 'config.tab.artists' },
{ id: 'message', labelKey: 'config.tab.message' },
{ id: 'mail', labelKey: 'config.tab.mail' },
{ id: 'rules', labelKey: 'config.tab.rules' },
];
@@ -38,6 +40,7 @@ export function ConfigPage() {
{tab === 'grid' ? <ConfigGrid /> : null}
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
{tab === 'artists' ? <ConfigArtists /> : null}
{tab === 'message' ? <ConfigMessage /> : null}
{tab === 'mail' ? <ConfigMail /> : null}
{tab === 'rules' ? <ConfigRules /> : null}
</div>
+313 -162
View File
@@ -1,60 +1,74 @@
import { Fragment, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import type { ShiftRead } from '@/api/schema';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { PositionRead, ShiftRead } from '@/api/schema';
import {
useAssignments,
useDayParts,
usePositions,
useShifts,
useUpdatePosition,
useVolunteer,
useVolunteers,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
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 { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un
// écran référent vide (`VolunteerHomeView`).
// `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
// mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
// prochain par défaut), les compteurs présents / attendus / absents, puis
// l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
// plus large, la même colonne, centrée. Un compte peut être référent de
// plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
// par des onglets. Aucun repli vers « le premier poste de l'édition » si le
// compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
// pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
// courant : « Donner un accès de connexion » promu en référent), il voit
// alors sa vue de bénévole classique (`VolunteerHomeView`).
//
// Le pointage présent/absent reste local à l'écran (état React, jamais
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
type Presence = Record<string, 'present' | 'absent'>;
const STATUS_KEY: Record<SlotStatus, string> = {
done: 'mobileLead.status.done',
now: 'mobileLead.status.now',
next: 'mobileLead.status.next',
};
export function MobileLeadPage() {
const { account } = useAuth();
const positions = usePositions();
const shifts = useShifts();
const volunteers = useVolunteers();
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 [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsByPosition = useMemo(() => {
const map = new Map<string, ShiftRead[]>();
for (const s of shifts.data ?? []) {
const list = map.get(s.position_id) ?? [];
list.push(s);
map.set(s.position_id, list);
}
for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
}
return map;
}, [shifts.data]);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
@@ -64,6 +78,10 @@ export function MobileLeadPage() {
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.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 (account?.volunteer_id) {
return (
@@ -82,149 +100,282 @@ export function MobileLeadPage() {
);
}
// Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
const nextEnd = (p: PositionRead) =>
(shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
const position =
myPositions.find((p) => p.id === positionId) ??
[...myPositions].sort((a, b) => {
const na = nextEnd(a);
const nb = nextEnd(b);
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
})[0];
const positionShifts = shiftsByPosition.get(position.id) ?? [];
// Par défaut : le créneau en cours ou le prochain, sinon le dernier.
const shift =
positionShifts.find((s) => s.id === shiftId) ??
nextEnd(position) ??
positionShifts[positionShifts.length - 1] ??
null;
const team = shift
? (assignments.data ?? [])
.filter((a) => a.shift_id === shift.id)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
volunteerId: a.volunteer_id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '',
state: presence[a.id] ?? null,
};
})
.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;
});
// 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 (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
{t('mobileLead.positionsCount', { count: myPositions.length })}
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
{myPositions.length > 1 ? (
<div className="mb-3 flex flex-wrap gap-[6px]">
{myPositions.map((p) => (
<button
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>
) : null}
<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>
{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>
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6">
{myPositions.map((position) => {
const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex;
{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>
return (
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<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)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
volunteerId: a.volunteer_id,
name: volunteer
? `${volunteer.first_name} ${volunteer.last_name}`
: a.volunteer_id,
initials: volunteer
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
: '?',
hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
return (
<Fragment key={shift.id}>
{i === nowInsertAt ? <NowLine /> : null}
<div className="border-2 border-ink">
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{longDayLabel(shift.starts_at)} ·{' '}
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
</div>
</div>
<div className="flex flex-col gap-[11px] p-[11px]">
{team.map((member) => (
<div
key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm"
>
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<VolunteerAvatar
volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto}
initials={member.initials}
className="h-[38px] w-[38px] text-[13px]"
/>
<span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight">
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">
{member.phone}
</span>
</span>
{member.present ? (
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
{t('mobileLead.badgePresent')}
</span>
) : null}
{member.absent ? (
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
{t('mobileLead.badgeAbsent')}
</span>
) : null}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() =>
setPresence((p) => ({ ...p, [member.id]: 'present' }))
}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white',
)}
>
{t('mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() =>
setPresence((p) => ({ ...p, [member.id]: 'absent' }))
}
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
</button>
</div>
</div>
))}
{team.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</div>
</Fragment>
);
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null}
</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) => (
<div
key={member.id}
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-[10px] py-[9px]">
<VolunteerAvatar
volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto}
initials={member.initials}
className="h-12 w-12 !border-2 text-[15px]"
/>
<span className="min-w-0 flex-1">
<span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name}
</span>
{member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone}
</span>
) : null}
</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}
</div>
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => mark(member.id, 'present')}
aria-pressed={member.state === 'present'}
className={cx(
'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(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button>
<button
type="button"
onClick={() => mark(member.id, 'absent')}
aria-pressed={member.state === 'absent'}
className={cx(
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
>
{t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button>
</div>
</div>
))}
{team.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</>
) : 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>
<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>
);
}
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>
);
}
+379 -80
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 { NowLine } from '@/components/NowLine';
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 { t } from '@/i18n';
/**
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu,
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent
* vide (voir `MobileLeadPage.tsx`).
* Contenu de l'écran bénévole (maquette `design/Planning bénévole
* mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
* à rebours, lieu, référent à appeler), puis le planning par journée de
* festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
* avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
* 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({
volunteerId,
@@ -26,13 +41,20 @@ export function VolunteerHomeView({
}) {
const meVolunteer = useVolunteer(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.
const slots = useMemo(
() =>
[...(planning.data ?? [])].sort(
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at),
),
(planning.data ?? [])
.map((entry) => ({
entry,
start: naiveMinutes(entry.starts_at),
end: naiveMinutes(entry.ends_at),
}))
.sort((a, b) => a.start - b.start),
[planning.data],
);
@@ -44,88 +66,365 @@ export function VolunteerHomeView({
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}`
: (firstName[0] ?? '?');
const totalHours =
Math.round(
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) *
10,
) / 10;
// Le repère « maintenant » se place juste avant le premier créneau pas
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
// passés.
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
const days = new Map<string, Slot[]>();
for (const slot of slots) {
const day = festivalDay(slot.entry.starts_at, boundary);
days.set(day, [...(days.get(day) ?? []), slot]);
}
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
const featured = slots.find((s) => now < s.end) ?? null;
return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7">
<div className="flex items-center gap-[12px]">
<VolunteerAvatar
volunteerId={volunteerId}
hasPhoto={meVolunteer.data?.has_photo ?? false}
initials={initials}
className="h-[52px] w-[52px] text-[18px]"
/>
<div className="min-w-0">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]">
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div>
<div className="flex items-center gap-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
<VolunteerAvatar
volunteerId={volunteerId}
hasPhoto={meVolunteer.data?.has_photo ?? false}
initials={initials}
className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
/>
<div className="min-w-0 flex-1">
<div className="font-display text-[30px] font-black leading-none tracking-tight">
{t('mobileVol.greeting', { name: firstName })}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileVol.slots', { count: slots.length })}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileVol.hours', { count: totalHours })}
<div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
{slots.length
? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
count: slots.length,
duration: durationLabel(totalMinutes),
days: days.size,
})
: t('mobileVol.summary.none')}
</div>
</div>
</div>
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6">
{slots.map((entry, i) => (
<Fragment key={entry.assignment_id}>
{i === nowInsertAt ? <NowLine /> : null}
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(entry.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(entry.starts_at, entry.ends_at)}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{entry.position_name.toUpperCase()}
</div>
{entry.position_location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: entry.position_location })}
</div>
) : null}
{entry.referent_name ? (
<div className="mt-[9px] font-mono text-[12px]">
{t('mobileVol.referent', { name: entry.referent_name })}
</div>
) : null}
{entry.referent_phone ? (
<div className="mt-[3px] font-mono text-[12px]">
{t('mobileVol.phone', { phone: entry.referent_phone })}
</div>
) : null}
</div>
{slots.length ? (
<div className="px-[14px] pt-4 sm:px-6">
{featured ? (
<FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
) : (
<div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
{t('mobileVol.allDone', { name: firstName })}
</div>
</Fragment>
)}
</div>
) : null}
<div className="flex flex-1 flex-col gap-[22px] px-[14px] pb-7 pt-[22px] sm:px-6">
{[...days.entries()].map(([day, daySlots]) => (
<DaySection
key={day}
day={day}
slots={daySlots}
all={slots}
now={now}
featuredId={featured?.entry.assignment_id ?? null}
/>
))}
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
{slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')}
</div>
) : null}
{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>
);
}
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-line': '#E0D8C4', // séparateurs sur paper
'sand-dark': '#E7E0CE',
'sand-mid': '#CFC7B4', // frise horaire : graduations, créneau terminé
yellow: '#FFD23F',
'yellow-soft': '#FFE68A', // état « incomplet »
'yellow-hover': '#FFDF77',
orange: '#FF5C39',
'orange-dark': '#E0421F',
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-strong': '#2A6BFF', // « non publié »
mauve: '#CBA6E3', // état « sureffectif »