feat(metrics): per-staff performance view in dashboard

GET /v1/metrics/staff returns bookings, completed, cancelled,
utilization, and revenue per staff member over a trailing window
(default 30d, max 365d). Appointment bookings only — class sessions
have no staff assignment. All active staff appear, including
zero-booking rows.

Staff page gains a localized Performance panel; demo mode seeds
trailing-window bookings and matching metric rows.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 16:34:45 +02:00
1 parent 4674203502
commit 76e45d4178
11 files changed
+496 -3

No files matched your search

+38
View File
@@ -251,6 +251,44 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
return metrics, err
}
// GetStaffMetrics mirrors GetDashboardMetrics grouped by staff_id. Class
// bookings have no staff_id and fall out naturally; revenue sums the linked
// service price for non-cancelled bookings.
func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT
b.staff_id,
COUNT(*) AS bookings_count,
COUNT(*) FILTER (WHERE b.status = 'cancelled') AS cancellations_count,
COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count,
COALESCE(
ROUND(100.0 * COUNT(*) FILTER (WHERE b.status = 'confirmed') / NULLIF(COUNT(*), 0)),
0
)::integer AS utilization_percent,
COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
WHERE b.tenant_id = $1 AND b.staff_id IS NOT NULL AND b.starts_at >= $2 AND b.starts_at < $3
GROUP BY b.staff_id
ORDER BY bookings_count DESC
`, tenantID, startsAt, endsAt)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffMetricsRecord
for rows.Next() {
var rec StaffMetricsRecord
if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount,
&rec.CompletedCount, &rec.UtilizationPercent, &rec.RevenueCents); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
var position int
err := r.pool.QueryRow(ctx, `