Écrans bénévole et référent redessinés, messages aux bénévoles
É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:
co-authored by
Claude Opus 5.5
parent
4a3f93e137
commit
16d8fb44a7
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user