From c57c70569cd7c2cbd531f0b61d8070c5b7268cfd Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 28 Oct 2026 08:06:32 +0100 Subject: [PATCH] =?UTF-8?q?Pointage=20enregistr=C3=A9=20et=20statistiques?= =?UTF-8?q?=20de=20pr=C3=A9sence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit É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 --- CLAUDE.md | 28 ++++- backend/app/api/assignments.py | 27 ++++- backend/app/api/schemas.py | 40 +++++++ backend/app/api/stats.py | 58 ++++++++++ .../versions/0030_assignment_attendance.py | 34 ++++++ backend/app/models/__init__.py | 2 + backend/app/models/domain.py | 9 ++ backend/app/models/enums.py | 7 ++ backend/tests/test_api_attendance.py | 72 ++++++++++++ backend/tests/test_api_stats.py | 46 ++++++++ frontend/src/api/hooks.ts | 36 +++++- frontend/src/api/http-client.ts | 6 + frontend/src/api/mock/dataset.ts | 4 + frontend/src/api/mock/mock-client.ts | 58 +++++++++- frontend/src/api/schema.ts | 36 ++++++ frontend/src/api/types.ts | 2 + frontend/src/i18n/locales/fr.json | 18 +++ frontend/src/pages/MobileLeadPage.tsx | 36 +++--- frontend/src/pages/StatsPage.tsx | 103 +++++++++++++++++- 19 files changed, 600 insertions(+), 22 deletions(-) create mode 100644 backend/app/migrations/versions/0030_assignment_attendance.py create mode 100644 backend/tests/test_api_attendance.py diff --git a/CLAUDE.md b/CLAUDE.md index 5d98482..72e5914 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`). diff --git a/backend/app/api/assignments.py b/backend/app/api/assignments.py index 9f2a129..5ccf07a 100644 --- a/backend/app/api/assignments.py +++ b/backend/app/api/assignments.py @@ -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, diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 1b6c68d..b4706b8 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py index 6c08274..d89d0aa 100644 --- a/backend/app/api/stats.py +++ b/backend/app/api/stats.py @@ -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), ) diff --git a/backend/app/migrations/versions/0030_assignment_attendance.py b/backend/app/migrations/versions/0030_assignment_attendance.py new file mode 100644 index 0000000..ee6ba6c --- /dev/null +++ b/backend/app/migrations/versions/0030_assignment_attendance.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 1df26d4..b17bb19 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 1603369..96a799c 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 8847535..c868d6e 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -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" diff --git a/backend/tests/test_api_attendance.py b/backend/tests/test_api_attendance.py new file mode 100644 index 0000000..4b79a6d --- /dev/null +++ b/backend/tests/test_api_attendance.py @@ -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 diff --git a/backend/tests/test_api_stats.py b/backend/tests/test_api_stats.py index 8d7d491..696ae4e 100644 --- a/backend/tests/test_api_stats.py +++ b/backend/tests/test_api_stats.py @@ -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 diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 97d1fe1..787e1e0 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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) => + client.setQueriesData({ 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({ 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({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 3128c21..15f777f 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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', diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 70acf07..8597c12 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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, }); diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 0d0188a..2c2cbb3 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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, 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(); + 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); diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b44a004..1dc43db 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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; + }>; } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 541d768..7115f17 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; update(editionId: string, id: string, body: AssignmentUpdate): Promise; remove(editionId: string, id: string): Promise; + setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise; validate( editionId: string, body: AssignmentValidateRequest, diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 2b3e4b8..1dac4cb 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx index 2d8fb86..e7ea827 100644 --- a/frontend/src/pages/MobileLeadPage.tsx +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -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; type View = 'team' | 'planning'; const STATUS_KEY: Record = { @@ -67,7 +68,7 @@ export function MobileLeadPage() { const [view, setView] = useState(null); const updatePosition = useUpdatePosition(); const now = useNowMinutes(); - const [presence, setPresence] = useState({}); + const setAttendance = useSetAttendance(); const [positionId, setPositionId] = useState(null); const [shiftId, setShiftId] = useState(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}
+ {setAttendance.isError ? ( +
+ {t('mobileLead.attendanceError')} +
+ ) : null}
{t('mobileLead.roll', { count: team.length })} @@ -344,6 +349,11 @@ export function MobileLeadPage() { {member.phone} ) : null} + {member.state && member.markedAt ? ( + + {t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })} + + ) : null} {member.phone ? (
+ {/* Pendant le festival, la présence en direct passe en tête. */} + {s.attendance.live_shifts > 0 ? : null} + {s.registrations.total === 0 ? (

{t('stats.empty')}

@@ -76,6 +80,7 @@ export function StatsPage() { )} + {s.attendance.live_shifts === 0 ? : null}
); } @@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) { ); } + +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 ? {fr(c.absent)} : '0', + fr(c.unmarked), + + + , + ]; +} + +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 ( +
+
+ {t('stats.attendance.live')} + + {t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })} + +
+ {a.live_shifts === 0 ? ( +

{t('stats.attendance.noLive')}

+ ) : ( + <> +

+ {t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })} +

+ +
+ [ + `${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`, + ...attendanceCells(p.live), + ])} + /> +
+ + )} + + {t('stats.attendance.past')} + {a.past.assigned === 0 ? ( +

{t('stats.attendance.noPast')}

+ ) : ( + <> + +
+ [p.name, ...attendanceCells(p.past)]), + [{t('stats.payments.total')}, ...attendanceCells(a.past)], + ]} + /> +
+ {a.past.unmarked > 0 ? ( +

+ {t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })} +

+ ) : null} + + )} +
+ ); +}