mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat: data surfacing — intake labels, service metrics, honest capacity
- booking views gain intakeLabels (declared key→label join); dashboard modal + manage view render real question labels with slug fallback - GET /v1/metrics/services aggregates bookings/completed/cancelled/ revenue per service + classBookings; service-report.csv delegates to the same ServiceMetrics aggregation and gains a completed column - services page gains a Service performance card (per-service stat grid, locale CZK revenue, class-bookings footer); demo rows match the fabricated service list - staff capacity utilization subtracts blocked days — tenant-wide and staff-scoped blocks remove that day's rule minutes via blockedAt - tests: blocked-day capacity (tenant + staff scope), service aggregation Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
34becc1bf4
commit
5c860a1264
16 files changed
+560
-69
No files matched your search
@@ -216,56 +216,15 @@ func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Prin
|
||||
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.
|
||||
// WriteServiceReportCSV exports the per-service performance metrics over a
|
||||
// trailing window — the same aggregation the dashboard services panel shows.
|
||||
func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
|
||||
tenant, err := s.tenantFor(ctx, principal)
|
||||
metrics, err := s.bookings.ServiceMetrics(ctx, principal, days)
|
||||
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 errors.Is(err, bookings.ErrTenantMembership) {
|
||||
return ErrTenantMembership
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := w.Write(utf8BOM); err != nil {
|
||||
@@ -274,25 +233,22 @@ func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Pr
|
||||
out := csv.NewWriter(w)
|
||||
defer out.Flush()
|
||||
|
||||
if err := out.Write([]string{"service", "bookings", "cancelled", "revenue_cents"}); err != nil {
|
||||
if err := out.Write([]string{"service", "bookings", "completed", "cancelled", "revenue_cents"}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, service := range services {
|
||||
agg := byService[service.ID]
|
||||
if agg == nil {
|
||||
agg = &aggregate{}
|
||||
}
|
||||
for _, m := range metrics.Services {
|
||||
if err := out.Write([]string{
|
||||
service.Name,
|
||||
strconv.Itoa(agg.bookings),
|
||||
strconv.Itoa(agg.cancelled),
|
||||
strconv.FormatInt(agg.revenue, 10),
|
||||
m.Name,
|
||||
strconv.Itoa(m.Bookings),
|
||||
strconv.Itoa(m.Completed),
|
||||
strconv.Itoa(m.Cancelled),
|
||||
strconv.FormatInt(m.RevenueCents, 10),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if classBookings > 0 {
|
||||
if err := out.Write([]string{"class sessions", strconv.Itoa(classBookings), "0", "0"}); err != nil {
|
||||
if metrics.ClassBookings > 0 {
|
||||
if err := out.Write([]string{"class sessions", strconv.Itoa(metrics.ClassBookings), "0", "0", "0"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ func TestWriteServiceReportCSV(t *testing.T) {
|
||||
t.Fatalf("expected header + service row + class row, got %d rows", len(records))
|
||||
}
|
||||
svcRow := records[1]
|
||||
if svcRow[0] != "Signature treatment" || svcRow[1] != "2" || svcRow[2] != "1" || svcRow[3] != "120000" {
|
||||
if svcRow[0] != "Signature treatment" || svcRow[1] != "2" || svcRow[2] != "0" || svcRow[3] != "1" || svcRow[4] != "120000" {
|
||||
t.Fatalf("unexpected service row: %v", svcRow)
|
||||
}
|
||||
classRow := records[2]
|
||||
|
||||
Reference in new issue
Block a user