diff --git a/CLAUDE.md b/CLAUDE.md index 1a17505..0dcdb08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 + diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 6936f86..b626d57 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/stats.py b/backend/app/api/stats.py new file mode 100644 index 0000000..56520fa --- /dev/null +++ b/backend/app/api/stats.py @@ -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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 964d0a3..414a40d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/tests/test_api_stats.py b/backend/tests/test_api_stats.py new file mode 100644 index 0000000..091ab1a --- /dev/null +++ b/backend/tests/test_api_stats.py @@ -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 diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index fa788a2..c0ce3ff 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -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; diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 77f46e2..1a29de2 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 7368df3..bd89b4e 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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) => { diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index ec7d1c2..b85a645 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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]; diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 5daa99b..88d4729 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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(); + 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(); + 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; assigned: number }; + const rows = new Map(); + 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(); + 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(); + 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(); for (const a of state.assignments) { @@ -1515,6 +1656,10 @@ export function createMockApi(): PlanningApi { get: () => delay(buildMatrix()), }, + stats: { + get: () => delay(buildStats()), + }, + badges: { getTemplate: (): Promise => delay({ diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 2b369dc..9a411f3 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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; + }; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5154a14..af09b29 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -58,6 +58,7 @@ import type { VolunteerPlanningEntry, VolunteerRead, VolunteerUpdate, + EditionStats, } from './schema'; /** @@ -156,6 +157,10 @@ export interface PlanningApi { matrix: { get(editionId: string): Promise; }; + stats: { + /** Statistiques de l'édition (COORDINATION uniquement). */ + get(editionId: string): Promise; + }; badges: { /** Configuration du recto/verso (COORDINATION uniquement). */ getTemplate(editionId: string): Promise; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 81d72d8..b87a2e5 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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: }, { path: 'badges', element: }, { path: 'inscriptions', element: }, + { path: 'statistiques', element: }, ], }, ], diff --git a/frontend/src/auth/roles.ts b/frontend/src/auth/roles.ts index 0e5ea7c..b6a6e39 100644 --- a/frontend/src/auth/roles.ts +++ b/frontend/src/auth/roles.ts @@ -11,6 +11,7 @@ export const ROUTE_ROLES: Record = { '/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 { diff --git a/frontend/src/components/charts.tsx b/frontend/src/components/charts.tsx new file mode 100644 index 0000000..84ae231 --- /dev/null +++ b/frontend/src/components/charts.tsx @@ -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(fallback = 640) { + const ref = useRef(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(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(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 ( +
+ {tip.content} +
+ ); +} + +/** 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 ( +
+ {color ? : null} + {value} + {label} +
+ ); +} + +// --------------------------------------------------------------------------- // +// Légende, vue tableau // +// --------------------------------------------------------------------------- // +export function Legend({ items }: { items: Array<{ label: string; color: string; value?: string }> }) { + return ( +
+ {items.map((item) => ( + + + {item.label} + {item.value ? {item.value} : null} + + ))} +
+ ); +} + +/** Équivalent tableau d'un graphique — toujours disponible, replié. */ +export function TableToggle({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export function DataTable({ + head, + rows, +}: { + head: string[]; + rows: Array>; +}) { + return ( + + + + {head.map((h, i) => ( + + ))} + + + + {rows.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + + ))} + +
+ {h} +
+ {cell} +
+ ); +} + +// --------------------------------------------------------------------------- // +// 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(); + const { tip, show, hide } = useTip(); + const [hover, setHover] = useState(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) => ( + <> +
{dateLabel(points[i].date)}
+ + + + ); + + return ( +
+ { + 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) => ( + + + + {fr(tk)} + + + ))} + {points.map((p, i) => + labelled.has(i) ? ( + + {dateLabel(p.date)} + + ) : null, + )} + + + {hover !== null ? ( + <> + + + + ) : null} + {last ? ( + <> + + + {fr(last.cumulative)} + + + ) : null} + + +
+ ); +} + +// --------------------------------------------------------------------------- // +// 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 ( +
+ {rows.map((r) => ( +
show(e, )} + onPointerLeave={hide} + onFocus={(e) => showAt(e.currentTarget, )} + onBlur={hide} + className="grid items-center gap-[8px] outline-none hover:bg-cream focus-visible:bg-cream" + style={{ gridTemplateColumns: 'minmax(90px, 34%) 1fr' }} + > + + {r.label} + + + 0 ? 1.5 : 0, (r.value / scale) * 82)}%`, + background: color, + }} + /> + {r.valueLabel} + +
+ ))} + +
+ ); +} + +// --------------------------------------------------------------------------- // +// 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(); + const { tip, show, hide, showAt } = useTip(); + const [hover, setHover] = useState(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]) => ( + <> +
{g.label}
+ {series.map((s, i) => ( + + ))} + + ); + // 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 ( +
+ s.label).join(', ')} className="block"> + {ticks.map((tk) => ( + + + + {fr(tk)} + + + ))} + {groups.map((g, gi) => { + const x0 = pad.left + gi * band + (band - groupW) / 2; + return ( + { + 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. */} + + {series.map((s, si) => ( + + ))} + + {g.label} + + + ); + })} + + + +
+ ); +} + +// --------------------------------------------------------------------------- // +// 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 ( +
+
+ {visible.map((s, i) => ( +
show(e, )} + onPointerLeave={hide} + onFocus={(e) => showAt(e.currentTarget, )} + 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 }} + /> + ))} +
+
+ ({ label: s.label, color: s.color, value: `${fr(s.value)} · ${pct(s.value)} %` }))} /> +
+ +
+ ); +} + +// --------------------------------------------------------------------------- // +// 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 ( + + + + + {label} + {extra ?? ''} + + ); +} diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 9ec4fb9..ce8bd97 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx new file mode 100644 index 0000000..7627108 --- /dev/null +++ b/frontend/src/pages/StatsPage.tsx @@ -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 ; + if (stats.error) return ; + 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 ( +
+
+ + + + +
+ + {s.registrations.total === 0 ? ( + +

{t('stats.empty')}

+
+ ) : ( + <> + +
+ + +
+ + )} + +
+ ); +} + +function Section({ title, help, children }: { title: string; help?: string; children: React.ReactNode }) { + return ( + + + {title} + {help ? : null} + +
{children}
+
+ ); +} + +function SubTitle({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +function RegistrationsSection({ stats }: { stats: EditionStats }) { + const points = stats.registrations.by_day; + return ( +
+ + + p.count > 0) + .map((p) => [calendarDay(p.date), fr(p.count), fr(p.cumulative)])} + /> + +
+ ); +} + +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 ( +
+ + + [t(`stats.answers.${key}`), fr(count), `${pct(count, a.total)} %`])} + /> + + + {artists.length > 0 ? ( + <> + {t('stats.artists.title')} + ({ + key: ar.id, + label: `${ar.name} · ${shortDayLabel(ar.date)}`, + value: ar.count, + valueLabel: fr(ar.count), + }))} + /> + + [ar.name, longDayLabel(ar.date), fr(ar.count)])} + /> + + + ) : null} +
+ ); +} + +function PaymentsSection({ stats }: { stats: EditionStats }) { + const p = stats.payments; + return ( +
+ + + {t('stats.payments.byOption')} + {p.by_option.length === 0 ? ( +

{t('stats.payments.noOnline')}

+ ) : ( + [ + // « 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), + ]), + [{t('stats.payments.total')}, {fr(p.online)}, {euros(p.total_collected)}], + ]} + /> + )} + {p.mecenat_count > 0 ? ( +

+ {t('stats.payments.mecenat', { count: fr(p.mecenat_count), amount: euros(p.mecenat_total) })} +

+ ) : null} + {p.pending_unfinished > 0 ? ( +

+ {t('stats.payments.unfinished', { count: fr(p.pending_unfinished) })} +

+ ) : null} +
+ ); +} + +function NeedsSection({ stats }: { stats: EditionStats }) { + const n = stats.needs; + if (n.by_day.length === 0 && n.by_position.every((p) => p.shifts === 0)) { + return ( +
+

{t('stats.needs.empty')}

+
+ ); + } + 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 ( +
+ {t('stats.needs.byDay')} + + ({ + key: d.date, + label: shortDayLabel(d.date), + values: [d.ideal_hours, d.available_hours, d.assigned_hours], + }))} + /> + + [ + 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), + ])} + /> + + + {t('stats.needs.byPosition')} +
+ p.shifts > 0 || p.wishes > 0) + .map((p) => [ + p.name, + fr(p.shifts), + fr(p.min_slots), + fr(p.ideal_slots), + + 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 + } + /> + , + fr(p.wishes), + p.ideal_slots > 0 ? fr(p.wishes / p.ideal_slots, 2) : '—', + ])} + /> +
+

{t('stats.needs.wishesHint')}

+
+ ); +}