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

+129 -3
View File
@@ -5,8 +5,10 @@ import (
"encoding/csv"
"errors"
"io"
"strconv"
"time"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
@@ -19,11 +21,12 @@ var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
const customerPageSize = 500
type Service struct {
repo db.Repository
repo db.Repository
bookings *bookings.Service
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo}
func NewService(repo db.Repository, bookingsService *bookings.Service) *Service {
return &Service{repo: repo, bookings: bookingsService}
}
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
@@ -172,3 +175,126 @@ func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Princi
}
return out.Error()
}
// WriteStaffReportCSV exports the per-staff performance metrics over a
// trailing window — the same numbers the dashboard performance panel shows.
func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
metrics, err := s.bookings.StaffMetrics(ctx, principal, days)
if err != nil {
if errors.Is(err, bookings.ErrTenantMembership) {
return ErrTenantMembership
}
return err
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{
"staff", "bookings", "completed", "cancelled",
"booked_minutes", "capacity_minutes", "utilization_percent", "revenue_cents",
}); err != nil {
return err
}
for _, m := range metrics.Staff {
if err := out.Write([]string{
m.DisplayName,
strconv.Itoa(m.Bookings),
strconv.Itoa(m.Completed),
strconv.Itoa(m.Cancelled),
strconv.Itoa(m.BookedMinutes),
strconv.Itoa(m.CapacityMinutes),
strconv.Itoa(m.UtilizationPercent),
strconv.FormatInt(m.RevenueCents, 10),
}); err != nil {
return err
}
}
return out.Error()
}
// WriteServiceReportCSV aggregates appointment bookings per catalog service
// over a trailing window. Class bookings have no service or price and roll
// into a final "class sessions" row when present.
func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
tenant, err := s.tenantFor(ctx, principal)
if err != nil {
return err
}
if days <= 0 || days > 365 {
days = 30
}
to := time.Now().UTC()
from := to.AddDate(0, 0, -days)
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, from, to)
if err != nil {
return err
}
services, err := s.repo.ListServicesByTenant(ctx, tenant.ID)
if err != nil {
return err
}
prices := make(map[string]int, len(services))
for _, service := range services {
prices[service.ID] = service.PriceCents
}
type aggregate struct {
bookings int
cancelled int
revenue int64
}
byService := make(map[string]*aggregate, len(services))
classBookings := 0
for _, booking := range bookingsList {
if booking.ServiceID == nil {
classBookings++
continue
}
agg := byService[*booking.ServiceID]
if agg == nil {
agg = &aggregate{}
byService[*booking.ServiceID] = agg
}
agg.bookings++
if booking.Status == "cancelled" {
agg.cancelled++
} else {
agg.revenue += int64(prices[*booking.ServiceID])
}
}
if _, err := w.Write(utf8BOM); err != nil {
return err
}
out := csv.NewWriter(w)
defer out.Flush()
if err := out.Write([]string{"service", "bookings", "cancelled", "revenue_cents"}); err != nil {
return err
}
for _, service := range services {
agg := byService[service.ID]
if agg == nil {
agg = &aggregate{}
}
if err := out.Write([]string{
service.Name,
strconv.Itoa(agg.bookings),
strconv.Itoa(agg.cancelled),
strconv.FormatInt(agg.revenue, 10),
}); err != nil {
return err
}
}
if classBookings > 0 {
if err := out.Write([]string{"class sessions", strconv.Itoa(classBookings), "0", "0"}); err != nil {
return err
}
}
return out.Error()
}