Écran Statistiques : inscriptions, réponses, cotisations et besoins
Nouvel onglet « Statistiques » (/statistiques, COORDINATION), alimenté
par GET /editions/{id}/stats (app/api/stats.py, recalculé à chaque appel,
aucune nouvelle entité) :
- inscriptions : total, 7 derniers jours, évolution cumulée par jour
- réponses : part des bénévoles ayant renseigné chaque information,
artistes les plus demandés
- cotisations : en ligne / sur place / aucune, montants par type,
mécénat, formulaires remplis sans paiement abouti
- besoins face aux bénévoles, par journée de festival : heures × personnes
demandées vs disponibilités déclarées vs affectées ; par poste, places
pourvues (plafonnées à l'effectif idéal de chaque créneau) et rapport
vœux / place
Graphiques SVG maison (components/charts.tsx) avec infobulles au survol
et au clavier, vue tableau pour chacun, palette validée (daltonisme,
contraste), mise en page vérifiée sur ordinateur et mobile. Le mock
calcule les mêmes chiffres et étale les inscriptions du jeu de démo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff57bbd863
commit
ee1f6ed72e
@@ -194,6 +194,24 @@ les tables sauf les tables d'association.
|
||||
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
|
||||
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
|
||||
affectation n'apparaît pas (rien à imprimer).
|
||||
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
|
||||
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
|
||||
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
|
||||
7 derniers jours, évolution cumulée par jour d'ajout `created_at`,
|
||||
jours sans inscription inclus), réponses (part des bénévoles ayant
|
||||
renseigné chaque information, artistes demandés), cotisations (mode de
|
||||
paiement — même règle que `lib/fee.ts:feeMode` —, montants par type,
|
||||
mécénat, `PendingFormSubmission` jamais payées), besoins face aux
|
||||
bénévoles : par **journée de festival** (§ 3), heures × personnes
|
||||
demandées (min/idéal) vs heures de disponibilité déclarées vs heures
|
||||
affectées ; par poste, places min/idéales, places **pourvues plafonnées
|
||||
créneau par créneau à l'effectif idéal** (`filled` — un sureffectif ne
|
||||
compense jamais un trou ailleurs ; `assigned` garde le total brut) et
|
||||
nombre de vœux. Graphiques en SVG maison (`components/charts.tsx`,
|
||||
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`).
|
||||
- **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,
|
||||
@@ -398,6 +416,7 @@ Matrice appliquée par les endpoints :
|
||||
| `badges` (config + génération) | ✔ | — | — |
|
||||
| `position-sheets` (génération) | ✔ | — | — |
|
||||
| `form` (config) | ✔ | — | — |
|
||||
| `stats` | ✔ | — | — |
|
||||
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
|
||||
|
||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||
@@ -721,7 +740,7 @@ l'étape 21.)*
|
||||
│ │ │ # day_parts, imports, artists, volunteers,
|
||||
│ │ │ # availability, positions, shifts,
|
||||
│ │ │ # assignments, solver, matrix, notifications,
|
||||
│ │ │ # badges, position_sheets, forms
|
||||
│ │ │ # badges, position_sheets, forms, stats
|
||||
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
|
||||
│ │ ├── email/ # SMTP (sender) + rendu texte (templates) et HTML (html.py) + logo (branding.py)
|
||||
│ │ ├── importing/ # import CSV/Excel (parser, text, service)
|
||||
@@ -863,6 +882,7 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
|
||||
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
|
||||
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
|
||||
| 40 | Écran Statistiques (`/statistiques`, COORDINATION, `GET /editions/{id}/stats`) : inscriptions (nombre, évolution cumulée), réponses au formulaire, artistes demandés, cotisations (en ligne / sur place / aucune, par type, mécénat, paiements jamais aboutis), besoins par journée de festival face aux disponibilités déclarées et aux affectations, places pourvues et vœux par poste — graphiques SVG maison avec infobulles et vues tableau | à valider |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
@@ -647,3 +647,111 @@ class SumUpWebhookPayload(ApiModel):
|
||||
|
||||
event_type: str
|
||||
id: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
class StatsRegistrationDay(ApiModel):
|
||||
date: dt.date
|
||||
count: int
|
||||
cumulative: int
|
||||
|
||||
|
||||
class StatsRegistrations(ApiModel):
|
||||
"""Bénévoles en base, par jour d'ajout (`Volunteer.created_at`) — jours
|
||||
sans inscription inclus (à 0), pour une courbe sans trou."""
|
||||
|
||||
total: int
|
||||
last_7_days: int
|
||||
by_day: list[StatsRegistrationDay]
|
||||
|
||||
|
||||
class StatsAnswers(ApiModel):
|
||||
"""Nombre de bénévoles ayant renseigné chaque information (sur `total`)."""
|
||||
|
||||
total: int
|
||||
email: int
|
||||
phone: int
|
||||
photo: int
|
||||
comment: int
|
||||
companion_note: int
|
||||
available: int
|
||||
position_wishes: int
|
||||
artist_wishes: int
|
||||
companion_wishes: int
|
||||
|
||||
|
||||
class StatsPaymentOption(ApiModel):
|
||||
label: str
|
||||
mecenat: bool
|
||||
count: int
|
||||
amount_total: float
|
||||
|
||||
|
||||
class StatsPayments(ApiModel):
|
||||
"""Mode de paiement (même règle que `lib/fee.ts:feeMode`) : `online`
|
||||
(paiement en ligne confirmé), `on_site`, `none`. `pending_unfinished` :
|
||||
formulaires remplis dont le paiement en ligne n'a jamais abouti — aucun
|
||||
bénévole créé pour eux (`PendingFormSubmission` non promue)."""
|
||||
|
||||
online: int
|
||||
on_site: int
|
||||
none: int
|
||||
pending_unfinished: int
|
||||
total_collected: float
|
||||
mecenat_count: int
|
||||
mecenat_total: float
|
||||
by_option: list[StatsPaymentOption]
|
||||
|
||||
|
||||
class StatsArtistWish(ApiModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
date: dt.date
|
||||
count: int
|
||||
|
||||
|
||||
class StatsNeedDay(ApiModel):
|
||||
"""Par journée de festival (`validation/engine.py:festival_day`) : heures
|
||||
× personnes demandées (effectif minimal / idéal), heures de disponibilité
|
||||
déclarées, heures déjà affectées."""
|
||||
|
||||
date: dt.date
|
||||
shifts: int
|
||||
min_hours: float
|
||||
ideal_hours: float
|
||||
available_hours: float
|
||||
available_volunteers: int
|
||||
assigned_hours: float
|
||||
|
||||
|
||||
class StatsNeedPosition(ApiModel):
|
||||
"""Places (personnes × créneaux) demandées et pourvues pour un poste, et
|
||||
nombre de bénévoles qui l'ont mis dans leurs vœux."""
|
||||
|
||||
position_id: uuid.UUID
|
||||
name: str
|
||||
shifts: int
|
||||
min_slots: int
|
||||
ideal_slots: int
|
||||
assigned: int
|
||||
# Places pourvues, plafonnées créneau par créneau à l'effectif idéal : un
|
||||
# sureffectif ne compense jamais un trou ailleurs.
|
||||
filled: int
|
||||
wishes: int
|
||||
|
||||
|
||||
class StatsNeeds(ApiModel):
|
||||
by_day: list[StatsNeedDay]
|
||||
by_position: list[StatsNeedPosition]
|
||||
volunteers_with_availability: int
|
||||
volunteers_with_assignment: int
|
||||
|
||||
|
||||
class EditionStats(ApiModel):
|
||||
registrations: StatsRegistrations
|
||||
answers: StatsAnswers
|
||||
payments: StatsPayments
|
||||
artist_wishes: list[StatsArtistWish]
|
||||
needs: StatsNeeds
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Statistiques d'une édition : inscriptions (nombre, évolution), réponses au
|
||||
formulaire, paiements de la cotisation, besoins face aux disponibilités.
|
||||
|
||||
Lecture seule, recalculée à chaque appel (quelques centaines de lignes au
|
||||
plus pour un festival de 50 à 200 bénévoles) — rien n'est stocké.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
from app.api.schemas import (
|
||||
EditionStats,
|
||||
StatsAnswers,
|
||||
StatsArtistWish,
|
||||
StatsNeedDay,
|
||||
StatsNeedPosition,
|
||||
StatsNeeds,
|
||||
StatsPaymentOption,
|
||||
StatsPayments,
|
||||
StatsRegistrationDay,
|
||||
StatsRegistrations,
|
||||
)
|
||||
from app.auth.deps import require_coordinator
|
||||
from app.db import get_session
|
||||
from app.models import (
|
||||
Account,
|
||||
Artist,
|
||||
Assignment,
|
||||
Availability,
|
||||
DayPart,
|
||||
Edition,
|
||||
FeePaymentStatus,
|
||||
PendingFormSubmission,
|
||||
Position,
|
||||
Shift,
|
||||
Volunteer,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
local_now,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/stats", tags=["stats"])
|
||||
|
||||
|
||||
def _hours(starts_at: dt.datetime, ends_at: dt.datetime) -> float:
|
||||
return (ends_at - starts_at).total_seconds() / 3600
|
||||
|
||||
|
||||
def _round(value: float) -> float:
|
||||
return round(value, 2)
|
||||
|
||||
|
||||
def _registrations(volunteers: list[Volunteer]) -> StatsRegistrations:
|
||||
per_day = Counter(v.created_at.date() for v in volunteers)
|
||||
by_day: list[StatsRegistrationDay] = []
|
||||
if per_day:
|
||||
day, last, cumulative = min(per_day), max(per_day), 0
|
||||
while day <= last:
|
||||
cumulative += per_day[day]
|
||||
by_day.append(
|
||||
StatsRegistrationDay(date=day, count=per_day[day], cumulative=cumulative)
|
||||
)
|
||||
day += dt.timedelta(days=1)
|
||||
since = local_now() - dt.timedelta(days=7)
|
||||
return StatsRegistrations(
|
||||
total=len(volunteers),
|
||||
last_7_days=sum(1 for v in volunteers if v.created_at >= since),
|
||||
by_day=by_day,
|
||||
)
|
||||
|
||||
|
||||
def _payments(
|
||||
volunteers: list[Volunteer], pending_unfinished: int
|
||||
) -> StatsPayments:
|
||||
# Même règle que `lib/fee.ts:feeMode` / `exporting/xlsx.py:_payment_mode`.
|
||||
online = [v for v in volunteers if v.fee_payment_status is not None]
|
||||
paid = [v for v in online if v.fee_payment_status == FeePaymentStatus.PAID]
|
||||
by_label: dict[str, list[Volunteer]] = defaultdict(list)
|
||||
for v in paid:
|
||||
by_label[v.fee_payment_label or ""].append(v)
|
||||
mecenat = [v for v in paid if v.mecenat_address]
|
||||
return StatsPayments(
|
||||
online=len(online),
|
||||
on_site=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and v.willing_to_pay_fee
|
||||
),
|
||||
none=sum(
|
||||
1 for v in volunteers if v.fee_payment_status is None and not v.willing_to_pay_fee
|
||||
),
|
||||
pending_unfinished=pending_unfinished,
|
||||
total_collected=_round(sum(v.fee_amount or 0 for v in paid)),
|
||||
mecenat_count=len(mecenat),
|
||||
mecenat_total=_round(sum(v.fee_amount or 0 for v in mecenat)),
|
||||
by_option=[
|
||||
StatsPaymentOption(
|
||||
label=label,
|
||||
mecenat=any(v.mecenat_address for v in group),
|
||||
count=len(group),
|
||||
amount_total=_round(sum(v.fee_amount or 0 for v in group)),
|
||||
)
|
||||
for label, group in sorted(by_label.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=EditionStats)
|
||||
def get_stats(
|
||||
edition: Edition = Depends(get_edition),
|
||||
_guard: Account = Depends(require_coordinator),
|
||||
session: Session = Depends(get_session),
|
||||
) -> EditionStats:
|
||||
volunteers = list(session.exec(select(Volunteer).where(Volunteer.edition_id == edition.id)))
|
||||
volunteer_ids = [v.id for v in volunteers]
|
||||
positions = list(
|
||||
session.exec(
|
||||
select(Position).where(Position.edition_id == edition.id).order_by(Position.name)
|
||||
)
|
||||
)
|
||||
shifts = list(session.exec(select(Shift).where(Shift.edition_id == edition.id)))
|
||||
assignments = list(
|
||||
session.exec(select(Assignment).where(Assignment.edition_id == edition.id))
|
||||
)
|
||||
availabilities = list(
|
||||
session.exec(select(Availability).where(Availability.edition_id == edition.id))
|
||||
)
|
||||
artists = list(
|
||||
session.exec(
|
||||
select(Artist)
|
||||
.where(Artist.edition_id == edition.id)
|
||||
.order_by(Artist.performance_date, Artist.start_time)
|
||||
)
|
||||
)
|
||||
position_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerPositionWish).where(
|
||||
VolunteerPositionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
artist_wishes = (
|
||||
list(
|
||||
session.exec(
|
||||
select(VolunteerArtist).where(VolunteerArtist.volunteer_id.in_(volunteer_ids))
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else []
|
||||
)
|
||||
companion_wishers = (
|
||||
set(
|
||||
session.exec(
|
||||
select(VolunteerCompanionWish.volunteer_id).where(
|
||||
VolunteerCompanionWish.volunteer_id.in_(volunteer_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
if volunteer_ids
|
||||
else set()
|
||||
)
|
||||
pending_unfinished = len(
|
||||
session.exec(
|
||||
select(PendingFormSubmission.id).where(
|
||||
PendingFormSubmission.edition_id == edition.id,
|
||||
PendingFormSubmission.promoted_volunteer_id.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
|
||||
answers = StatsAnswers(
|
||||
total=len(volunteers),
|
||||
email=sum(1 for v in volunteers if v.email),
|
||||
phone=sum(1 for v in volunteers if v.phone),
|
||||
photo=sum(1 for v in volunteers if v.photo is not None),
|
||||
comment=sum(1 for v in volunteers if v.comment),
|
||||
companion_note=sum(1 for v in volunteers if v.companion_wish_note),
|
||||
available=sum(1 for v in volunteers if v.self_declared_available),
|
||||
position_wishes=len({w.volunteer_id for w in position_wishes}),
|
||||
artist_wishes=len({w.volunteer_id for w in artist_wishes}),
|
||||
companion_wishes=len(companion_wishers),
|
||||
)
|
||||
|
||||
fans = Counter(w.artist_id for w in artist_wishes)
|
||||
artist_stats = [
|
||||
StatsArtistWish(id=a.id, name=a.name, date=a.performance_date, count=fans[a.id])
|
||||
for a in artists
|
||||
]
|
||||
|
||||
# Besoins par journée de festival : une soirée qui passe minuit reste sur
|
||||
# le jour où elle a commencé (CLAUDE.md § 3).
|
||||
shifts_by_id = {s.id: s for s in shifts}
|
||||
day_rows: dict[dt.date, dict] = defaultdict(
|
||||
lambda: {
|
||||
"shifts": 0, "min_hours": 0.0, "ideal_hours": 0.0, "available_hours": 0.0,
|
||||
"available_volunteers": set(), "assigned_hours": 0.0,
|
||||
}
|
||||
)
|
||||
for s in shifts:
|
||||
row = day_rows[festival_day(s.starts_at, boundary)]
|
||||
hours = _hours(s.starts_at, s.ends_at)
|
||||
row["shifts"] += 1
|
||||
row["min_hours"] += hours * s.min_staff
|
||||
row["ideal_hours"] += hours * s.ideal_staff
|
||||
for a in assignments:
|
||||
s = shifts_by_id.get(a.shift_id)
|
||||
if s is not None:
|
||||
day_rows[festival_day(s.starts_at, boundary)]["assigned_hours"] += _hours(
|
||||
s.starts_at, s.ends_at
|
||||
)
|
||||
for w in availabilities:
|
||||
starts_at = dt.datetime.combine(w.date, w.start_time)
|
||||
end_date = w.date if w.end_time > w.start_time else w.date + dt.timedelta(days=1)
|
||||
row = day_rows[festival_day(starts_at, boundary)]
|
||||
row["available_hours"] += _hours(starts_at, dt.datetime.combine(end_date, w.end_time))
|
||||
row["available_volunteers"].add(w.volunteer_id)
|
||||
|
||||
wishers = Counter(w.position_id for w in position_wishes)
|
||||
assigned_by_shift = Counter(a.shift_id for a in assignments)
|
||||
shifts_by_position: dict = defaultdict(list)
|
||||
for s in shifts:
|
||||
shifts_by_position[s.position_id].append(s)
|
||||
|
||||
needs = StatsNeeds(
|
||||
by_day=[
|
||||
StatsNeedDay(
|
||||
date=day,
|
||||
shifts=row["shifts"],
|
||||
min_hours=_round(row["min_hours"]),
|
||||
ideal_hours=_round(row["ideal_hours"]),
|
||||
available_hours=_round(row["available_hours"]),
|
||||
available_volunteers=len(row["available_volunteers"]),
|
||||
assigned_hours=_round(row["assigned_hours"]),
|
||||
)
|
||||
for day, row in sorted(day_rows.items())
|
||||
],
|
||||
by_position=[
|
||||
StatsNeedPosition(
|
||||
position_id=p.id,
|
||||
name=p.name,
|
||||
shifts=len(shifts_by_position[p.id]),
|
||||
min_slots=sum(s.min_staff for s in shifts_by_position[p.id]),
|
||||
ideal_slots=sum(s.ideal_staff for s in shifts_by_position[p.id]),
|
||||
assigned=sum(assigned_by_shift[s.id] for s in shifts_by_position[p.id]),
|
||||
filled=sum(
|
||||
min(assigned_by_shift[s.id], s.ideal_staff) for s in shifts_by_position[p.id]
|
||||
),
|
||||
wishes=wishers[p.id],
|
||||
)
|
||||
for p in positions
|
||||
],
|
||||
volunteers_with_availability=len({w.volunteer_id for w in availabilities}),
|
||||
volunteers_with_assignment=len({a.volunteer_id for a in assignments}),
|
||||
)
|
||||
|
||||
return EditionStats(
|
||||
registrations=_registrations(volunteers),
|
||||
answers=answers,
|
||||
payments=_payments(volunteers, pending_unfinished),
|
||||
artist_wishes=artist_stats,
|
||||
needs=needs,
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from app.api.position_sheets import router as position_sheets_router
|
||||
from app.api.positions import router as position_router
|
||||
from app.api.shifts import router as shift_router
|
||||
from app.api.solver import router as solver_router
|
||||
from app.api.stats import router as stats_router
|
||||
from app.api.volunteers import router as volunteer_router
|
||||
from app.config import get_settings
|
||||
from app.logging_config import configure_logging
|
||||
@@ -72,6 +73,7 @@ app.include_router(shift_router)
|
||||
app.include_router(assignment_router)
|
||||
app.include_router(solver_router)
|
||||
app.include_router(matrix_router)
|
||||
app.include_router(stats_router)
|
||||
app.include_router(notification_router)
|
||||
app.include_router(badge_router)
|
||||
app.include_router(form_router)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Statistiques d'une édition (inscriptions, réponses, paiements, besoins)."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from app.models import (
|
||||
Assignment,
|
||||
FeePaymentStatus,
|
||||
PendingFormSubmission,
|
||||
Role,
|
||||
VolunteerArtist,
|
||||
VolunteerCompanionWish,
|
||||
VolunteerPositionWish,
|
||||
)
|
||||
from tests.factories import (
|
||||
dt_at,
|
||||
make_artist,
|
||||
make_availability,
|
||||
make_position,
|
||||
make_shift,
|
||||
make_volunteer,
|
||||
)
|
||||
|
||||
|
||||
def _url(edition):
|
||||
return f"/editions/{edition.id}/stats"
|
||||
|
||||
|
||||
def _registered_on(session, volunteer, day):
|
||||
volunteer.created_at = dt.datetime(2026, 6, day, 10, 0)
|
||||
session.add(volunteer)
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_is_coordinator_only(edition, make_account, as_account):
|
||||
lead = make_account(Role.LEAD, email="lead@e.fr")
|
||||
assert as_account(lead).get(_url(edition)).status_code == 403
|
||||
|
||||
|
||||
def test_empty_edition(client, edition):
|
||||
resp = client.get(_url(edition))
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["registrations"]["total"] == 0
|
||||
assert body["registrations"]["by_day"] == []
|
||||
assert body["payments"]["total_collected"] == 0
|
||||
assert body["needs"]["by_day"] == []
|
||||
|
||||
|
||||
def test_registrations_evolution_is_cumulative_with_gap_days_filled(client, edition, session):
|
||||
for day in (1, 1, 3):
|
||||
_registered_on(session, make_volunteer(session, edition), day)
|
||||
|
||||
by_day = client.get(_url(edition)).json()["registrations"]["by_day"]
|
||||
assert by_day == [
|
||||
{"date": "2026-06-01", "count": 2, "cumulative": 2},
|
||||
{"date": "2026-06-02", "count": 0, "cumulative": 2},
|
||||
{"date": "2026-06-03", "count": 1, "cumulative": 3},
|
||||
]
|
||||
|
||||
|
||||
def test_answers_count_volunteers_per_filled_field(client, edition, session):
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
artist = make_artist(session, edition)
|
||||
full = make_volunteer(session, edition, first_name="Alex", email="alex@ex.fr")
|
||||
full.phone = "0600000000"
|
||||
full.comment = "Dispo le soir"
|
||||
full.companion_wish_note = "Sam"
|
||||
full.self_declared_available = True
|
||||
other = make_volunteer(session, edition, first_name="Sam")
|
||||
session.add_all([
|
||||
full,
|
||||
VolunteerPositionWish(volunteer_id=full.id, position_id=bar.id),
|
||||
VolunteerArtist(volunteer_id=full.id, artist_id=artist.id),
|
||||
VolunteerCompanionWish(volunteer_id=full.id, companion_id=other.id),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
body = client.get(_url(edition)).json()
|
||||
answers = body["answers"]
|
||||
assert answers["total"] == 2
|
||||
for key in (
|
||||
"email", "phone", "comment", "companion_note", "available",
|
||||
"position_wishes", "artist_wishes", "companion_wishes",
|
||||
):
|
||||
assert answers[key] == 1, key
|
||||
assert answers["photo"] == 0
|
||||
assert body["artist_wishes"] == [
|
||||
{"id": str(artist.id), "name": "Chilla", "date": "2026-07-10", "count": 1}
|
||||
]
|
||||
|
||||
|
||||
def test_payments_split_online_on_site_and_unfinished(client, edition, session):
|
||||
donor = make_volunteer(session, edition, first_name="Alex")
|
||||
donor.willing_to_pay_fee = True
|
||||
donor.fee_payment_label = "Mécénat"
|
||||
donor.fee_amount = 50.0
|
||||
donor.fee_payment_status = FeePaymentStatus.PAID
|
||||
donor.mecenat_address = "1 rue du Festival"
|
||||
classic = make_volunteer(session, edition, first_name="Sam")
|
||||
classic.willing_to_pay_fee = True
|
||||
classic.fee_payment_label = "Cotisation classique"
|
||||
classic.fee_amount = 12.5
|
||||
classic.fee_payment_status = FeePaymentStatus.PAID
|
||||
on_site = make_volunteer(session, edition, first_name="Jo")
|
||||
on_site.willing_to_pay_fee = True
|
||||
make_volunteer(session, edition, first_name="Lou")
|
||||
session.add_all([
|
||||
donor,
|
||||
classic,
|
||||
on_site,
|
||||
# Formulaire rempli, paiement jamais abouti : pas de bénévole créé.
|
||||
PendingFormSubmission(
|
||||
edition_id=edition.id, payment_label="Mécénat", payment_amount=30.0,
|
||||
checkout_status=FeePaymentStatus.FAILED,
|
||||
),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
payments = client.get(_url(edition)).json()["payments"]
|
||||
assert payments["online"] == 2
|
||||
assert payments["on_site"] == 1
|
||||
assert payments["none"] == 1
|
||||
assert payments["pending_unfinished"] == 1
|
||||
assert payments["total_collected"] == 62.5
|
||||
assert payments["mecenat_count"] == 1
|
||||
assert payments["mecenat_total"] == 50.0
|
||||
assert payments["by_option"] == [
|
||||
{"label": "Cotisation classique", "mecenat": False, "count": 1, "amount_total": 12.5},
|
||||
{"label": "Mécénat", "mecenat": True, "count": 1, "amount_total": 50.0},
|
||||
]
|
||||
|
||||
|
||||
def test_needs_are_grouped_by_festival_day(client, edition, session):
|
||||
# Début de journée = 06:00 (fixture) : la soirée du 9 déborde après minuit.
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
late = make_shift(
|
||||
session, edition, bar, start=dt_at(9, 22), end=dt_at(10, 0), min_staff=1, ideal_staff=2,
|
||||
)
|
||||
make_shift(
|
||||
session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1), min_staff=1, ideal_staff=1,
|
||||
)
|
||||
alex = make_volunteer(session, edition, first_name="Alex")
|
||||
sam = make_volunteer(session, edition, first_name="Sam")
|
||||
make_availability(
|
||||
session, edition, alex,
|
||||
date=dt.date(2026, 7, 9), start_time=dt.time(20, 0), end_time=dt.time(2, 0),
|
||||
)
|
||||
session.add_all([
|
||||
Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=late.id),
|
||||
VolunteerPositionWish(volunteer_id=alex.id, position_id=bar.id),
|
||||
VolunteerPositionWish(volunteer_id=sam.id, position_id=bar.id),
|
||||
])
|
||||
session.commit()
|
||||
|
||||
needs = client.get(_url(edition)).json()["needs"]
|
||||
assert needs["by_day"] == [
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"shifts": 2,
|
||||
"min_hours": 3.0,
|
||||
"ideal_hours": 5.0,
|
||||
"available_hours": 6.0,
|
||||
"available_volunteers": 1,
|
||||
"assigned_hours": 2.0,
|
||||
}
|
||||
]
|
||||
assert needs["by_position"] == [
|
||||
{
|
||||
"position_id": str(bar.id),
|
||||
"name": "Bar",
|
||||
"shifts": 2,
|
||||
"min_slots": 2,
|
||||
"ideal_slots": 3,
|
||||
"assigned": 1,
|
||||
"filled": 1,
|
||||
"wishes": 2,
|
||||
}
|
||||
]
|
||||
assert needs["volunteers_with_availability"] == 1
|
||||
assert needs["volunteers_with_assignment"] == 1
|
||||
|
||||
|
||||
def test_filled_slots_never_count_overstaffing(client, edition, session):
|
||||
# 3 affectés sur un créneau qui n'en demande que 2 : 2 places pourvues,
|
||||
# pas 3 — sinon un sureffectif masquerait un trou ailleurs.
|
||||
bar = make_position(session, edition, name="Bar")
|
||||
full = make_shift(session, edition, bar, start=dt_at(9, 18), end=dt_at(9, 20), ideal_staff=2)
|
||||
make_shift(session, edition, bar, start=dt_at(9, 20), end=dt_at(9, 22), ideal_staff=2)
|
||||
for name in ("A", "B", "C"):
|
||||
v = make_volunteer(session, edition, first_name=name)
|
||||
session.add(Assignment(edition_id=edition.id, volunteer_id=v.id, shift_id=full.id))
|
||||
session.commit()
|
||||
|
||||
position = client.get(_url(edition)).json()["needs"]["by_position"][0]
|
||||
assert position["assigned"] == 3
|
||||
assert position["filled"] == 2
|
||||
assert position["ideal_slots"] == 4
|
||||
@@ -22,6 +22,7 @@ import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
|
||||
import { BadgesPage } from '@/pages/BadgesPage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { FormBuilderPage } from '@/pages/FormBuilderPage';
|
||||
import { StatsPage } from '@/pages/StatsPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
@@ -443,6 +444,9 @@ if (coordinator) {
|
||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
||||
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
||||
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
||||
});
|
||||
}
|
||||
if (volunteerAccount) {
|
||||
const vid = volunteerAccount.volunteer_id as string;
|
||||
|
||||
@@ -167,6 +167,12 @@ export function useMatrix() {
|
||||
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
||||
}
|
||||
|
||||
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
|
||||
* recalculées côté serveur à chaque appel. */
|
||||
export function useStats() {
|
||||
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) });
|
||||
}
|
||||
|
||||
/** Configuration du recto/verso des badges (images, champs). */
|
||||
export function useBadgeTemplate() {
|
||||
return useQuery({
|
||||
|
||||
@@ -241,6 +241,9 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
matrix: {
|
||||
get: (editionId) => request(`${base(editionId)}/matrix`),
|
||||
},
|
||||
stats: {
|
||||
get: (editionId) => request(`${base(editionId)}/stats`),
|
||||
},
|
||||
badges: {
|
||||
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
|
||||
uploadFrontImage: (editionId, file: File) => {
|
||||
|
||||
@@ -161,6 +161,14 @@ const ARTIST_POOL = [
|
||||
];
|
||||
|
||||
const NOW = '2026-10-10T09:00:00';
|
||||
|
||||
/** Date d'inscription de démonstration du i-ème bénévole : plus dense en
|
||||
* fin de période (relances), jamais après `NOW`. */
|
||||
function registeredAt(i: number): string {
|
||||
const daysBefore = Math.floor(((i * 37) % 64) ** 1.4 / 9);
|
||||
const d = new Date(Date.UTC(2026, 9, 10 - daysBefore, 9 + (i % 10), (i * 13) % 60));
|
||||
return d.toISOString().slice(0, 19);
|
||||
}
|
||||
const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]);
|
||||
const OVERLOADED = [3, 9];
|
||||
|
||||
@@ -323,6 +331,25 @@ export function buildMockState(): MockState {
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
});
|
||||
// Démo des statistiques : inscriptions étalées sur ~5 semaines et
|
||||
// quelques cotisations — dérivées de `i`, pas de `rnd()`, pour ne rien
|
||||
// décaler du reste du jeu de démonstration.
|
||||
const v = volunteers[volunteers.length - 1];
|
||||
v.created_at = registeredAt(i);
|
||||
if (i % 4 === 1) v.willing_to_pay_fee = true;
|
||||
if (i % 7 === 2 || i % 11 === 3) {
|
||||
const mecenat = i % 11 === 3;
|
||||
Object.assign(v, {
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: mecenat ? 'Mécénat' : 'Cotisation classique',
|
||||
fee_amount: mecenat ? 40 : 15,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: v.created_at,
|
||||
...(mecenat
|
||||
? { mecenat_address: '1 rue du Festival', mecenat_city: 'Rennes', mecenat_postal_code: '35000', mecenat_country: 'France' }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const me = volunteers[0];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { uncoveredMinutes } from '@/lib/planning';
|
||||
import { dayBoundaryFromDayParts, festivalDay, uncoveredMinutes } from '@/lib/planning';
|
||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||
|
||||
import type {
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
TokenResponse,
|
||||
Violation,
|
||||
VolunteerRead,
|
||||
EditionStats,
|
||||
} from '../schema';
|
||||
import { getAuthToken } from '../session';
|
||||
import { ApiError, type PlanningApi } from '../types';
|
||||
@@ -534,6 +535,146 @@ function hasBlocking(violations: Violation[]): boolean {
|
||||
return violations.some((v) => v.severity === 'BLOCKING');
|
||||
}
|
||||
|
||||
/** Miroir de `app/api/stats.py` (mêmes règles, données du mock). */
|
||||
function buildStats(): EditionStats {
|
||||
const vols = state.volunteers;
|
||||
const round = (n: number) => Math.round(n * 100) / 100;
|
||||
const addDay = (d: string) =>
|
||||
new Date(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10) + 1))
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
const perDay = new Map<string, number>();
|
||||
for (const v of vols) perDay.set(v.created_at.slice(0, 10), (perDay.get(v.created_at.slice(0, 10)) ?? 0) + 1);
|
||||
const byDay: EditionStats['registrations']['by_day'] = [];
|
||||
const days = [...perDay.keys()].sort();
|
||||
if (days.length) {
|
||||
let cumulative = 0;
|
||||
for (let d = days[0]; d <= days[days.length - 1]; d = addDay(d)) {
|
||||
cumulative += perDay.get(d) ?? 0;
|
||||
byDay.push({ date: d, count: perDay.get(d) ?? 0, cumulative });
|
||||
}
|
||||
}
|
||||
const latest = days.length ? days[days.length - 1] : '';
|
||||
const weekAgo = latest
|
||||
? new Date(Date.parse(`${latest}T00:00:00Z`) - 6 * 86400000).toISOString().slice(0, 10)
|
||||
: '';
|
||||
|
||||
const paid = vols.filter((v) => v.fee_payment_status === 'PAID');
|
||||
const byLabel = new Map<string, VolunteerRead[]>();
|
||||
for (const v of paid) byLabel.set(v.fee_payment_label ?? '', [...(byLabel.get(v.fee_payment_label ?? '') ?? []), v]);
|
||||
const mecenat = paid.filter((v) => v.mecenat_address);
|
||||
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
|
||||
|
||||
const boundary = dayBoundaryFromDayParts(state.dayParts);
|
||||
type Row = { shifts: number; min: number; ideal: number; avail: number; who: Set<string>; assigned: number };
|
||||
const rows = new Map<string, Row>();
|
||||
const row = (d: string) => {
|
||||
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, avail: 0, who: new Set(), assigned: 0 });
|
||||
return rows.get(d) as Row;
|
||||
};
|
||||
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
|
||||
for (const s of state.shifts) {
|
||||
const r = row(festivalDay(s.starts_at, boundary));
|
||||
const h = hoursBetween(s.starts_at, s.ends_at);
|
||||
r.shifts += 1;
|
||||
r.min += h * s.min_staff;
|
||||
r.ideal += h * s.ideal_staff;
|
||||
}
|
||||
for (const a of state.assignments) {
|
||||
const s = shiftsById.get(a.shift_id);
|
||||
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
|
||||
}
|
||||
const withAvailability = new Set<string>();
|
||||
for (const [vid, windows] of state.availability) {
|
||||
for (const w of windows) {
|
||||
const start = `${w.date}T${w.start_time.slice(0, 5)}:00`;
|
||||
const endDate = w.end_time > w.start_time ? w.date : addDay(w.date);
|
||||
const r = row(festivalDay(start, boundary));
|
||||
r.avail += hoursBetween(start, `${endDate}T${w.end_time.slice(0, 5)}:00`);
|
||||
r.who.add(vid);
|
||||
withAvailability.add(vid);
|
||||
}
|
||||
}
|
||||
const assignedByShift = new Map<string, number>();
|
||||
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
|
||||
|
||||
return {
|
||||
registrations: {
|
||||
total: vols.length,
|
||||
last_7_days: vols.filter((v) => v.created_at.slice(0, 10) >= weekAgo).length,
|
||||
by_day: byDay,
|
||||
},
|
||||
answers: {
|
||||
total: vols.length,
|
||||
email: vols.filter((v) => v.email).length,
|
||||
phone: vols.filter((v) => v.phone).length,
|
||||
photo: vols.filter((v) => v.has_photo).length,
|
||||
comment: vols.filter((v) => v.comment).length,
|
||||
companion_note: vols.filter((v) => v.companion_wish_note).length,
|
||||
available: vols.filter((v) => v.self_declared_available).length,
|
||||
position_wishes: vols.filter((v) => v.wished_position_ids.length).length,
|
||||
artist_wishes: vols.filter((v) => v.artist_ids.length).length,
|
||||
companion_wishes: vols.filter((v) => v.wished_companion_ids.length).length,
|
||||
},
|
||||
payments: {
|
||||
online: vols.filter((v) => v.fee_payment_status !== null).length,
|
||||
on_site: vols.filter((v) => v.fee_payment_status === null && v.willing_to_pay_fee).length,
|
||||
none: vols.filter((v) => v.fee_payment_status === null && !v.willing_to_pay_fee).length,
|
||||
pending_unfinished: [...state.pendingSubmissions.values()].filter((p) => p.promotedVolunteerId === null).length,
|
||||
total_collected: sum(paid),
|
||||
mecenat_count: mecenat.length,
|
||||
mecenat_total: sum(mecenat),
|
||||
by_option: [...byLabel.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([label, group]) => ({
|
||||
label,
|
||||
mecenat: group.some((v) => v.mecenat_address),
|
||||
count: group.length,
|
||||
amount_total: sum(group),
|
||||
})),
|
||||
},
|
||||
artist_wishes: [...state.artists]
|
||||
.sort((a, b) => a.performance_date.localeCompare(b.performance_date) || a.start_time.localeCompare(b.start_time))
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
date: a.performance_date,
|
||||
count: vols.filter((v) => v.artist_ids.includes(a.id)).length,
|
||||
})),
|
||||
needs: {
|
||||
by_day: [...rows.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, r]) => ({
|
||||
date,
|
||||
shifts: r.shifts,
|
||||
min_hours: round(r.min),
|
||||
ideal_hours: round(r.ideal),
|
||||
available_hours: round(r.avail),
|
||||
available_volunteers: r.who.size,
|
||||
assigned_hours: round(r.assigned),
|
||||
})),
|
||||
by_position: [...state.positions]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((p) => {
|
||||
const own = state.shifts.filter((s) => s.position_id === p.id);
|
||||
return {
|
||||
position_id: p.id,
|
||||
name: p.name,
|
||||
shifts: own.length,
|
||||
min_slots: own.reduce((t, s) => t + s.min_staff, 0),
|
||||
ideal_slots: own.reduce((t, s) => t + s.ideal_staff, 0),
|
||||
assigned: own.reduce((t, s) => t + (assignedByShift.get(s.id) ?? 0), 0),
|
||||
filled: own.reduce((t, s) => t + Math.min(assignedByShift.get(s.id) ?? 0, s.ideal_staff), 0),
|
||||
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
|
||||
};
|
||||
}),
|
||||
volunteers_with_availability: withAvailability.size,
|
||||
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMatrix(): MatrixResponse {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of state.assignments) {
|
||||
@@ -1515,6 +1656,10 @@ export function createMockApi(): PlanningApi {
|
||||
get: () => delay(buildMatrix()),
|
||||
},
|
||||
|
||||
stats: {
|
||||
get: () => delay(buildStats()),
|
||||
},
|
||||
|
||||
badges: {
|
||||
getTemplate: (): Promise<BadgeTemplateRead> =>
|
||||
delay({
|
||||
|
||||
@@ -731,3 +731,77 @@ export interface PublicFormSubmission {
|
||||
artist_wish_ids?: string[];
|
||||
photo?: File | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Statistiques d'une édition (`GET /editions/{id}/stats`, app/api/stats.py) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface StatsRegistrationDay {
|
||||
date: string;
|
||||
count: number;
|
||||
cumulative: number;
|
||||
}
|
||||
|
||||
export interface StatsAnswers {
|
||||
total: number;
|
||||
email: number;
|
||||
phone: number;
|
||||
photo: number;
|
||||
comment: number;
|
||||
companion_note: number;
|
||||
available: number;
|
||||
position_wishes: number;
|
||||
artist_wishes: number;
|
||||
companion_wishes: number;
|
||||
}
|
||||
|
||||
export interface StatsPaymentOption {
|
||||
label: string;
|
||||
mecenat: boolean;
|
||||
count: number;
|
||||
amount_total: number;
|
||||
}
|
||||
|
||||
export interface StatsNeedDay {
|
||||
date: string;
|
||||
shifts: number;
|
||||
min_hours: number;
|
||||
ideal_hours: number;
|
||||
available_hours: number;
|
||||
available_volunteers: number;
|
||||
assigned_hours: number;
|
||||
}
|
||||
|
||||
export interface StatsNeedPosition {
|
||||
position_id: string;
|
||||
name: string;
|
||||
shifts: number;
|
||||
min_slots: number;
|
||||
ideal_slots: number;
|
||||
assigned: number;
|
||||
/** Places pourvues, plafonnées créneau par créneau à l'effectif idéal. */
|
||||
filled: number;
|
||||
wishes: number;
|
||||
}
|
||||
|
||||
export interface EditionStats {
|
||||
registrations: { total: number; last_7_days: number; by_day: StatsRegistrationDay[] };
|
||||
answers: StatsAnswers;
|
||||
payments: {
|
||||
online: number;
|
||||
on_site: number;
|
||||
none: number;
|
||||
/** Formulaires remplis dont le paiement en ligne n'a jamais abouti. */
|
||||
pending_unfinished: number;
|
||||
total_collected: number;
|
||||
mecenat_count: number;
|
||||
mecenat_total: number;
|
||||
by_option: StatsPaymentOption[];
|
||||
};
|
||||
artist_wishes: Array<{ id: string; name: string; date: string; count: number }>;
|
||||
needs: {
|
||||
by_day: StatsNeedDay[];
|
||||
by_position: StatsNeedPosition[];
|
||||
volunteers_with_availability: number;
|
||||
volunteers_with_assignment: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import type {
|
||||
VolunteerPlanningEntry,
|
||||
VolunteerRead,
|
||||
VolunteerUpdate,
|
||||
EditionStats,
|
||||
} from './schema';
|
||||
|
||||
/**
|
||||
@@ -156,6 +157,10 @@ export interface PlanningApi {
|
||||
matrix: {
|
||||
get(editionId: string): Promise<MatrixResponse>;
|
||||
};
|
||||
stats: {
|
||||
/** Statistiques de l'édition (COORDINATION uniquement). */
|
||||
get(editionId: string): Promise<EditionStats>;
|
||||
};
|
||||
badges: {
|
||||
/** Configuration du recto/verso (COORDINATION uniquement). */
|
||||
getTemplate(editionId: string): Promise<BadgeTemplateRead>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||
import { PublicSignupPage } from '@/pages/PublicSignupPage';
|
||||
import { StatsPage } from '@/pages/StatsPage';
|
||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
@@ -35,6 +36,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'configuration', element: <ConfigPage /> },
|
||||
{ path: 'badges', element: <BadgesPage /> },
|
||||
{ path: 'inscriptions', element: <FormBuilderPage /> },
|
||||
{ path: 'statistiques', element: <StatsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
||||
'/configuration': ['COORDINATOR'],
|
||||
'/badges': ['COORDINATOR'],
|
||||
'/inscriptions': ['COORDINATOR'],
|
||||
'/statistiques': ['COORDINATOR'],
|
||||
'/mobile-benevole': ['VOLUNTEER'],
|
||||
'/mobile-referent': ['LEAD'],
|
||||
};
|
||||
@@ -23,6 +24,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||
{ to: '/inscriptions', num: '08', labelKey: 'tab.forms' },
|
||||
{ to: '/statistiques', num: '09', labelKey: 'tab.stats' },
|
||||
];
|
||||
|
||||
export function canAccess(pathname: string, role: Role): boolean {
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
// Graphiques de l'écran Statistiques — SVG à la main (aucune bibliothèque de
|
||||
// graphiques dans le projet). Règles suivies : marques fines (barres ≤ 24 px,
|
||||
// bout arrondi de 4 px côté valeur, carré côté ligne de base ; courbes de
|
||||
// 2 px), séparation par un vide de 2 px couleur de fond (jamais de bordure),
|
||||
// quadrillage en filet plein discret, un seul axe par graphique, texte
|
||||
// toujours à l'encre (jamais dans la couleur de la série), infobulle au
|
||||
// survol et au focus — qui ne fait que compléter : chaque graphique a sa vue
|
||||
// en tableau (`TableToggle`). Palette catégorielle vérifiée (daltonisme,
|
||||
// séparation, contraste) sur le fond `paper` : l'aqua est sous 3:1, d'où
|
||||
// libellés directs + tableau.
|
||||
|
||||
export const SERIES = {
|
||||
blue: '#2a78d6',
|
||||
orange: '#eb6834',
|
||||
aqua: '#1baf7a',
|
||||
/** « Rien / aucun » : gris de mise en retrait, jamais une série à part entière. */
|
||||
muted: '#c9c3b3',
|
||||
} as const;
|
||||
|
||||
const GRID = '#E0D8C4'; // sand-line : un cran au-dessus du fond paper
|
||||
const SURFACE = '#FFFDF7'; // paper
|
||||
const BAR_MAX_PX = 24;
|
||||
|
||||
const fr = (n: number, digits = 0) =>
|
||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||
|
||||
/** Graduations « propres » (0, 5, 10… / 0, 20, 40…) couvrant `max`. */
|
||||
function niceTicks(max: number, target = 4): number[] {
|
||||
if (max <= 0) return [0, 1];
|
||||
const raw = max / target;
|
||||
const pow = 10 ** Math.floor(Math.log10(raw));
|
||||
const step = [1, 2, 2.5, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;
|
||||
const ticks: number[] = [];
|
||||
for (let v = 0; v <= max + step * 0.999; v += step) ticks.push(Math.round(v * 100) / 100);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Largeur réelle du conteneur (le SVG se dessine en pixels, pas en %). */
|
||||
function useWidth<T extends HTMLElement>(fallback = 640) {
|
||||
const ref = useRef<T>(null);
|
||||
const [width, setWidth] = useState(fallback);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
const ro = new ResizeObserver(([entry]) => setWidth(Math.max(240, entry.contentRect.width)));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return { ref, width };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Infobulle (position: fixed, bornée à l'écran — même raison que //
|
||||
// lib/useTooltipPosition.ts : jamais coincée par un conteneur défilant) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
interface TipState {
|
||||
x: number;
|
||||
y: number;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
function useTip() {
|
||||
const [tip, setTip] = useState<TipState | null>(null);
|
||||
const show = (e: { clientX: number; clientY: number }, content: ReactNode) =>
|
||||
setTip({ x: e.clientX, y: e.clientY, content });
|
||||
const showAt = (el: Element, content: ReactNode) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setTip({ x: r.left + r.width / 2, y: r.top, content });
|
||||
};
|
||||
return { tip, show, showAt, hide: () => setTip(null) };
|
||||
}
|
||||
|
||||
function Tooltip({ tip }: { tip: TipState | null }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: -9999, top: -9999 });
|
||||
useLayoutEffect(() => {
|
||||
if (!tip || !ref.current) return;
|
||||
const { width, height } = ref.current.getBoundingClientRect();
|
||||
const left = Math.min(Math.max(tip.x + 12, 8), window.innerWidth - width - 8);
|
||||
const top = tip.y - height - 12 < 8 ? tip.y + 16 : tip.y - height - 12;
|
||||
setPos({ left, top });
|
||||
}, [tip]);
|
||||
if (!tip) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tooltip"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 50 }}
|
||||
className="pointer-events-none border-2 border-ink bg-paper px-[9px] py-[6px] font-mono text-[11px] shadow-brutal-sm"
|
||||
>
|
||||
{tip.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ligne d'infobulle : la valeur d'abord (forte), puis la série (avec un
|
||||
* court trait de sa couleur, pas une pastille pleine). */
|
||||
function TipRow({ color, label, value }: { color?: string; label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[6px] whitespace-nowrap">
|
||||
{color ? <span className="inline-block h-[2px] w-[10px]" style={{ background: color }} /> : null}
|
||||
<b>{value}</b>
|
||||
<span className="opacity-70">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Légende, vue tableau //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-[14px] gap-y-[4px] font-mono text-[10.5px]">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="flex items-center gap-[6px]">
|
||||
<span className="inline-block h-[10px] w-[10px] rounded-[2px]" style={{ background: item.color }} />
|
||||
{item.label}
|
||||
{item.value ? <b>{item.value}</b> : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Équivalent tableau d'un graphique — toujours disponible, replié. */
|
||||
export function TableToggle({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer font-display text-[9.5px] font-extrabold uppercase tracking-wide opacity-70 hover:opacity-100">
|
||||
{label}
|
||||
</summary>
|
||||
<div className="mt-2 overflow-x-auto">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
head,
|
||||
rows,
|
||||
}: {
|
||||
head: string[];
|
||||
rows: Array<Array<ReactNode>>;
|
||||
}) {
|
||||
return (
|
||||
<table className="w-full border-collapse font-mono text-[11px]">
|
||||
<thead>
|
||||
<tr>
|
||||
{head.map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
className={cx(
|
||||
'border-b-2 border-ink px-[6px] py-[4px] font-display text-[9.5px] font-extrabold uppercase tracking-wide',
|
||||
i === 0 ? 'text-left' : 'text-right',
|
||||
)}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r} className="border-b border-sand-line">
|
||||
{row.map((cell, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className={cx('px-[6px] py-[4px]', c === 0 ? 'text-left' : 'text-right tabular-nums')}
|
||||
>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Courbe cumulée (une série : pas de légende, le titre la nomme) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function CumulativeLineChart({
|
||||
points,
|
||||
dateLabel,
|
||||
valueLabel,
|
||||
dailyLabel,
|
||||
}: {
|
||||
points: Array<{ date: string; count: number; cumulative: number }>;
|
||||
dateLabel: (date: string) => string;
|
||||
valueLabel: string;
|
||||
dailyLabel: string;
|
||||
}) {
|
||||
const { ref, width } = useWidth<HTMLDivElement>();
|
||||
const { tip, show, hide } = useTip();
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const height = 220;
|
||||
const pad = { top: 16, right: 48, bottom: 28, left: 40 };
|
||||
const innerW = width - pad.left - pad.right;
|
||||
const innerH = height - pad.top - pad.bottom;
|
||||
const ticks = niceTicks(Math.max(...points.map((p) => p.cumulative), 1));
|
||||
const yMax = ticks[ticks.length - 1];
|
||||
const x = (i: number) => pad.left + (points.length <= 1 ? innerW / 2 : (i / (points.length - 1)) * innerW);
|
||||
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
|
||||
const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(i)},${y(p.cumulative)}`).join(' ');
|
||||
const area = `${line} L${x(points.length - 1)},${y(0)} L${x(0)},${y(0)} Z`;
|
||||
const last = points[points.length - 1];
|
||||
// Quelques dates seulement sous l'axe, réparties du début à la fin — leur
|
||||
// nombre suit la largeur (≈ une tous les 80 px), sinon elles se
|
||||
// chevauchent sur un téléphone.
|
||||
const slots = Math.max(2, Math.min(points.length, Math.floor(innerW / 80) + 1));
|
||||
const labelled = new Set(
|
||||
Array.from({ length: slots }, (_, k) =>
|
||||
Math.round((k * (points.length - 1)) / Math.max(1, slots - 1)),
|
||||
),
|
||||
);
|
||||
|
||||
const pick = (clientX: number, rect: DOMRect) => {
|
||||
const rel = clientX - rect.left - pad.left;
|
||||
return Math.min(points.length - 1, Math.max(0, Math.round((rel / innerW) * (points.length - 1))));
|
||||
};
|
||||
const tipContent = (i: number) => (
|
||||
<>
|
||||
<div className="mb-[2px] font-display text-[10px] font-extrabold">{dateLabel(points[i].date)}</div>
|
||||
<TipRow color={SERIES.blue} label={valueLabel} value={fr(points[i].cumulative)} />
|
||||
<TipRow label={dailyLabel} value={`+${fr(points[i].count)}`} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full min-w-0 overflow-hidden">
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`${valueLabel} : ${last ? fr(last.cumulative) : 0}`}
|
||||
onPointerMove={(e) => {
|
||||
const i = pick(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||
setHover(i);
|
||||
show(e, tipContent(i));
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
||||
const i = Math.min(points.length - 1, Math.max(0, (hover ?? points.length - 1) + (e.key === 'ArrowLeft' ? -1 : 1)));
|
||||
setHover(i);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
show({ clientX: rect.left + x(i), clientY: rect.top + y(points[i].cumulative) }, tipContent(i));
|
||||
}}
|
||||
className="block outline-none focus-visible:outline-2 focus-visible:outline-ink"
|
||||
>
|
||||
{ticks.map((tk) => (
|
||||
<g key={tk}>
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
|
||||
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
|
||||
{fr(tk)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{points.map((p, i) =>
|
||||
labelled.has(i) ? (
|
||||
<text
|
||||
key={p.date}
|
||||
x={x(i)}
|
||||
y={height - 8}
|
||||
textAnchor={i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'middle'}
|
||||
className="fill-ink font-mono text-[10px] opacity-60"
|
||||
>
|
||||
{dateLabel(p.date)}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
<path d={area} fill={SERIES.blue} fillOpacity={0.1} />
|
||||
<path d={line} fill="none" stroke={SERIES.blue} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
|
||||
{hover !== null ? (
|
||||
<>
|
||||
<line x1={x(hover)} x2={x(hover)} y1={pad.top} y2={pad.top + innerH} stroke="#14110E" strokeOpacity={0.35} strokeWidth={1} />
|
||||
<circle cx={x(hover)} cy={y(points[hover].cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
|
||||
</>
|
||||
) : null}
|
||||
{last ? (
|
||||
<>
|
||||
<circle cx={x(points.length - 1)} cy={y(last.cumulative)} r={4} fill={SERIES.blue} stroke={SURFACE} strokeWidth={2} />
|
||||
<text x={x(points.length - 1) + 8} y={y(last.cumulative) + 4} className="fill-ink font-mono text-[11px] font-bold">
|
||||
{fr(last.cumulative)}
|
||||
</text>
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Barres horizontales (une série, une couleur pour toutes les barres) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function BarList({
|
||||
rows,
|
||||
max,
|
||||
color = SERIES.blue,
|
||||
}: {
|
||||
rows: Array<{ key: string; label: string; value: number; valueLabel: string; tip?: string }>;
|
||||
/** Échelle commune (ex. 100 pour des pourcentages) — défaut : la plus grande valeur. */
|
||||
max?: number;
|
||||
color?: string;
|
||||
}) {
|
||||
const { tip, show, showAt, hide } = useTip();
|
||||
const scale = max ?? Math.max(...rows.map((r) => r.value), 1);
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => show(e, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
|
||||
onPointerLeave={hide}
|
||||
onFocus={(e) => showAt(e.currentTarget, <TipRow color={color} label={r.label} value={r.tip ?? r.valueLabel} />)}
|
||||
onBlur={hide}
|
||||
className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream"
|
||||
style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }}
|
||||
>
|
||||
<span className="truncate font-mono text-[11px]" title={r.label}>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-[6px]">
|
||||
<span
|
||||
className="block h-[14px] rounded-r-[4px]"
|
||||
style={{
|
||||
width: `${Math.max(r.value > 0 ? 1.5 : 0, (r.value / scale) * 82)}%`,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
<span className="whitespace-nowrap font-mono text-[10.5px] font-bold">{r.valueLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Colonnes groupées (plusieurs séries, même unité, un seul axe) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function GroupedColumns({
|
||||
groups,
|
||||
series,
|
||||
unit,
|
||||
}: {
|
||||
groups: Array<{ key: string; label: string; values: number[] }>;
|
||||
series: Array<{ label: string; color: string }>;
|
||||
unit: string;
|
||||
}) {
|
||||
const { ref, width } = useWidth<HTMLDivElement>();
|
||||
const { tip, show, hide, showAt } = useTip();
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
const height = 240;
|
||||
const pad = { top: 18, right: 8, bottom: 30, left: 44 };
|
||||
const innerW = width - pad.left - pad.right;
|
||||
const innerH = height - pad.top - pad.bottom;
|
||||
const ticks = niceTicks(Math.max(...groups.flatMap((g) => g.values), 1));
|
||||
const yMax = ticks[ticks.length - 1];
|
||||
const band = innerW / Math.max(groups.length, 1);
|
||||
const barW = Math.min(BAR_MAX_PX, (band * 0.7 - 2 * (series.length - 1)) / series.length);
|
||||
const groupW = barW * series.length + 2 * (series.length - 1);
|
||||
const y = (v: number) => pad.top + innerH - (v / yMax) * innerH;
|
||||
const content = (g: (typeof groups)[number]) => (
|
||||
<>
|
||||
<div className="mb-[2px] font-display text-[10px] font-extrabold">{g.label}</div>
|
||||
{series.map((s, i) => (
|
||||
<TipRow key={s.label} color={s.color} label={s.label} value={`${fr(g.values[i], 1)} ${unit}`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
// Barre au bout arrondi côté valeur (haut), carré côté ligne de base.
|
||||
const bar = (x0: number, v: number) => {
|
||||
const top = y(v);
|
||||
const h = y(0) - top;
|
||||
if (h <= 0) return '';
|
||||
const r = Math.min(4, h, barW / 2);
|
||||
return `M${x0},${y(0)} V${top + r} Q${x0},${top} ${x0 + r},${top} H${x0 + barW - r} Q${x0 + barW},${top} ${x0 + barW},${top + r} V${y(0)} Z`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full min-w-0 overflow-hidden">
|
||||
<svg width={width} height={height} role="img" aria-label={series.map((s) => s.label).join(', ')} className="block">
|
||||
{ticks.map((tk) => (
|
||||
<g key={tk}>
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(tk)} y2={y(tk)} stroke={GRID} strokeWidth={1} />
|
||||
<text x={pad.left - 6} y={y(tk) + 3} textAnchor="end" className="fill-ink font-mono text-[10px] opacity-60">
|
||||
{fr(tk)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{groups.map((g, gi) => {
|
||||
const x0 = pad.left + gi * band + (band - groupW) / 2;
|
||||
return (
|
||||
<g
|
||||
key={g.key}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => {
|
||||
setHover(gi);
|
||||
show(e, content(g));
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
setHover(gi);
|
||||
showAt(e.currentTarget, content(g));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setHover(null);
|
||||
hide();
|
||||
}}
|
||||
className="outline-none"
|
||||
>
|
||||
{/* Zone de survol : toute la bande du jour, pas seulement les barres. */}
|
||||
<rect
|
||||
x={pad.left + gi * band}
|
||||
y={pad.top}
|
||||
width={band}
|
||||
height={innerH}
|
||||
fill={hover === gi ? '#14110E' : 'transparent'}
|
||||
fillOpacity={hover === gi ? 0.04 : 0}
|
||||
/>
|
||||
{series.map((s, si) => (
|
||||
<path key={s.label} d={bar(x0 + si * (barW + 2), g.values[si])} fill={s.color} />
|
||||
))}
|
||||
<text x={pad.left + gi * band + band / 2} y={height - 10} textAnchor="middle" className="fill-ink font-mono text-[10.5px]">
|
||||
{g.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={pad.left} x2={width - pad.right} y1={y(0)} y2={y(0)} stroke="#14110E" strokeOpacity={0.4} strokeWidth={1} />
|
||||
</svg>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Répartition (partie d'un tout) — une barre, segments séparés par 2 px //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function ShareBar({
|
||||
segments,
|
||||
}: {
|
||||
segments: Array<{ label: string; value: number; color: string }>;
|
||||
}) {
|
||||
const { tip, show, showAt, hide } = useTip();
|
||||
const total = segments.reduce((t, s) => t + s.value, 0);
|
||||
const visible = segments.filter((s) => s.value > 0);
|
||||
const pct = (v: number) => (total ? Math.round((v / total) * 100) : 0);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-[22px] w-full gap-[2px]">
|
||||
{visible.map((s, i) => (
|
||||
<div
|
||||
key={s.label}
|
||||
tabIndex={0}
|
||||
onPointerMove={(e) => show(e, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
|
||||
onPointerLeave={hide}
|
||||
onFocus={(e) => showAt(e.currentTarget, <TipRow color={s.color} label={s.label} value={`${fr(s.value)} (${pct(s.value)} %)`} />)}
|
||||
onBlur={hide}
|
||||
className={cx(
|
||||
'h-full outline-none hover:brightness-110 focus-visible:brightness-110',
|
||||
i === 0 && 'rounded-l-[4px]',
|
||||
i === visible.length - 1 && 'rounded-r-[4px]',
|
||||
)}
|
||||
style={{ flexGrow: s.value, flexBasis: 0, background: s.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Legend items={segments.map((s) => ({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} />
|
||||
</div>
|
||||
<Tooltip tip={tip} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Jauge : une valeur rapportée à un maximum (piste = même teinte, plus claire) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export function Meter({
|
||||
value,
|
||||
max,
|
||||
label,
|
||||
extra,
|
||||
title,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
label: string;
|
||||
/** Complément court après le libellé (ex. « +2 »), dans une colonne fixe
|
||||
* pour que les jauges restent alignées d'une ligne à l'autre. */
|
||||
extra?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const ratio = max > 0 ? Math.min(1, value / max) : 0;
|
||||
return (
|
||||
<span className="flex items-center gap-[6px]" title={title ?? label}>
|
||||
<span className="relative block h-[8px] w-[72px] rounded-[4px]" style={{ background: '#cde2fb' }}>
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-[4px]"
|
||||
style={{ width: `${ratio * 100}%`, background: SERIES.blue }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-[64px] text-left font-mono text-[10.5px] font-bold">{label}</span>
|
||||
<span className="w-[26px] text-left font-mono text-[10px] opacity-60">{extra ?? ''}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -388,6 +388,68 @@
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
|
||||
"tab.forms": "Inscriptions",
|
||||
"tab.stats": "Statistiques",
|
||||
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
|
||||
"stats.kpi.collected": "Cotisations encaissées en ligne",
|
||||
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
|
||||
"stats.kpi.withAvailability": "Ont déclaré des disponibilités · sur {total}",
|
||||
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
|
||||
"stats.showTable": "Voir le tableau",
|
||||
"stats.unit.hours": "h",
|
||||
"stats.registrations.title": "Évolution des inscriptions",
|
||||
"stats.registrations.help": "Nombre cumulé de bénévoles en base, par jour d'ajout (formulaire public, import ou ajout à la main).",
|
||||
"stats.registrations.cumulative": "inscrits au total",
|
||||
"stats.registrations.daily": "nouveaux ce jour",
|
||||
"stats.answers.title": "Réponses",
|
||||
"stats.answers.help": "Part des bénévoles ayant renseigné chaque information (formulaire public ou fiche).",
|
||||
"stats.answers.email": "Email",
|
||||
"stats.answers.phone": "Téléphone",
|
||||
"stats.answers.available": "Case « disponible »",
|
||||
"stats.answers.positionWishes": "Vœux de poste",
|
||||
"stats.answers.artistWishes": "Vœux d'artiste",
|
||||
"stats.answers.companionWishes": "Vœux de compagnon",
|
||||
"stats.answers.companionNote": "Compagnon (texte libre)",
|
||||
"stats.answers.photo": "Photo",
|
||||
"stats.answers.comment": "Commentaire",
|
||||
"stats.artists.title": "Artistes que les bénévoles veulent voir",
|
||||
"stats.payments.title": "Cotisations",
|
||||
"stats.payments.help": "En ligne : paiement SumUp confirmé. Sur place : « je paierai sur place » ou case cotisation cochée sans paiement en ligne.",
|
||||
"stats.payments.online": "En ligne",
|
||||
"stats.payments.onSite": "Sur place",
|
||||
"stats.payments.none": "Aucune",
|
||||
"stats.payments.byOption": "Paiements en ligne par type",
|
||||
"stats.payments.noOnline": "Aucun paiement en ligne pour l'instant.",
|
||||
"stats.payments.mecenatTag": "mécénat",
|
||||
"stats.payments.total": "Total",
|
||||
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
|
||||
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
|
||||
"stats.needs.title": "Besoins et bénévoles",
|
||||
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures de disponibilité déclarées et aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
|
||||
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
|
||||
"stats.needs.byDay": "Par jour (heures × personnes)",
|
||||
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
|
||||
"stats.needs.min": "Besoin minimal",
|
||||
"stats.needs.ideal": "Besoin idéal",
|
||||
"stats.needs.available": "Disponibilités déclarées",
|
||||
"stats.needs.assigned": "Déjà affecté",
|
||||
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
|
||||
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
|
||||
"stats.col.date": "Date",
|
||||
"stats.col.day": "Jour",
|
||||
"stats.col.answer": "Information",
|
||||
"stats.col.volunteers": "Bénévoles",
|
||||
"stats.col.share": "Part",
|
||||
"stats.col.artist": "Artiste",
|
||||
"stats.col.paymentType": "Type de paiement",
|
||||
"stats.col.amount": "Montant",
|
||||
"stats.col.shifts": "Créneaux",
|
||||
"stats.col.availableVolunteers": "Bénévoles dispo.",
|
||||
"stats.col.position": "Poste",
|
||||
"stats.col.minSlots": "Places min.",
|
||||
"stats.col.idealSlots": "Places idéales",
|
||||
"stats.col.filled": "Pourvues",
|
||||
"stats.col.wishes": "Vœux",
|
||||
"stats.col.wishesPerSlot": "Vœux / place",
|
||||
"form.field.email": "Email",
|
||||
"form.field.phone": "Téléphone",
|
||||
"form.field.positionWishes": "Vœux de poste",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useStats } from '@/api/hooks';
|
||||
import type { EditionStats } from '@/api/schema';
|
||||
import {
|
||||
BarList,
|
||||
CumulativeLineChart,
|
||||
DataTable,
|
||||
GroupedColumns,
|
||||
Legend,
|
||||
Meter,
|
||||
SERIES,
|
||||
ShareBar,
|
||||
TableToggle,
|
||||
} from '@/components/charts';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { foldAccents } from '@/lib/planning';
|
||||
import { longDayLabel, shortDayLabel } from '@/lib/time';
|
||||
|
||||
const fr = (n: number, digits = 0) =>
|
||||
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
|
||||
const euros = (n: number) =>
|
||||
n.toLocaleString('fr-FR', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 });
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0);
|
||||
/** Ex. « 12 sept. » — dates d'inscription (pas des jours de festival). */
|
||||
const calendarDay = (date: string) =>
|
||||
new Date(`${date}T12:00:00`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
|
||||
/**
|
||||
* Statistiques d'une édition (COORDINATION) : inscriptions (nombre,
|
||||
* évolution), réponses au formulaire, cotisations, et besoins (postes ×
|
||||
* créneaux) face aux bénévoles qui ont répondu — disponibilités déclarées,
|
||||
* vœux de poste, affectations. Tout est calculé côté serveur
|
||||
* (`app/api/stats.py`), par journée de festival comme la grille.
|
||||
*/
|
||||
export function StatsPage() {
|
||||
const stats = useStats();
|
||||
if (stats.isLoading) return <Loading />;
|
||||
if (stats.error) return <ErrorNote error={stats.error} />;
|
||||
if (!stats.data) return null;
|
||||
const s = stats.data;
|
||||
|
||||
const idealSlots = s.needs.by_position.reduce((t2, p) => t2 + p.ideal_slots, 0);
|
||||
const filled = s.needs.by_position.reduce((t2, p) => t2 + p.filled, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1180px] flex-col gap-5 p-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
tone="yellow"
|
||||
value={fr(s.registrations.total)}
|
||||
label={t('stats.kpi.registered', { count: s.registrations.last_7_days })}
|
||||
/>
|
||||
<StatCard value={euros(s.payments.total_collected)} label={t('stats.kpi.collected')} />
|
||||
<StatCard
|
||||
value={`${pct(filled, idealSlots)} %`}
|
||||
label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })}
|
||||
/>
|
||||
<StatCard
|
||||
value={fr(s.needs.volunteers_with_availability)}
|
||||
label={t('stats.kpi.withAvailability', { total: fr(s.registrations.total) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{s.registrations.total === 0 ? (
|
||||
<Panel>
|
||||
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<RegistrationsSection stats={s} />
|
||||
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
|
||||
<AnswersSection stats={s} />
|
||||
<PaymentsSection stats={s} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<NeedsSection stats={s} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, help, children }: { title: string; help?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Panel className="min-w-0">
|
||||
<PanelHeader className="flex items-center gap-[6px]">
|
||||
{title}
|
||||
{help ? <HelpTip text={help} /> : null}
|
||||
</PanelHeader>
|
||||
<div className="flex min-w-0 flex-col gap-3 p-4">{children}</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SubTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="font-display text-[11px] font-extrabold uppercase tracking-wide">{children}</h3>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistrationsSection({ stats }: { stats: EditionStats }) {
|
||||
const points = stats.registrations.by_day;
|
||||
return (
|
||||
<Section title={t('stats.registrations.title')} help={t('stats.registrations.help')}>
|
||||
<CumulativeLineChart
|
||||
points={points}
|
||||
dateLabel={calendarDay}
|
||||
valueLabel={t('stats.registrations.cumulative')}
|
||||
dailyLabel={t('stats.registrations.daily')}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.date'), t('stats.registrations.daily'), t('stats.registrations.cumulative')]}
|
||||
rows={points
|
||||
.filter((p) => p.count > 0)
|
||||
.map((p) => [calendarDay(p.date), fr(p.count), fr(p.cumulative)])}
|
||||
/>
|
||||
</TableToggle>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function AnswersSection({ stats }: { stats: EditionStats }) {
|
||||
const a = stats.answers;
|
||||
const fields: Array<[string, number]> = [
|
||||
['email', a.email],
|
||||
['phone', a.phone],
|
||||
['available', a.available],
|
||||
['positionWishes', a.position_wishes],
|
||||
['artistWishes', a.artist_wishes],
|
||||
['companionWishes', a.companion_wishes],
|
||||
['companionNote', a.companion_note],
|
||||
['photo', a.photo],
|
||||
['comment', a.comment],
|
||||
];
|
||||
const rows = fields.map(([key, count]) => ({
|
||||
key,
|
||||
label: t(`stats.answers.${key}`),
|
||||
value: pct(count, a.total),
|
||||
valueLabel: `${pct(count, a.total)} %`,
|
||||
tip: `${fr(count)} / ${fr(a.total)} (${pct(count, a.total)} %)`,
|
||||
}));
|
||||
const artists = [...stats.artist_wishes].sort((x, y) => y.count - x.count);
|
||||
return (
|
||||
<Section title={t('stats.answers.title')} help={t('stats.answers.help')}>
|
||||
<BarList rows={rows} max={100} />
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.answer'), t('stats.col.volunteers'), t('stats.col.share')]}
|
||||
rows={fields.map(([key, count]) => [t(`stats.answers.${key}`), fr(count), `${pct(count, a.total)} %`])}
|
||||
/>
|
||||
</TableToggle>
|
||||
|
||||
{artists.length > 0 ? (
|
||||
<>
|
||||
<SubTitle>{t('stats.artists.title')}</SubTitle>
|
||||
<BarList
|
||||
rows={artists.map((ar) => ({
|
||||
key: ar.id,
|
||||
label: `${ar.name} · ${shortDayLabel(ar.date)}`,
|
||||
value: ar.count,
|
||||
valueLabel: fr(ar.count),
|
||||
}))}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[t('stats.col.artist'), t('stats.col.day'), t('stats.col.volunteers')]}
|
||||
rows={artists.map((ar) => [ar.name, longDayLabel(ar.date), fr(ar.count)])}
|
||||
/>
|
||||
</TableToggle>
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentsSection({ stats }: { stats: EditionStats }) {
|
||||
const p = stats.payments;
|
||||
return (
|
||||
<Section title={t('stats.payments.title')} help={t('stats.payments.help')}>
|
||||
<ShareBar
|
||||
segments={[
|
||||
{ label: t('stats.payments.online'), value: p.online, color: SERIES.blue },
|
||||
{ label: t('stats.payments.onSite'), value: p.on_site, color: SERIES.orange },
|
||||
{ label: t('stats.payments.none'), value: p.none, color: SERIES.muted },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SubTitle>{t('stats.payments.byOption')}</SubTitle>
|
||||
{p.by_option.length === 0 ? (
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.payments.noOnline')}</p>
|
||||
) : (
|
||||
<DataTable
|
||||
head={[t('stats.col.paymentType'), t('stats.col.volunteers'), t('stats.col.amount')]}
|
||||
rows={[
|
||||
...p.by_option.map((o) => [
|
||||
// « mécénat » seulement si le libellé ne le dit pas déjà.
|
||||
o.mecenat && !foldAccents(o.label).toLowerCase().includes('mecenat')
|
||||
? `${o.label} · ${t('stats.payments.mecenatTag')}`
|
||||
: o.label,
|
||||
fr(o.count),
|
||||
euros(o.amount_total),
|
||||
]),
|
||||
[<b key="t">{t('stats.payments.total')}</b>, <b key="c">{fr(p.online)}</b>, <b key="a">{euros(p.total_collected)}</b>],
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{p.mecenat_count > 0 ? (
|
||||
<p className="font-mono text-[11px]">
|
||||
{t('stats.payments.mecenat', { count: fr(p.mecenat_count), amount: euros(p.mecenat_total) })}
|
||||
</p>
|
||||
) : null}
|
||||
{p.pending_unfinished > 0 ? (
|
||||
<p className="border-2 border-ink bg-yellow-soft p-[8px] font-mono text-[11px] leading-relaxed">
|
||||
{t('stats.payments.unfinished', { count: fr(p.pending_unfinished) })}
|
||||
</p>
|
||||
) : null}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function NeedsSection({ stats }: { stats: EditionStats }) {
|
||||
const n = stats.needs;
|
||||
if (n.by_day.length === 0 && n.by_position.every((p) => p.shifts === 0)) {
|
||||
return (
|
||||
<Section title={t('stats.needs.title')}>
|
||||
<p className="font-mono text-[11px] opacity-60">{t('stats.needs.empty')}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
const series = [
|
||||
{ label: t('stats.needs.ideal'), color: SERIES.blue },
|
||||
{ label: t('stats.needs.available'), color: SERIES.orange },
|
||||
{ label: t('stats.needs.assigned'), color: SERIES.aqua },
|
||||
];
|
||||
return (
|
||||
<Section title={t('stats.needs.title')} help={t('stats.needs.help')}>
|
||||
<SubTitle>{t('stats.needs.byDay')}</SubTitle>
|
||||
<Legend items={series} />
|
||||
<GroupedColumns
|
||||
unit={t('stats.unit.hours')}
|
||||
series={series}
|
||||
groups={n.by_day.map((d) => ({
|
||||
key: d.date,
|
||||
label: shortDayLabel(d.date),
|
||||
values: [d.ideal_hours, d.available_hours, d.assigned_hours],
|
||||
}))}
|
||||
/>
|
||||
<TableToggle label={t('stats.showTable')}>
|
||||
<DataTable
|
||||
head={[
|
||||
t('stats.col.day'),
|
||||
t('stats.col.shifts'),
|
||||
t('stats.needs.min'),
|
||||
t('stats.needs.ideal'),
|
||||
t('stats.needs.available'),
|
||||
t('stats.col.availableVolunteers'),
|
||||
t('stats.needs.assigned'),
|
||||
]}
|
||||
rows={n.by_day.map((d) => [
|
||||
longDayLabel(d.date),
|
||||
fr(d.shifts),
|
||||
fr(d.min_hours, 1),
|
||||
fr(d.ideal_hours, 1),
|
||||
fr(d.available_hours, 1),
|
||||
fr(d.available_volunteers),
|
||||
fr(d.assigned_hours, 1),
|
||||
])}
|
||||
/>
|
||||
</TableToggle>
|
||||
|
||||
<SubTitle>{t('stats.needs.byPosition')}</SubTitle>
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
head={[
|
||||
t('stats.col.position'),
|
||||
t('stats.col.shifts'),
|
||||
t('stats.col.minSlots'),
|
||||
t('stats.col.idealSlots'),
|
||||
t('stats.col.filled'),
|
||||
t('stats.col.wishes'),
|
||||
t('stats.col.wishesPerSlot'),
|
||||
]}
|
||||
rows={n.by_position
|
||||
.filter((p) => p.shifts > 0 || p.wishes > 0)
|
||||
.map((p) => [
|
||||
p.name,
|
||||
fr(p.shifts),
|
||||
fr(p.min_slots),
|
||||
fr(p.ideal_slots),
|
||||
<span key="m" className="flex justify-end">
|
||||
<Meter
|
||||
value={p.filled}
|
||||
max={p.ideal_slots}
|
||||
label={`${fr(p.filled)} / ${fr(p.ideal_slots)}`}
|
||||
extra={p.assigned > p.filled ? `+${fr(p.assigned - p.filled)}` : undefined}
|
||||
title={
|
||||
p.assigned > p.filled
|
||||
? t('stats.needs.filledWithExtra', {
|
||||
filled: fr(p.filled),
|
||||
ideal: fr(p.ideal_slots),
|
||||
extra: fr(p.assigned - p.filled),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>,
|
||||
fr(p.wishes),
|
||||
p.ideal_slots > 0 ? fr(p.wishes / p.ideal_slots, 2) : '—',
|
||||
])}
|
||||
/>
|
||||
</div>
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('stats.needs.wishesHint')}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user