Pointage enregistré et statistiques de présence
É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:
co-authored by
Claude Opus 5.5
parent
a37f6c2c41
commit
c57c70569c
@@ -170,6 +170,17 @@ les tables sauf les tables d'association.
|
|||||||
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
|
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`)
|
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
|
||||||
qui en a besoin.
|
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`,
|
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
|
||||||
`volunteer_id` (nullable, unique).
|
`volunteer_id` (nullable, unique).
|
||||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
- **`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
|
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
|
||||||
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
|
||||||
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
|
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
|
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
|
||||||
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par
|
||||||
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
|
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)
|
### 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
|
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 à
|
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 |
|
| 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 |
|
| 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
|
**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/`).
|
connexion en place (voir `frontend/`).
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.api.schemas import (
|
|||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
|
AttendanceUpdate,
|
||||||
)
|
)
|
||||||
from app.auth.deps import (
|
from app.auth.deps import (
|
||||||
current_account_in_edition,
|
current_account_in_edition,
|
||||||
@@ -24,7 +25,7 @@ from app.auth.deps import (
|
|||||||
require_staff,
|
require_staff,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
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 import has_blocking, validate
|
||||||
from app.validation.loader import build_validation_input
|
from app.validation.loader import build_validation_input
|
||||||
|
|
||||||
@@ -206,6 +207,30 @@ def update_assignment(
|
|||||||
return AssignmentWriteResult(assignment=assignment, warnings=warnings)
|
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)
|
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_assignment(
|
def delete_assignment(
|
||||||
assignment_id: uuid.UUID,
|
assignment_id: uuid.UUID,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFace,
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
@@ -305,10 +306,17 @@ class AssignmentRead(ApiModel):
|
|||||||
shift_id: uuid.UUID
|
shift_id: uuid.UUID
|
||||||
source: AssignmentSource
|
source: AssignmentSource
|
||||||
locked: bool
|
locked: bool
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
attendance_marked_at: dt.datetime | None
|
||||||
created_at: dt.datetime
|
created_at: dt.datetime
|
||||||
updated_at: dt.datetime
|
updated_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AttendanceUpdate(ApiModel):
|
||||||
|
# `None` = annuler le pointage.
|
||||||
|
attendance: AttendanceStatus | None
|
||||||
|
|
||||||
|
|
||||||
class AssignmentWriteResult(ApiModel):
|
class AssignmentWriteResult(ApiModel):
|
||||||
assignment: AssignmentRead
|
assignment: AssignmentRead
|
||||||
warnings: list[Violation] = Field(default_factory=list)
|
warnings: list[Violation] = Field(default_factory=list)
|
||||||
@@ -758,9 +766,41 @@ class StatsNeeds(ApiModel):
|
|||||||
volunteers_with_assignment: int
|
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):
|
class EditionStats(ApiModel):
|
||||||
registrations: StatsRegistrations
|
registrations: StatsRegistrations
|
||||||
answers: StatsAnswers
|
answers: StatsAnswers
|
||||||
payments: StatsPayments
|
payments: StatsPayments
|
||||||
artist_wishes: list[StatsArtistWish]
|
artist_wishes: list[StatsArtistWish]
|
||||||
needs: StatsNeeds
|
needs: StatsNeeds
|
||||||
|
attendance: StatsAttendance
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ from app.api.schemas import (
|
|||||||
EditionStats,
|
EditionStats,
|
||||||
StatsAnswers,
|
StatsAnswers,
|
||||||
StatsArtistWish,
|
StatsArtistWish,
|
||||||
|
StatsAttendance,
|
||||||
|
StatsAttendanceCounts,
|
||||||
|
StatsAttendancePosition,
|
||||||
StatsNeedDay,
|
StatsNeedDay,
|
||||||
StatsNeedPosition,
|
StatsNeedPosition,
|
||||||
StatsNeeds,
|
StatsNeeds,
|
||||||
@@ -30,6 +33,7 @@ from app.models import (
|
|||||||
Account,
|
Account,
|
||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
|
AttendanceStatus,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
FeePaymentStatus,
|
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)
|
@router.get("", response_model=EditionStats)
|
||||||
def get_stats(
|
def get_stats(
|
||||||
edition: Edition = Depends(get_edition),
|
edition: Edition = Depends(get_edition),
|
||||||
@@ -256,4 +313,5 @@ def get_stats(
|
|||||||
payments=_payments(volunteers, pending_unfinished),
|
payments=_payments(volunteers, pending_unfinished),
|
||||||
artist_wishes=artist_stats,
|
artist_wishes=artist_stats,
|
||||||
needs=needs,
|
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")
|
||||||
@@ -27,6 +27,7 @@ from app.models.domain import (
|
|||||||
)
|
)
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFace,
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
@@ -42,6 +43,7 @@ __all__ = [
|
|||||||
"Artist",
|
"Artist",
|
||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
|
"AttendanceStatus",
|
||||||
"BadgeAlign",
|
"BadgeAlign",
|
||||||
"BadgeFace",
|
"BadgeFace",
|
||||||
"BadgeField",
|
"BadgeField",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
|||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
AttendanceStatus,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
BadgeFace,
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
@@ -414,6 +415,14 @@ class Assignment(TimestampMixin, table=True):
|
|||||||
default=False,
|
default=False,
|
||||||
sa_column=Column(Boolean, nullable=False, server_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")
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
shift: Shift = Relationship(back_populates="assignments")
|
shift: Shift = Relationship(back_populates="assignments")
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
|
|||||||
MANUAL = "MANUAL"
|
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):
|
class PositionCategory(enum.StrEnum):
|
||||||
BAR = "BAR"
|
BAR = "BAR"
|
||||||
TICKETING = "TICKETING"
|
TICKETING = "TICKETING"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -187,3 +187,49 @@ def test_filled_slots_never_count_overstaffing(client, edition, session):
|
|||||||
assert position["assigned"] == 3
|
assert position["assigned"] == 3
|
||||||
assert position["filled"] == 2
|
assert position["filled"] == 2
|
||||||
assert position["ideal_slots"] == 4
|
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
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import type {
|
|||||||
ShiftUpdate,
|
ShiftUpdate,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
|
AssignmentRead,
|
||||||
|
AttendanceStatus,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
const edition = EDITION_ID;
|
const edition = EDITION_ID;
|
||||||
@@ -157,8 +159,14 @@ export function useMatrix() {
|
|||||||
|
|
||||||
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
||||||
* recalculées côté serveur à chaque appel. */
|
* 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() {
|
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). */
|
/** 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() {
|
export function useDeleteAssignment() {
|
||||||
const invalidate = useInvalidate();
|
const invalidate = useInvalidate();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type {
|
|||||||
VerifyRequest,
|
VerifyRequest,
|
||||||
VolunteerCreate,
|
VolunteerCreate,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
|
AttendanceUpdate,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
import { getAuthToken, notifyUnauthorized } from './session';
|
import { getAuthToken, notifyUnauthorized } from './session';
|
||||||
import { ApiError, type PlanningApi } from './types';
|
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) }),
|
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
|
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) =>
|
validate: (editionId, body: AssignmentValidateRequest) =>
|
||||||
request(`${base(editionId)}/assignments/validate`, {
|
request(`${base(editionId)}/assignments/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -382,6 +382,8 @@ export function buildMockState(): MockState {
|
|||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
source: 'SOLVER',
|
source: 'SOLVER',
|
||||||
locked: false,
|
locked: false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -411,6 +413,8 @@ export function buildMockState(): MockState {
|
|||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
source: 'MANUAL',
|
source: 'MANUAL',
|
||||||
locked: false,
|
locked: false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
|
||||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
@@ -594,6 +594,53 @@ function buildStats(): EditionStats {
|
|||||||
}),
|
}),
|
||||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
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,
|
shift_id: body.shift_id,
|
||||||
source: body.source ?? 'MANUAL',
|
source: body.source ?? 'MANUAL',
|
||||||
locked: body.locked ?? false,
|
locked: body.locked ?? false,
|
||||||
|
attendance: null,
|
||||||
|
attendance_marked_at: null,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -1551,6 +1600,13 @@ export function createMockApi(): PlanningApi {
|
|||||||
});
|
});
|
||||||
return delay({ assignment: a, warnings: [] as Violation[] });
|
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) => {
|
remove: (_edition, id) => {
|
||||||
state.assignments = state.assignments.filter((x) => x.id !== id);
|
state.assignments = state.assignments.filter((x) => x.id !== id);
|
||||||
return delay(undefined);
|
return delay(undefined);
|
||||||
|
|||||||
@@ -370,10 +370,20 @@ export interface AssignmentRead {
|
|||||||
shift_id: string;
|
shift_id: string;
|
||||||
source: AssignmentSource;
|
source: AssignmentSource;
|
||||||
locked: boolean;
|
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;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
|
||||||
|
|
||||||
|
export interface AttendanceUpdate {
|
||||||
|
/** `null` = annuler le pointage. */
|
||||||
|
attendance: AttendanceStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignmentCreate {
|
export interface AssignmentCreate {
|
||||||
volunteer_id: string;
|
volunteer_id: string;
|
||||||
shift_id: string;
|
shift_id: string;
|
||||||
@@ -831,4 +841,30 @@ export interface EditionStats {
|
|||||||
by_position: StatsNeedPosition[];
|
by_position: StatsNeedPosition[];
|
||||||
volunteers_with_assignment: number;
|
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;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import type {
|
|||||||
VolunteerRead,
|
VolunteerRead,
|
||||||
VolunteerUpdate,
|
VolunteerUpdate,
|
||||||
EditionStats,
|
EditionStats,
|
||||||
|
AttendanceUpdate,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +141,7 @@ export interface PlanningApi {
|
|||||||
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
|
||||||
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
remove(editionId: string, id: string): Promise<void>;
|
||||||
|
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
|
||||||
validate(
|
validate(
|
||||||
editionId: string,
|
editionId: string,
|
||||||
body: AssignmentValidateRequest,
|
body: AssignmentValidateRequest,
|
||||||
|
|||||||
@@ -177,6 +177,8 @@
|
|||||||
"mobileLead.view.team": "Mon équipe",
|
"mobileLead.view.team": "Mon équipe",
|
||||||
"mobileLead.view.planning": "Mon planning · {count}",
|
"mobileLead.view.planning": "Mon planning · {count}",
|
||||||
"mobileLead.you": "TOI",
|
"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.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",
|
||||||
@@ -575,6 +577,22 @@
|
|||||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||||
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
|
"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.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||||
"stats.showTable": "Voir le tableau",
|
"stats.showTable": "Voir le tableau",
|
||||||
"stats.unit.hours": "h",
|
"stats.unit.hours": "h",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useAssignments,
|
useAssignments,
|
||||||
useDayParts,
|
useDayParts,
|
||||||
usePositions,
|
usePositions,
|
||||||
|
useSetAttendance,
|
||||||
useShifts,
|
useShifts,
|
||||||
useUpdatePosition,
|
useUpdatePosition,
|
||||||
useVolunteer,
|
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
|
// 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 ».
|
// 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 est enregistré sur l'affectation
|
||||||
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5).
|
// (`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';
|
type View = 'team' | 'planning';
|
||||||
|
|
||||||
const STATUS_KEY: Record<SlotStatus, string> = {
|
const STATUS_KEY: Record<SlotStatus, string> = {
|
||||||
@@ -67,7 +68,7 @@ export function MobileLeadPage() {
|
|||||||
const [view, setView] = useState<View | null>(null);
|
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 setAttendance = useSetAttendance();
|
||||||
const [positionId, setPositionId] = useState<string | null>(null);
|
const [positionId, setPositionId] = useState<string | null>(null);
|
||||||
const [shiftId, setShiftId] = 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]}` : '?',
|
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
|
||||||
hasPhoto: volunteer?.has_photo ?? false,
|
hasPhoto: volunteer?.has_photo ?? false,
|
||||||
phone: volunteer?.phone ?? '',
|
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'))
|
.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 presentCount = team.filter((m) => m.state === 'present').length;
|
||||||
const absentCount = team.filter((m) => m.state === 'absent').length;
|
const absentCount = team.filter((m) => m.state === 'absent').length;
|
||||||
// Un second appui sur le même bouton annule le pointage.
|
// Un second appui sur le même bouton annule le pointage.
|
||||||
const mark = (assignmentId: string, value: 'present' | 'absent') =>
|
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
|
||||||
setPresence((p) => {
|
setAttendance.mutate({
|
||||||
const next = { ...p };
|
id: member.id,
|
||||||
if (next[assignmentId] === value) delete next[assignmentId];
|
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
|
||||||
else next[assignmentId] = value;
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bénévoles distincts affectés à au moins un créneau du poste.
|
// Bénévoles distincts affectés à au moins un créneau du poste.
|
||||||
@@ -303,6 +303,11 @@ export function MobileLeadPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
|
<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">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-display text-[12px] font-black tracking-wider">
|
<span className="font-display text-[12px] font-black tracking-wider">
|
||||||
{t('mobileLead.roll', { count: team.length })}
|
{t('mobileLead.roll', { count: team.length })}
|
||||||
@@ -344,6 +349,11 @@ export function MobileLeadPage() {
|
|||||||
{member.phone}
|
{member.phone}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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>
|
</span>
|
||||||
{member.phone ? (
|
{member.phone ? (
|
||||||
<a
|
<a
|
||||||
@@ -358,7 +368,7 @@ export function MobileLeadPage() {
|
|||||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => mark(member.id, 'present')}
|
onClick={() => mark(member, 'present')}
|
||||||
aria-pressed={member.state === 'present'}
|
aria-pressed={member.state === 'present'}
|
||||||
className={cx(
|
className={cx(
|
||||||
'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
|
'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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => mark(member.id, 'absent')}
|
onClick={() => mark(member, 'absent')}
|
||||||
aria-pressed={member.state === 'absent'}
|
aria-pressed={member.state === 'absent'}
|
||||||
className={cx(
|
className={cx(
|
||||||
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
|
'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useStats } from '@/api/hooks';
|
import { useStats } from '@/api/hooks';
|
||||||
import type { EditionStats } from '@/api/schema';
|
import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
|
||||||
import {
|
import {
|
||||||
BarList,
|
BarList,
|
||||||
CumulativeLineChart,
|
CumulativeLineChart,
|
||||||
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
|
|||||||
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { foldAccents } from '@/lib/planning';
|
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) =>
|
const fr = (n: number, digits = 0) =>
|
||||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||||
@@ -62,6 +63,9 @@ export function StatsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{s.registrations.total === 0 ? (
|
||||||
<Panel>
|
<Panel>
|
||||||
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
||||||
@@ -76,6 +80,7 @@ export function StatsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<NeedsSection stats={s} />
|
<NeedsSection stats={s} />
|
||||||
|
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
|
|||||||
</Section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user