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

+63 -1
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net/mail"
"sort"
"strconv"
"strings"
"time"
@@ -701,22 +702,83 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
if err != nil {
return domain.StaffMetricsResponse{}, err
}
rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
loc, err := time.LoadLocation(membership.Tenant.Timezone)
if err != nil {
loc = time.UTC
}
capacity := staffCapacityMinutes(rules, from, to, loc)
metrics := make([]domain.StaffMetric, 0, len(staff))
for _, member := range staff {
rec := byStaff[member.ID]
capMinutes := capacity[member.ID]
utilization := 0
if capMinutes > 0 {
utilization = int(float64(rec.BookedMinutes) / float64(capMinutes) * 100)
}
metrics = append(metrics, domain.StaffMetric{
StaffID: member.ID,
DisplayName: member.DisplayName,
Bookings: rec.BookingsCount,
Completed: rec.CompletedCount,
Cancelled: rec.CancellationsCount,
UtilizationPercent: rec.UtilizationPercent,
BookedMinutes: rec.BookedMinutes,
CapacityMinutes: capMinutes,
UtilizationPercent: utilization,
RevenueCents: rec.RevenueCents,
})
}
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
}
// staffCapacityMinutes sums each member's availability-rule minutes across the
// window, evaluated on local calendar dates in the tenant timezone. Rules
// without a staff assignment are tenant-wide and don't map to a member.
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, from, to time.Time, loc *time.Location) map[string]int {
byStaff := map[string]int{}
for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) {
weekday := int(day.Weekday())
for _, rule := range rules {
if rule.StaffID == nil || rule.DayOfWeek != weekday {
continue
}
byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal)
}
}
return byStaff
}
// localMinutesBetween returns the minutes between two "HH:MM[:SS]" local times,
// or zero when the range is empty or malformed.
func localMinutesBetween(starts, ends string) int {
start := parseLocalMinutes(starts)
end := parseLocalMinutes(ends)
if end <= start {
return 0
}
return end - start
}
func parseLocalMinutes(hhmm string) int {
parts := strings.SplitN(hhmm, ":", 3)
if len(parts) < 2 {
return 0
}
hours, err := strconv.Atoi(parts[0])
if err != nil {
return 0
}
minutes, err := strconv.Atoi(parts[1])
if err != nil {
return 0
}
return hours*60 + minutes
}
// setupCompletion scores onboarding readiness across five equal-weight checks:
// brand profile, location, service catalog, staff roster, and schedule rules.
func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int {