Référent aussi bénévole : onglets Mon équipe / Mon planning
Un référent rattaché à une fiche bénévole voit aussi ses propres créneaux (vue bénévole) dans un second onglet de son écran, ouvert d'office quand il tient un créneau de bénévole hors de ses postes ; « TOI » dans l'équipe pointée. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
16d8fb44a7
commit
a37f6c2c41
@@ -132,7 +132,13 @@ les tables sauf les tables d'association.
|
|||||||
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
|
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
|
||||||
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
|
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
|
||||||
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
|
compte est rattaché à une fiche bénévole, sa vue de bénévole classique
|
||||||
(`VolunteerHomeView`) plutôt qu'un écran référent vide.
|
(`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
|
||||||
|
aussi être affecté comme bénévole (même soirée comprise) : si son compte
|
||||||
|
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
|
||||||
|
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
|
||||||
|
planning » quand il tient en ce moment un créneau de bénévole hors de ses
|
||||||
|
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
|
||||||
|
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
|
||||||
- **`Position.message`** / `message_updated_at` : **message du poste**
|
- **`Position.message`** / `message_updated_at` : **message du poste**
|
||||||
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
|
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
|
||||||
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
|
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
|
||||||
@@ -1008,6 +1014,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
|
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
|
||||||
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
|
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
|
||||||
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
|
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
|
||||||
|
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -174,6 +174,9 @@
|
|||||||
"mobileLead.message.audience": "{count} bénévole(s) le verront",
|
"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.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.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
|
||||||
|
"mobileLead.view.team": "Mon équipe",
|
||||||
|
"mobileLead.view.planning": "Mon planning · {count}",
|
||||||
|
"mobileLead.you": "TOI",
|
||||||
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
|
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
|
||||||
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
"mobileLead.noShift": "Aucun créneau sur ce poste.",
|
||||||
"mobileLead.badgePresent": "✓ PRÉSENT",
|
"mobileLead.badgePresent": "✓ PRÉSENT",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useShifts,
|
useShifts,
|
||||||
useUpdatePosition,
|
useUpdatePosition,
|
||||||
useVolunteer,
|
useVolunteer,
|
||||||
|
useVolunteerPlanning,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
@@ -35,10 +36,18 @@ import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
|
|||||||
// courant : « Donner un accès de connexion » promu en référent), il voit
|
// courant : « Donner un accès de connexion » promu en référent), il voit
|
||||||
// alors sa vue de bénévole classique (`VolunteerHomeView`).
|
// alors sa vue de bénévole classique (`VolunteerHomeView`).
|
||||||
//
|
//
|
||||||
|
// Un référent peut aussi être affecté comme bénévole, y compris la même
|
||||||
|
// soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
|
||||||
|
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
|
||||||
|
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
|
||||||
|
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
|
||||||
|
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
|
||||||
|
//
|
||||||
// Le pointage présent/absent reste local à l'écran (état React, jamais
|
// Le pointage présent/absent reste local à l'écran (état React, jamais
|
||||||
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
||||||
|
|
||||||
type Presence = Record<string, 'present' | 'absent'>;
|
type Presence = Record<string, 'present' | 'absent'>;
|
||||||
|
type View = 'team' | 'planning';
|
||||||
|
|
||||||
const STATUS_KEY: Record<SlotStatus, string> = {
|
const STATUS_KEY: Record<SlotStatus, string> = {
|
||||||
done: 'mobileLead.status.done',
|
done: 'mobileLead.status.done',
|
||||||
@@ -54,6 +63,8 @@ export function MobileLeadPage() {
|
|||||||
const assignments = useAssignments();
|
const assignments = useAssignments();
|
||||||
const dayParts = useDayParts();
|
const dayParts = useDayParts();
|
||||||
const me = useVolunteer(account?.volunteer_id);
|
const me = useVolunteer(account?.volunteer_id);
|
||||||
|
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
|
||||||
|
const [view, setView] = useState<View | null>(null);
|
||||||
const updatePosition = useUpdatePosition();
|
const updatePosition = useUpdatePosition();
|
||||||
const now = useNowMinutes();
|
const now = useNowMinutes();
|
||||||
const [presence, setPresence] = useState<Presence>({});
|
const [presence, setPresence] = useState<Presence>({});
|
||||||
@@ -158,214 +169,251 @@ export function MobileLeadPage() {
|
|||||||
: null;
|
: null;
|
||||||
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
|
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
|
||||||
|
|
||||||
|
const inProgress = (start: string, end: string) =>
|
||||||
|
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
|
||||||
|
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
|
||||||
|
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
|
||||||
|
const myEntries = myPlanning.data ?? [];
|
||||||
|
const volunteeringNow = myEntries.some(
|
||||||
|
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
|
||||||
|
);
|
||||||
|
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
|
||||||
|
const tabs = account?.volunteer_id ? (
|
||||||
|
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
|
||||||
|
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
|
||||||
|
{t('mobileLead.view.team')}
|
||||||
|
</ViewTab>
|
||||||
|
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
|
||||||
|
{t('mobileLead.view.planning', { count: myEntries.length })}
|
||||||
|
</ViewTab>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (currentView === 'planning' && account?.volunteer_id) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
{tabs}
|
||||||
|
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
|
{tabs}
|
||||||
{myPositions.length > 1 ? (
|
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
|
||||||
<div className="mb-3 flex flex-wrap gap-[6px]">
|
<div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
|
||||||
{myPositions.map((p) => (
|
{myPositions.length > 1 ? (
|
||||||
<button
|
<div className="mb-3 flex flex-wrap gap-[6px]">
|
||||||
key={p.id}
|
{myPositions.map((p) => (
|
||||||
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
|
<button
|
||||||
key={s.id}
|
key={p.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShiftId(s.id)}
|
onClick={() => {
|
||||||
aria-pressed={on}
|
setPositionId(p.id);
|
||||||
|
setShiftId(null);
|
||||||
|
}}
|
||||||
className={cx(
|
className={cx(
|
||||||
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
|
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
|
||||||
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
|
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block font-display text-[12px] font-black leading-tight">
|
{p.name}
|
||||||
{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>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shift ? (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-3 border-b-[3px] border-ink">
|
|
||||||
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
|
|
||||||
<Counter
|
|
||||||
value={team.length - presentCount - absentCount}
|
|
||||||
label={t('mobileLead.count.waiting')}
|
|
||||||
className="border-r-2 bg-paper"
|
|
||||||
/>
|
|
||||||
<Counter
|
|
||||||
value={absentCount}
|
|
||||||
label={t('mobileLead.count.absent')}
|
|
||||||
className={absentCount ? 'bg-orange' : 'bg-paper'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{absentCount ? (
|
|
||||||
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
|
|
||||||
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
|
|
||||||
count: absentCount,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
|
{me.data ? (
|
||||||
<div className="flex items-baseline gap-2">
|
<VolunteerAvatar
|
||||||
<span className="font-display text-[12px] font-black tracking-wider">
|
volunteerId={me.data.id}
|
||||||
{t('mobileLead.roll', { count: team.length })}
|
hasPhoto={me.data.has_photo}
|
||||||
</span>
|
initials={`${me.data.first_name[0]}${me.data.last_name[0]}`}
|
||||||
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
|
className="h-[60px] w-[60px] !border-blue text-[19px]"
|
||||||
{shiftStatus === 'now'
|
/>
|
||||||
? t('mobileLead.when.now')
|
) : null}
|
||||||
: shiftStatus === 'next'
|
<div className="min-w-0 flex-1">
|
||||||
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
|
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
|
||||||
: t('mobileLead.when.done')}
|
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
|
||||||
</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>
|
</div>
|
||||||
))}
|
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
|
||||||
{team.length === 0 ? (
|
{position.name.toUpperCase()}
|
||||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
</div>
|
||||||
{t('mobileLead.emptyTeam')}
|
{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>
|
||||||
|
|
||||||
|
{shift ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 border-b-[3px] border-ink">
|
||||||
|
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
|
||||||
|
<Counter
|
||||||
|
value={team.length - presentCount - absentCount}
|
||||||
|
label={t('mobileLead.count.waiting')}
|
||||||
|
className="border-r-2 bg-paper"
|
||||||
|
/>
|
||||||
|
<Counter
|
||||||
|
value={absentCount}
|
||||||
|
label={t('mobileLead.count.absent')}
|
||||||
|
className={absentCount ? 'bg-orange' : 'bg-paper'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{absentCount ? (
|
||||||
|
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
|
||||||
|
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
|
||||||
|
count: absentCount,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Message du poste : montré à chaque bénévole affecté, sur son
|
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
|
||||||
écran (créneau mis en avant et liste des créneaux). */}
|
<div className="flex items-baseline gap-2">
|
||||||
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
|
<span className="font-display text-[12px] font-black tracking-wider">
|
||||||
<div className="flex items-baseline gap-2">
|
{t('mobileLead.roll', { count: team.length })}
|
||||||
<span className="font-display text-[12px] font-black tracking-wider">
|
</span>
|
||||||
{t('mobileLead.message.title')}
|
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
|
||||||
</span>
|
{shiftStatus === 'now'
|
||||||
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
|
? t('mobileLead.when.now')
|
||||||
{t('mobileLead.message.audience', { count: teamSize })}
|
: shiftStatus === 'next'
|
||||||
</span>
|
? 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}
|
||||||
|
{member.volunteerId === account?.volunteer_id ? (
|
||||||
|
<span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
|
||||||
|
{t('mobileLead.you')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</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>
|
||||||
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
|
|
||||||
{t('mobileLead.message.help')}
|
|
||||||
</p>
|
|
||||||
<MessageEditor
|
|
||||||
key={position.id}
|
|
||||||
value={position.message}
|
|
||||||
updatedAt={position.message_updated_at}
|
|
||||||
placeholder={t('mobileLead.message.placeholder')}
|
|
||||||
saving={updatePosition.isPending}
|
|
||||||
error={updatePosition.error}
|
|
||||||
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -379,3 +427,31 @@ function Counter({ value, label, className }: { value: number; label: string; cl
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ViewTab({
|
||||||
|
active,
|
||||||
|
live,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
/** Un créneau de cette vue est en cours : pastille orange. */
|
||||||
|
live: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cx(
|
||||||
|
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
|
||||||
|
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user