feat(reports): capacity-based utilization + report CSVs

Utilization now measures booked minutes against each member's
availability-rule capacity in the tenant timezone, replacing the
confirmed-share proxy. StaffMetric exposes booked/capacity minutes.

New endpoints: GET /v1/export/staff-report.csv (reuses StaffMetrics)
and GET /v1/export/service-report.csv (per-service bookings/cancelled/
revenue, class bookings rolled up). Export buttons on the staff
performance panel and services page.

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 17:10:15 +02:00
1 parent 2464aa45fa
commit 7b9080eb1c
13 files changed
+541 -33

No files matched your search

+6 -9
View File
@@ -315,7 +315,7 @@ type StaffMetricsRecord struct {
BookingsCount int
CancellationsCount int
CompletedCount int
UtilizationPercent int
BookedMinutes int
RevenueCents int64
}
@@ -1806,22 +1806,19 @@ func (r *MemoryRepository) GetStaffMetrics(_ context.Context, tenantID string, s
case booking.Status == "cancelled":
rec.CancellationsCount++
case booking.Status == "confirmed":
rec.UtilizationPercent++
if booking.EndsAt.Before(time.Now().UTC()) {
rec.CompletedCount++
}
}
if booking.Status != "cancelled" && booking.ServiceID != nil {
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
if booking.Status != "cancelled" {
rec.BookedMinutes += int(booking.EndsAt.Sub(booking.StartsAt).Minutes())
if booking.ServiceID != nil {
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
}
}
}
records := make([]StaffMetricsRecord, 0, len(byStaff))
for _, rec := range byStaff {
if rec.BookingsCount == 0 {
rec.UtilizationPercent = 0
} else {
rec.UtilizationPercent = int(float64(rec.UtilizationPercent) / float64(rec.BookingsCount) * 100)
}
records = append(records, *rec)
}
sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount })