mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +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
@@ -1369,6 +1369,20 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
protected.GET("/metrics/services", func(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
|
||||
response, err := bookingService.ServiceMetrics(c.Request.Context(), auth.PrincipalFromContext(c), days)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, bookings.ErrTenantMembership) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
protected.GET("/export/bookings.csv", func(c *gin.Context) {
|
||||
from, to := exportRange(c)
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -98,6 +98,19 @@ func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRec
|
||||
return serviceName, locationName
|
||||
}
|
||||
|
||||
// intakeLabels returns the booking's service-declared intake field labels,
|
||||
// keyed by answer key; nil for class bookings or services without intake.
|
||||
func (s *CustomerService) intakeLabels(ctx context.Context, booking db.BookingRecord) map[string]string {
|
||||
if booking.ServiceID == nil {
|
||||
return nil
|
||||
}
|
||||
service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return shared.IntakeLabels(service.IntakeFields)
|
||||
}
|
||||
|
||||
// GetBookingByReference returns booking details for customer management link
|
||||
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
|
||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||
@@ -136,6 +149,7 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
||||
Status: booking.Status,
|
||||
Notes: booking.Notes,
|
||||
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||
IntakeLabels: s.intakeLabels(ctx, booking),
|
||||
WaitlistPosition: position,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -548,9 +548,11 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
}
|
||||
|
||||
serviceNames := map[string]string{}
|
||||
serviceIntakeLabels := map[string]map[string]string{}
|
||||
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
|
||||
for _, service := range services {
|
||||
serviceNames[service.ID] = service.Name
|
||||
serviceIntakeLabels[service.ID] = shared.IntakeLabels(service.IntakeFields)
|
||||
}
|
||||
}
|
||||
locationNames := map[string]string{}
|
||||
@@ -580,6 +582,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
}
|
||||
if booking.ServiceID != nil {
|
||||
out.Service = serviceNames[*booking.ServiceID]
|
||||
out.IntakeLabels = serviceIntakeLabels[*booking.ServiceID]
|
||||
}
|
||||
if booking.LocationID != nil {
|
||||
out.Location = locationNames[*booking.LocationID]
|
||||
@@ -719,11 +722,15 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
||||
if err != nil {
|
||||
return domain.StaffMetricsResponse{}, err
|
||||
}
|
||||
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
||||
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)
|
||||
capacity := staffCapacityMinutes(rules, blockedDays, from, to, loc)
|
||||
|
||||
metrics := make([]domain.StaffMetric, 0, len(staff))
|
||||
for _, member := range staff {
|
||||
@@ -748,17 +755,105 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
||||
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
|
||||
}
|
||||
|
||||
// ServiceMetrics aggregates appointment bookings per catalog service over a
|
||||
// trailing window — every service appears, even with zero bookings. Class
|
||||
// bookings carry no service and are counted separately.
|
||||
func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal, days int) (domain.ServiceMetricsResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ServiceMetricsResponse{}, ErrTenantMembership
|
||||
}
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
if days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
to := time.Now().UTC()
|
||||
from := to.AddDate(0, 0, -days)
|
||||
|
||||
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to)
|
||||
if err != nil {
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
prices := make(map[string]int, len(services))
|
||||
for _, service := range services {
|
||||
prices[service.ID] = service.PriceCents
|
||||
}
|
||||
|
||||
type aggregate struct {
|
||||
bookings int
|
||||
completed 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++
|
||||
switch booking.Status {
|
||||
case "cancelled":
|
||||
agg.cancelled++
|
||||
case "completed":
|
||||
agg.completed++
|
||||
}
|
||||
if booking.Status != "cancelled" {
|
||||
agg.revenue += int64(prices[*booking.ServiceID])
|
||||
}
|
||||
}
|
||||
|
||||
metrics := make([]domain.ServiceMetric, 0, len(services))
|
||||
for _, service := range services {
|
||||
agg := byService[service.ID]
|
||||
if agg == nil {
|
||||
agg = &aggregate{}
|
||||
}
|
||||
metrics = append(metrics, domain.ServiceMetric{
|
||||
ServiceID: service.ID,
|
||||
Name: service.Name,
|
||||
Bookings: agg.bookings,
|
||||
Completed: agg.completed,
|
||||
Cancelled: agg.cancelled,
|
||||
RevenueCents: agg.revenue,
|
||||
})
|
||||
}
|
||||
return domain.ServiceMetricsResponse{
|
||||
From: from,
|
||||
To: to,
|
||||
Services: metrics,
|
||||
ClassBookings: classBookings,
|
||||
}, 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 {
|
||||
// without a staff assignment are tenant-wide and don't map to a member. A day
|
||||
// blocked for the member (or tenant-wide) contributes no capacity.
|
||||
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, blockedDays []db.BlockedDayRecord, 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())
|
||||
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
for _, rule := range rules {
|
||||
if rule.StaffID == nil || rule.DayOfWeek != weekday {
|
||||
continue
|
||||
}
|
||||
if blockedAt(blockedDays, rule.StaffID, dayStart, dayStart.AddDate(0, 0, 1)) {
|
||||
continue
|
||||
}
|
||||
byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,3 +879,146 @@ func TestStaffMetricsAggregatesPerStaff(t *testing.T) {
|
||||
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffMetricsBlockedDayReducesCapacity(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
principal := domain.Principal{Subject: "demo-owner", Role: "owner"}
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
|
||||
|
||||
baseline, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("baseline metrics: %v", err)
|
||||
}
|
||||
capacityBefore := baseline.Staff[0].CapacityMinutes
|
||||
if capacityBefore <= 0 {
|
||||
t.Fatalf("expected positive baseline capacity, got %d", capacityBefore)
|
||||
}
|
||||
|
||||
// Block the most recent weekday inside the window, tenant-wide. The
|
||||
// tenant runs Mon–Fri 09:00–17:00, so one blocked weekday removes
|
||||
// exactly 480 capacity minutes.
|
||||
loc, _ := time.LoadLocation("Europe/Prague")
|
||||
now := time.Now().UTC()
|
||||
var blocked time.Time
|
||||
for i := 1; i <= 7; i++ {
|
||||
day := now.AddDate(0, 0, -i).In(loc)
|
||||
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday {
|
||||
blocked = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
|
||||
TenantID: tenantID,
|
||||
StartsAt: blocked,
|
||||
EndsAt: blocked.AddDate(0, 0, 1),
|
||||
Kind: "closure",
|
||||
}); err != nil {
|
||||
t.Fatalf("create blocked day: %v", err)
|
||||
}
|
||||
|
||||
after, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("metrics after block: %v", err)
|
||||
}
|
||||
want := capacityBefore - 480
|
||||
if got := after.Staff[0].CapacityMinutes; got != want {
|
||||
t.Fatalf("expected capacity %d after tenant-wide block, got %d", want, got)
|
||||
}
|
||||
|
||||
// A staff-scoped block on a different weekday removes another 480.
|
||||
var secondBlocked time.Time
|
||||
for i := 1; i <= 7; i++ {
|
||||
day := now.AddDate(0, 0, -i).In(loc)
|
||||
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
if day.Weekday() >= time.Monday && day.Weekday() <= time.Friday && !dayStart.Equal(blocked) {
|
||||
secondBlocked = dayStart
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, err := repo.CreateBlockedDay(ctx, db.CreateBlockedDayParams{
|
||||
TenantID: tenantID,
|
||||
StaffID: &staffID,
|
||||
StartsAt: secondBlocked,
|
||||
EndsAt: secondBlocked.AddDate(0, 0, 1),
|
||||
Kind: "vacation",
|
||||
}); err != nil {
|
||||
t.Fatalf("create staff block: %v", err)
|
||||
}
|
||||
final, err := service.StaffMetrics(ctx, principal, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("metrics after staff block: %v", err)
|
||||
}
|
||||
if got := final.Staff[0].CapacityMinutes; got != want-480 {
|
||||
t.Fatalf("expected capacity %d after staff block, got %d", want-480, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMetricsAggregatesPerService(t *testing.T) {
|
||||
repo := db.NewMemoryRepository()
|
||||
service := NewService(repo, nil, "http://localhost:3000")
|
||||
ctx := context.Background()
|
||||
|
||||
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč
|
||||
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||
now := time.Now().UTC()
|
||||
|
||||
seed := func(service *string, session *string, status string, ref string) {
|
||||
t.Helper()
|
||||
_, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||
TenantID: tenantID,
|
||||
ServiceID: service,
|
||||
ClassSessionID: session,
|
||||
CustomerName: "Client",
|
||||
CustomerEmail: "[email protected]",
|
||||
StartsAt: now.Add(-5 * time.Hour),
|
||||
EndsAt: now.Add(-4 * time.Hour),
|
||||
Status: status,
|
||||
Reference: ref,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed booking: %v", err)
|
||||
}
|
||||
}
|
||||
seed(&serviceID, nil, "confirmed", "BK-SVM-1")
|
||||
seed(&serviceID, nil, "completed", "BK-SVM-2")
|
||||
seed(&serviceID, nil, "cancelled", "BK-SVM-3")
|
||||
seed(nil, &sessionID, "confirmed", "BK-SVM-4")
|
||||
seed(&serviceID, nil, "confirmed", "BK-SVM-OLD")
|
||||
|
||||
// Move the old booking outside the 30-day window via a direct update.
|
||||
old, err := repo.GetBookingByReference(ctx, "BK-SVM-OLD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetch old booking: %v", err)
|
||||
}
|
||||
if err := repo.RescheduleBooking(ctx, old.ID, now.AddDate(0, 0, -40), now.AddDate(0, 0, -40).Add(time.Hour)); err != nil {
|
||||
t.Fatalf("reschedule old booking: %v", err)
|
||||
}
|
||||
|
||||
response, err := service.ServiceMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("service metrics: %v", err)
|
||||
}
|
||||
var row domain.ServiceMetric
|
||||
for _, m := range response.Services {
|
||||
if m.ServiceID == serviceID {
|
||||
row = m
|
||||
}
|
||||
}
|
||||
if row.Bookings != 3 {
|
||||
t.Fatalf("expected 3 bookings in window, got %d", row.Bookings)
|
||||
}
|
||||
if row.Completed != 1 || row.Cancelled != 1 {
|
||||
t.Fatalf("expected 1 completed + 1 cancelled, got %d/%d", row.Completed, row.Cancelled)
|
||||
}
|
||||
// Revenue counts non-cancelled bookings at list price: confirmed + completed.
|
||||
if row.RevenueCents != 240000 {
|
||||
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
||||
}
|
||||
if response.ClassBookings != 1 {
|
||||
t.Fatalf("expected 1 class booking, got %d", response.ClassBookings)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type UpcomingBooking struct {
|
||||
StaffName string `json:"staffName,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
||||
IntakeLabels map[string]string `json:"intakeLabels,omitempty"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Status string `json:"status"`
|
||||
@@ -545,6 +547,8 @@ type CustomerBookingView struct {
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Intake map[string]string `json:"intake,omitempty"`
|
||||
// IntakeLabels maps Intake keys to the service's declared field labels.
|
||||
IntakeLabels map[string]string `json:"intakeLabels,omitempty"`
|
||||
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||
// bookings; zero for any other status.
|
||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||
@@ -624,6 +628,27 @@ type StaffMetricsResponse struct {
|
||||
Staff []StaffMetric `json:"staff"`
|
||||
}
|
||||
|
||||
// ServiceMetric aggregates appointment bookings for one catalog service over
|
||||
// a trailing window. Revenue counts non-cancelled bookings at list price.
|
||||
type ServiceMetric struct {
|
||||
ServiceID string `json:"serviceId"`
|
||||
Name string `json:"name"`
|
||||
Bookings int `json:"bookings"`
|
||||
Completed int `json:"completed"`
|
||||
Cancelled int `json:"cancelled"`
|
||||
RevenueCents int64 `json:"revenueCents"`
|
||||
}
|
||||
|
||||
type ServiceMetricsResponse struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
// Services lists every catalog service, including zero rows.
|
||||
Services []ServiceMetric `json:"services"`
|
||||
// ClassBookings counts session bookings in the window; they carry no
|
||||
// service or price so they are reported separately.
|
||||
ClassBookings int `json:"classBookings"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ADMIN MODELS
|
||||
// ============================================
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -24,6 +24,20 @@ func ParseIntakeFields(raw string) []domain.IntakeField {
|
||||
return fields
|
||||
}
|
||||
|
||||
// IntakeLabels maps a service's declared intake field keys to their display
|
||||
// labels, so booking views can render questions rather than slugs.
|
||||
func IntakeLabels(raw string) map[string]string {
|
||||
fields := ParseIntakeFields(raw)
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
labels := make(map[string]string, len(fields))
|
||||
for _, field := range fields {
|
||||
labels[field.Key] = field.Label
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// NormalizeIntakeFields validates a service's declared intake fields and
|
||||
// returns the canonical JSON string for storage. Keys are slugified from the
|
||||
// provided key or the label, must be unique, and fields are capped at 8.
|
||||
|
||||
@@ -1545,6 +1545,33 @@ paths:
|
||||
$ref: "#/components/schemas/StaffMetricsResponse"
|
||||
"404":
|
||||
description: Tenant membership not found
|
||||
/v1/metrics/services:
|
||||
get:
|
||||
tags: [Metrics]
|
||||
operationId: getServiceMetrics
|
||||
security:
|
||||
- bearerAuth: []
|
||||
summary: Per-service appointment metrics over a trailing window
|
||||
description: >-
|
||||
Bookings, completions, cancellations, and revenue per catalog service.
|
||||
Every service appears, including zero rows. Class bookings carry no
|
||||
service or price and are counted separately in `classBookings`.
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 30
|
||||
maximum: 365
|
||||
responses:
|
||||
"200":
|
||||
description: Per-service metrics
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceMetricsResponse"
|
||||
"404":
|
||||
description: Tenant membership not found
|
||||
/v1/export/bookings.csv:
|
||||
get:
|
||||
tags: [Export]
|
||||
@@ -2219,6 +2246,11 @@ components:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Structured intake answers keyed by the service's declared intakeFields keys
|
||||
intakeLabels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Maps intake answer keys to the service's declared field labels; absent for services without intake
|
||||
classSessionId:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -2266,6 +2298,11 @@ components:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
intakeLabels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Maps intake answer keys to the service's declared field labels; absent for services without intake
|
||||
waitlistPosition:
|
||||
type: integer
|
||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||
@@ -2408,6 +2445,42 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/StaffMetric"
|
||||
ServiceMetric:
|
||||
type: object
|
||||
required: [serviceId, name, bookings, completed, cancelled, revenueCents]
|
||||
properties:
|
||||
serviceId:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
bookings:
|
||||
type: integer
|
||||
completed:
|
||||
type: integer
|
||||
cancelled:
|
||||
type: integer
|
||||
revenueCents:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Sum of the service's price for non-cancelled bookings
|
||||
ServiceMetricsResponse:
|
||||
type: object
|
||||
required: [from, to, services, classBookings]
|
||||
properties:
|
||||
from:
|
||||
type: string
|
||||
format: date-time
|
||||
to:
|
||||
type: string
|
||||
format: date-time
|
||||
services:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ServiceMetric"
|
||||
classBookings:
|
||||
type: integer
|
||||
description: Session bookings in the window; reported separately since they carry no service or price
|
||||
DashboardBookingRequest:
|
||||
type: object
|
||||
required: [customerName, customerEmail, startsAt]
|
||||
|
||||
Reference in new issue
Block a user