É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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user