Pointage enregistré et statistiques de présence
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.

Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 08:06:32 +01:00
co-authored by Claude Opus 5.5
parent a37f6c2c41
commit c57c70569c
19 changed files with 600 additions and 22 deletions
+24 -4
View File
@@ -170,6 +170,17 @@ les tables sauf les tables d'association.
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin.
**Pointage** : `attendance` (`AttendanceStatus` `PRESENT` / `ABSENT`,
`NULL` = pas pointé) et `attendance_marked_at` (heure du dernier
pointage), posés par `PUT .../assignments/{id}/attendance`
(`{attendance}`, `null` annule — même contrôle d'écriture que
l'affectation : COORDINATION ou référent du poste). Écran référent : un
second appui sur le même bouton annule, mise à jour immédiate à l'écran
puis enregistrée (`hooks.ts:useSetAttendance`, retour arrière et message
si l'enregistrement échoue). Ce n'est pas le statut d'une affectation
(toujours valide dès sa création), juste la présence constatée sur
place. Avant l'étape 58, ce pointage n'était gardé que dans le
navigateur et se perdait au rechargement.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
@@ -286,7 +297,13 @@ les tables sauf les tables d'association.
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
de démo (`registeredAt`).
de démo (`registeredAt`). **Présence** (`attendance`,
`_attendance` / mock `buildAttendance`) : présents, absents et non
pointés (`Assignment.attendance`) par poste, sur les créneaux terminés
et sur ceux en cours à `as_of` (créneaux à venir ignorés), avec le taux
de présence parmi les bénévoles pointés ; la section passe en tête de
l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit
toutes les 30 s (`useStats`, `refetchInterval`).
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
@@ -802,10 +819,11 @@ adapté à son tableau bénévole × créneau.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en
SMS · fonctions d'IA. *(L'envoi d'emails par SMTP est en
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)*
l'étape 21. Le pointage présent / absent par le référent est en place depuis
l'étape 58 — `Assignment.attendance`, § 2.)*
---
@@ -1015,7 +1033,9 @@ Aucune dépendance locale requise hors Docker.
| 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 |
| 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 |
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
**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, IA. Frontend : squelette + 6 écrans +
connexion en place (voir `frontend/`).
+26 -1
View File
@@ -17,6 +17,7 @@ from app.api.schemas import (
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
AttendanceUpdate,
)
from app.auth.deps import (
current_account_in_edition,
@@ -24,7 +25,7 @@ from app.auth.deps import (
require_staff,
)
from app.db import get_session
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
from app.validation import has_blocking, validate
from app.validation.loader import build_validation_input
@@ -206,6 +207,30 @@ def update_assignment(
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
def set_attendance(
assignment_id: uuid.UUID,
payload: AttendanceUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Assignment:
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
le référent du poste ou la coordination. Enregistré en base : le
pointage survit à un rechargement et se retrouve sur tout appareil."""
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
ensure_can_write_shift(
account, session, session.get(Shift, assignment.shift_id)
)
assignment.attendance = payload.attendance
assignment.attendance_marked_at = local_now() if payload.attendance else None
session.commit()
session.refresh(assignment)
return assignment
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_assignment(
assignment_id: uuid.UUID,
+40
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
@@ -305,10 +306,17 @@ class AssignmentRead(ApiModel):
shift_id: uuid.UUID
source: AssignmentSource
locked: bool
attendance: AttendanceStatus | None
attendance_marked_at: dt.datetime | None
created_at: dt.datetime
updated_at: dt.datetime
class AttendanceUpdate(ApiModel):
# `None` = annuler le pointage.
attendance: AttendanceStatus | None
class AssignmentWriteResult(ApiModel):
assignment: AssignmentRead
warnings: list[Violation] = Field(default_factory=list)
@@ -758,9 +766,41 @@ class StatsNeeds(ApiModel):
volunteers_with_assignment: int
class StatsAttendanceCounts(ApiModel):
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
`assigned` = `present` + `absent` + `unmarked`."""
assigned: int = 0
present: int = 0
absent: int = 0
unmarked: int = 0
class StatsAttendancePosition(ApiModel):
position_id: uuid.UUID
name: str
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
class StatsAttendance(ApiModel):
"""Présents / absents pointés par les référents, par poste : sur les
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
locale). Les créneaux à venir ne comptent pas."""
as_of: dt.datetime
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
by_position: list[StatsAttendancePosition]
class EditionStats(ApiModel):
registrations: StatsRegistrations
answers: StatsAnswers
payments: StatsPayments
artist_wishes: list[StatsArtistWish]
needs: StatsNeeds
attendance: StatsAttendance
+58
View File
@@ -16,6 +16,9 @@ from app.api.schemas import (
EditionStats,
StatsAnswers,
StatsArtistWish,
StatsAttendance,
StatsAttendanceCounts,
StatsAttendancePosition,
StatsNeedDay,
StatsNeedPosition,
StatsNeeds,
@@ -30,6 +33,7 @@ from app.models import (
Account,
Artist,
Assignment,
AttendanceStatus,
DayPart,
Edition,
FeePaymentStatus,
@@ -108,6 +112,59 @@ def _payments(
)
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
counts.assigned += 1
if assignment.attendance == AttendanceStatus.PRESENT:
counts.present += 1
elif assignment.attendance == AttendanceStatus.ABSENT:
counts.absent += 1
else:
counts.unmarked += 1
def _attendance(
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
) -> StatsAttendance:
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
now = local_now()
past = StatsAttendanceCounts()
live = StatsAttendanceCounts()
by_position = {
p.id: StatsAttendancePosition(
position_id=p.id,
name=p.name,
past=StatsAttendanceCounts(),
live=StatsAttendanceCounts(),
live_shifts=0,
)
for p in positions
}
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
phase: dict = {}
for s in shifts:
if s.ends_at <= now:
phase[s.id] = ("past", s.position_id)
elif s.starts_at <= now:
phase[s.id] = ("live", s.position_id)
by_position[s.position_id].live_shifts += 1
for a in assignments:
if a.shift_id not in phase:
continue
when, position_id = phase[a.shift_id]
row = by_position[position_id]
_count(past if when == "past" else live, a)
_count(row.past if when == "past" else row.live, a)
return StatsAttendance(
as_of=now,
past=past,
live=live,
live_shifts=sum(r.live_shifts for r in by_position.values()),
by_position=[
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
],
)
@router.get("", response_model=EditionStats)
def get_stats(
edition: Edition = Depends(get_edition),
@@ -256,4 +313,5 @@ def get_stats(
payments=_payments(volunteers, pending_unfinished),
artist_wishes=artist_stats,
needs=needs,
attendance=_attendance(positions, shifts, assignments),
)
@@ -0,0 +1,34 @@
"""Pointage des bénévoles
Revision ID: 0030_assignment_attendance
Revises: 0029_volunteer_messages
Create Date: 2026-10-28
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
`attendance_marked_at` : le pointage du référent était gardé dans le
navigateur seulement et se perdait au rechargement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0030_assignment_attendance"
down_revision: str | None = "0029_volunteer_messages"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
def upgrade() -> None:
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
op.add_column(
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
)
def downgrade() -> None:
op.drop_column("assignment", "attendance_marked_at")
op.drop_column("assignment", "attendance")
+2
View File
@@ -27,6 +27,7 @@ from app.models.domain import (
)
from app.models.enums import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
@@ -42,6 +43,7 @@ __all__ = [
"Artist",
"Assignment",
"AssignmentSource",
"AttendanceStatus",
"BadgeAlign",
"BadgeFace",
"BadgeField",
+9
View File
@@ -17,6 +17,7 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
AttendanceStatus,
BadgeAlign,
BadgeFace,
BadgeFieldType,
@@ -414,6 +415,14 @@ class Assignment(TimestampMixin, table=True):
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Pointage par le référent du poste (ou la coordination) : `None` tant
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
# pointage, affichée sur l'écran référent.
attendance: AttendanceStatus | None = Field(
default=None,
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
)
attendance_marked_at: dt.datetime | None = None
volunteer: Volunteer = Relationship(back_populates="assignments")
shift: Shift = Relationship(back_populates="assignments")
+7
View File
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
MANUAL = "MANUAL"
class AttendanceStatus(enum.StrEnum):
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
PRESENT = "PRESENT"
ABSENT = "ABSENT"
class PositionCategory(enum.StrEnum):
BAR = "BAR"
TICKETING = "TICKETING"
+72
View File
@@ -0,0 +1,72 @@
"""Pointage présent / absent d'un bénévole sur son créneau
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
référent du poste ou la coordination."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _assignment(session, edition, lead=None):
position = make_position(session, edition)
if lead is not None:
position.lead_account_id = lead.id
session.add(position)
session.commit()
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
volunteer = make_volunteer(session, edition)
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
session.add(assignment)
session.commit()
session.refresh(assignment)
return assignment
def _url(edition, assignment):
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
assignment = _assignment(session, edition, lead)
client = as_account(lead)
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
assert resp.status_code == 200
assert resp.json()["attendance"] == "PRESENT"
assert resp.json()["attendance_marked_at"] is not None
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
assert listed["attendance"] == "PRESENT"
def test_attendance_can_be_switched_and_cleared(client, edition, session):
assignment = _assignment(session, edition)
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
"attendance"
] == "ABSENT"
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
assert cleared["attendance"] is None
assert cleared["attendance_marked_at"] is None
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition, make_account(Role.LEAD))
resp = as_account(make_account(Role.LEAD)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition)
resp = as_account(make_account(Role.VOLUNTEER)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_unknown_status_is_rejected(client, edition, session):
assignment = _assignment(session, edition)
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
assert resp.status_code == 422
+46
View File
@@ -187,3 +187,49 @@ def test_filled_slots_never_count_overstaffing(client, edition, session):
assert position["assigned"] == 3
assert position["filled"] == 2
assert position["ideal_slots"] == 4
def test_attendance_splits_past_and_live_shifts_per_position(
client, edition, session, monkeypatch
):
import app.api.stats as stats_api
from app.models import AttendanceStatus
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
# 22h–02h à venir (ignoré).
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
bar = make_position(session, edition, name="Bar")
parking = make_position(session, edition, name="Parking")
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
def assign(shift, name, attendance=None):
volunteer = make_volunteer(session, edition, first_name=name)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
attendance=attendance,
)
)
assign(past, "A", AttendanceStatus.PRESENT)
assign(past, "B", AttendanceStatus.ABSENT)
assign(past, "C")
assign(live, "D", AttendanceStatus.PRESENT)
assign(live, "E")
assign(later, "F", AttendanceStatus.PRESENT)
session.commit()
att = client.get(_url(edition)).json()["attendance"]
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
assert att["live_shifts"] == 1
# Parking n'a encore aucun créneau commencé : absent du détail.
(row,) = att["by_position"]
assert row["name"] == "Bar"
assert row["past"]["assigned"] == 3
assert row["live"]["present"] == 1
assert row["live_shifts"] == 1
+35 -1
View File
@@ -37,6 +37,8 @@ import type {
ShiftUpdate,
VolunteerCreate,
VolunteerUpdate,
AssignmentRead,
AttendanceStatus,
} from './schema';
const edition = EDITION_ID;
@@ -157,8 +159,14 @@ export function useMatrix() {
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
* recalculées côté serveur à chaque appel. */
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
* « Présence » suit le pointage des référents en temps réel. */
export function useStats() {
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) });
return useQuery({
queryKey: ['stats', edition] as const,
queryFn: () => api.stats.get(edition),
refetchInterval: 30_000,
});
}
/** Configuration du recto/verso des badges (images, champs). */
@@ -523,6 +531,32 @@ export function useUpdateAssignment() {
});
}
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
* jour tout de suite dans les listes d'affectations en cache (le référent
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
export function useSetAttendance() {
const client = useQueryClient();
const patch = (id: string, change: Partial<AssignmentRead>) =>
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
);
return useMutation({
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
api.assignments.setAttendance(edition, id, { attendance }),
onMutate: async ({ id, attendance }) => {
await client.cancelQueries({ queryKey: ['assignments', edition] });
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
patch(id, { attendance });
return { previous };
},
onError: (_error, _vars, context) => {
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
},
onSuccess: (saved) => patch(saved.id, saved),
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
});
}
export function useDeleteAssignment() {
const invalidate = useInvalidate();
return useMutation({
+6
View File
@@ -30,6 +30,7 @@ import type {
VerifyRequest,
VolunteerCreate,
VolunteerUpdate,
AttendanceUpdate,
} from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types';
@@ -232,6 +233,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
setAttendance: (editionId, id, body: AttendanceUpdate) =>
request(`${base(editionId)}/assignments/${id}/attendance`, {
method: 'PUT',
body: JSON.stringify(body),
}),
validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, {
method: 'POST',
+4
View File
@@ -382,6 +382,8 @@ export function buildMockState(): MockState {
shift_id: shift.id,
source: 'SOLVER',
locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW,
updated_at: NOW,
});
@@ -411,6 +413,8 @@ export function buildMockState(): MockState {
shift_id: shift.id,
source: 'MANUAL',
locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW,
updated_at: NOW,
});
+57 -1
View File
@@ -1,5 +1,5 @@
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
import type {
AccountRead,
@@ -594,6 +594,53 @@ function buildStats(): EditionStats {
}),
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
},
attendance: buildAttendance(),
};
}
/** Même calcul que `app/api/stats.py:_attendance`. */
function buildAttendance(): EditionStats['attendance'] {
const now = nowNaiveMinutes();
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
c.assigned += 1;
if (a.attendance === 'PRESENT') c.present += 1;
else if (a.attendance === 'ABSENT') c.absent += 1;
else c.unmarked += 1;
};
const past = empty();
const live = empty();
const rows = new Map(
state.positions.map((p) => [
p.id,
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
]),
);
const phase = new Map<string, 'past' | 'live'>();
for (const s of state.shifts) {
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
else if (naiveMinutes(s.starts_at) <= now) {
phase.set(s.id, 'live');
const row = rows.get(s.position_id);
if (row) row.live_shifts += 1;
}
}
for (const a of state.assignments) {
const when = phase.get(a.shift_id);
const shift = state.shifts.find((s) => s.id === a.shift_id);
const row = shift ? rows.get(shift.position_id) : undefined;
if (!when || !row) continue;
count(when === 'past' ? past : live, a);
count(when === 'past' ? row.past : row.live, a);
}
return {
as_of: touch(),
past,
live,
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
by_position: [...rows.values()]
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
};
}
@@ -1536,6 +1583,8 @@ export function createMockApi(): PlanningApi {
shift_id: body.shift_id,
source: body.source ?? 'MANUAL',
locked: body.locked ?? false,
attendance: null,
attendance_marked_at: null,
created_at: now,
updated_at: now,
};
@@ -1551,6 +1600,13 @@ export function createMockApi(): PlanningApi {
});
return delay({ assignment: a, warnings: [] as Violation[] });
},
setAttendance: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
a.attendance = body.attendance;
a.attendance_marked_at = body.attendance ? touch() : null;
a.updated_at = touch();
return delay({ ...a });
},
remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id);
return delay(undefined);
+36
View File
@@ -370,10 +370,20 @@ export interface AssignmentRead {
shift_id: string;
source: AssignmentSource;
locked: boolean;
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
attendance: AttendanceStatus | null;
attendance_marked_at: string | null;
created_at: string;
updated_at: string;
}
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
export interface AttendanceUpdate {
/** `null` = annuler le pointage. */
attendance: AttendanceStatus | null;
}
export interface AssignmentCreate {
volunteer_id: string;
shift_id: string;
@@ -831,4 +841,30 @@ export interface EditionStats {
by_position: StatsNeedPosition[];
volunteers_with_assignment: number;
};
attendance: StatsAttendance;
}
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
* `absent` + `unmarked`. */
export interface StatsAttendanceCounts {
assigned: number;
present: number;
absent: number;
unmarked: number;
}
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
export interface StatsAttendance {
as_of: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
by_position: Array<{
position_id: string;
name: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
}>;
}
+2
View File
@@ -57,6 +57,7 @@ import type {
VolunteerRead,
VolunteerUpdate,
EditionStats,
AttendanceUpdate,
} from './schema';
/**
@@ -140,6 +141,7 @@ export interface PlanningApi {
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
remove(editionId: string, id: string): Promise<void>;
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
validate(
editionId: string,
body: AssignmentValidateRequest,
+18
View File
@@ -177,6 +177,8 @@
"mobileLead.view.team": "Mon équipe",
"mobileLead.view.planning": "Mon planning · {count}",
"mobileLead.you": "TOI",
"mobileLead.markedAt": "pointé à {time}",
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.badgePresent": "✓ PRÉSENT",
@@ -575,6 +577,22 @@
"stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.attendance.title": "Présence",
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
"stats.attendance.live": "En ce moment",
"stats.attendance.past": "Créneaux terminés",
"stats.attendance.asOf": "mis à jour à {time}",
"stats.attendance.noLive": "Aucun créneau en cours.",
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
"stats.attendance.shiftsCount": "{count} en cours",
"stats.attendance.expected": "Attendus",
"stats.attendance.present": "Présents",
"stats.attendance.absent": "Absents",
"stats.attendance.unmarked": "Non pointés",
"stats.attendance.rate": "Présence",
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau",
"stats.unit.hours": "h",
+23 -13
View File
@@ -5,6 +5,7 @@ import {
useAssignments,
useDayParts,
usePositions,
useSetAttendance,
useShifts,
useUpdatePosition,
useVolunteer,
@@ -43,10 +44,10 @@ import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
// 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
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
// Le pointage présent/absent est enregistré sur l'affectation
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
// survit à un rechargement et se retrouve sur un autre téléphone.
type Presence = Record<string, 'present' | 'absent'>;
type View = 'team' | 'planning';
const STATUS_KEY: Record<SlotStatus, string> = {
@@ -67,7 +68,7 @@ export function MobileLeadPage() {
const [view, setView] = useState<View | null>(null);
const updatePosition = useUpdatePosition();
const now = useNowMinutes();
const [presence, setPresence] = useState<Presence>({});
const setAttendance = useSetAttendance();
const [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
@@ -142,7 +143,8 @@ export function MobileLeadPage() {
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '',
state: presence[a.id] ?? null,
state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
markedAt: a.attendance_marked_at,
};
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
@@ -150,12 +152,10 @@ export function MobileLeadPage() {
const presentCount = team.filter((m) => m.state === 'present').length;
const absentCount = team.filter((m) => m.state === 'absent').length;
// Un second appui sur le même bouton annule le pointage.
const mark = (assignmentId: string, value: 'present' | 'absent') =>
setPresence((p) => {
const next = { ...p };
if (next[assignmentId] === value) delete next[assignmentId];
else next[assignmentId] = value;
return next;
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
setAttendance.mutate({
id: member.id,
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
});
// Bénévoles distincts affectés à au moins un créneau du poste.
@@ -303,6 +303,11 @@ export function MobileLeadPage() {
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
{setAttendance.isError ? (
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileLead.attendanceError')}
</div>
) : null}
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.roll', { count: team.length })}
@@ -344,6 +349,11 @@ export function MobileLeadPage() {
{member.phone}
</span>
) : null}
{member.state && member.markedAt ? (
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
</span>
) : null}
</span>
{member.phone ? (
<a
@@ -358,7 +368,7 @@ export function MobileLeadPage() {
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() => mark(member.id, 'present')}
onClick={() => mark(member, 'present')}
aria-pressed={member.state === 'present'}
className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
@@ -369,7 +379,7 @@ export function MobileLeadPage() {
</button>
<button
type="button"
onClick={() => mark(member.id, 'absent')}
onClick={() => mark(member, 'absent')}
aria-pressed={member.state === 'absent'}
className={cx(
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
+101 -2
View File
@@ -1,5 +1,5 @@
import { useStats } from '@/api/hooks';
import type { EditionStats } from '@/api/schema';
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
import {
BarList,
CumulativeLineChart,
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
import { t } from '@/i18n';
import { foldAccents } from '@/lib/planning';
import { longDayLabel, shortDayLabel } from '@/lib/time';
import { longDayLabel, naiveMinutes, shortDayLabel } from '@/lib/time';
import { clockLabel } from '@/lib/slotTimeline';
const fr = (n: number, digits = 0) =>
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
@@ -62,6 +63,9 @@ export function StatsPage() {
/>
</div>
{/* Pendant le festival, la présence en direct passe en tête. */}
{s.attendance.live_shifts > 0 ? <AttendanceSection stats={s} /> : null}
{s.registrations.total === 0 ? (
<Panel>
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
@@ -76,6 +80,7 @@ export function StatsPage() {
</>
)}
<NeedsSection stats={s} />
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
</div>
);
}
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
</Section>
);
}
function attendanceSegments(c: StatsAttendanceCounts) {
return [
{ label: t('stats.attendance.present'), value: c.present, color: SERIES.aqua },
{ label: t('stats.attendance.absent'), value: c.absent, color: SERIES.orange },
{ label: t('stats.attendance.unmarked'), value: c.unmarked, color: SERIES.muted },
];
}
/** Ligne de tableau : attendus, présents, absents, non pointés, et part de
* présents parmi les bénévoles déjà pointés. */
function attendanceCells(c: StatsAttendanceCounts) {
const marked = c.present + c.absent;
return [
fr(c.assigned),
fr(c.present),
c.absent ? <b key="a">{fr(c.absent)}</b> : '0',
fr(c.unmarked),
<span key="m" className="flex justify-end">
<Meter
value={c.present}
max={marked}
label={marked ? `${pct(c.present, marked)} %` : '—'}
title={t('stats.attendance.rateTip', { present: fr(c.present), marked: fr(marked) })}
/>
</span>,
];
}
function AttendanceSection({ stats }: { stats: EditionStats }) {
const a = stats.attendance;
const head = [
t('stats.col.position'),
t('stats.attendance.expected'),
t('stats.attendance.present'),
t('stats.attendance.absent'),
t('stats.attendance.unmarked'),
t('stats.attendance.rate'),
];
const liveRows = a.by_position.filter((p) => p.live_shifts > 0);
const pastRows = a.by_position.filter((p) => p.past.assigned > 0);
return (
<Section title={t('stats.attendance.title')} help={t('stats.attendance.help')}>
<div className="flex flex-wrap items-baseline gap-2">
<SubTitle>{t('stats.attendance.live')}</SubTitle>
<span className="ml-auto font-mono text-[10px] opacity-60">
{t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })}
</span>
</div>
{a.live_shifts === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noLive')}</p>
) : (
<>
<p className="font-mono text-[11px]">
{t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })}
</p>
<ShareBar segments={attendanceSegments(a.live)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={liveRows.map((p) => [
`${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`,
...attendanceCells(p.live),
])}
/>
</div>
</>
)}
<SubTitle>{t('stats.attendance.past')}</SubTitle>
{a.past.assigned === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noPast')}</p>
) : (
<>
<ShareBar segments={attendanceSegments(a.past)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={[
...pastRows.map((p) => [p.name, ...attendanceCells(p.past)]),
[<b key="t">{t('stats.payments.total')}</b>, ...attendanceCells(a.past)],
]}
/>
</div>
{a.past.unmarked > 0 ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })}
</p>
) : null}
</>
)}
</Section>
);
}