diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index a2467e8..037a4af 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -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 diff --git a/apps/backend/internal/bookings/customer_service.go b/apps/backend/internal/bookings/customer_service.go index d6210f7..254a017 100644 --- a/apps/backend/internal/bookings/customer_service.go +++ b/apps/backend/internal/bookings/customer_service.go @@ -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 } diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 92c05d8..2c77e03 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -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) } } diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go index c3d1833..1849d5c 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -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: "client@example.com", + 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) + } +} diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 8fccdd4..05d640a 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -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 // ============================================ diff --git a/apps/backend/internal/export/service.go b/apps/backend/internal/export/service.go index 3a50116..700509c 100644 --- a/apps/backend/internal/export/service.go +++ b/apps/backend/internal/export/service.go @@ -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 } } diff --git a/apps/backend/internal/export/service_test.go b/apps/backend/internal/export/service_test.go index 2c22442..681327c 100644 --- a/apps/backend/internal/export/service_test.go +++ b/apps/backend/internal/export/service_test.go @@ -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] diff --git a/apps/backend/internal/shared/intake.go b/apps/backend/internal/shared/intake.go index 07e6f16..fbf3e9c 100644 --- a/apps/backend/internal/shared/intake.go +++ b/apps/backend/internal/shared/intake.go @@ -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. diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index b6fe6a4..cdd5f06 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -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] diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 5be1644..d52f132 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -464,6 +464,8 @@ const dictionaries = { "dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?", "dashboard.services": "Služby", "dashboard.services.subtitle": "služeb v nabídce", + "dashboard.services.performance": "Výkonnost služeb", + "dashboard.services.classBookings": "rezervací na skupinové lekce (vykazováno zvlášť)", "dashboard.service.add": "Přidat službu", "dashboard.service.edit": "Upravit službu", "dashboard.service.name": "Název služby", @@ -1093,6 +1095,8 @@ const dictionaries = { "dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?", "dashboard.services": "Services", "dashboard.services.subtitle": "services offered", + "dashboard.services.performance": "Service performance", + "dashboard.services.classBookings": "class session bookings (reported separately)", "dashboard.service.add": "Add service", "dashboard.service.edit": "Edit service", "dashboard.service.name": "Service name", diff --git a/apps/frontend/src/routes/booking-manage-route.tsx b/apps/frontend/src/routes/booking-manage-route.tsx index 587c569..172c1c5 100644 --- a/apps/frontend/src/routes/booking-manage-route.tsx +++ b/apps/frontend/src/routes/booking-manage-route.tsx @@ -398,9 +398,10 @@ export function BookingManageRoute() { {([key, raw]) => { const value = String(raw); + const label = b().intakeLabels?.[key] ?? key.replace(/_/g, " "); return (

- {key.replace(/_/g, " ")}: + {label}: {value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}

); diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index b6441d5..4084746 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType) { { id: "b4", customerName: customers[3].name, customerEmail: customers[3].email, service: services[3], status: "cancelled", startsAt: fmt(at(-1, 11)), endsAt: fmt(at(-1, 12)), reference: "BOK-004", location: salonA, staffName: staff1, notes: "" }, { id: "b11", customerName: customers[2].name, customerEmail: customers[2].email, service: services[0], status: "completed", startsAt: fmt(at(-1, 15, 30)), endsAt: fmt(at(-1, 16, 30)), reference: "BOK-011", location: salonA, staffName: staff2, notes: "" }, // today - { id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" } }, + { id: "b1", customerName: customers[0].name, customerEmail: customers[0].email, service: services[0], status: "confirmed", startsAt: fmt(at(0, 10)), endsAt: fmt(at(0, 11)), reference: "BOK-001", location: salonA, staffName: staff1, notes: "", intake: cs ? { "zdravotni_poznamky": "Citlivá pokožka", "prvni_navsteva": "yes" } : { "health_notes": "Sensitive skin", "first_visit": "yes" }, intakeLabels: cs ? { "zdravotni_poznamky": "Zdravotní poznámky nebo alergie", "prvni_navsteva": "Je to vaše první návštěva?" } : { "health_notes": "Health notes or allergies", "first_visit": "Is this your first visit?" } }, { id: "b7", customerName: customers[1].name, customerEmail: customers[1].email, service: services[2], status: "confirmed", startsAt: fmt(at(0, 16)), endsAt: fmt(at(0, 17)), reference: "BOK-007", location: salonB, staffName: "", notes: "" }, // upcoming { id: "b2", customerName: customers[1].name, customerEmail: customers[1].email, service: services[1], status: "pending", startsAt: fmt(at(1, 14)), endsAt: fmt(at(1, 15)), reference: "BOK-002", location: salonA, staffName: staff2, notes: "" }, diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index 94d4ef7..317ed75 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -336,9 +336,10 @@ function DashboardShell(props: { children: JSX.Element }) { {([key, raw]) => { const value = String(raw); + const label = data.selectedBooking()!.intakeLabels?.[key] ?? key.replace(/_/g, " "); return (
- {key.replace(/_/g, " ")} + {label} {value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
); diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx index db8de48..3a76a1b 100644 --- a/apps/frontend/src/routes/dashboard/services-page.tsx +++ b/apps/frontend/src/routes/dashboard/services-page.tsx @@ -73,6 +73,26 @@ function ServicesPage() { }, ); + const [serviceMetrics] = createResource( + () => data.token(), + async (bearer) => { + if (!bearer) return null; + if (bearer.startsWith("demo.")) { + return { + services: [ + { serviceId: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", bookings: 9, completed: 5, cancelled: 1, revenueCents: 712000 }, + { serviceId: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", bookings: 6, completed: 4, cancelled: 0, revenueCents: 354000 }, + { serviceId: "sv3", name: cs() ? "Barvení" : "Coloring", bookings: 3, completed: 2, cancelled: 0, revenueCents: 597000 }, + { serviceId: "sv4", name: cs() ? "Konzultace" : "Consultation", bookings: 2, completed: 1, cancelled: 0, revenueCents: 0 }, + ], + classBookings: 4, + }; + } + const response = await apiClient.GET("/v1/metrics/services", authHeaders()); + return response.data ?? null; + }, + ); + const resolvedServices = () => services() ?? []; const formatPrice = (priceCents: number) => { @@ -80,6 +100,9 @@ function ServicesPage() { return new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(priceCents / 100); }; + const formatRevenue = (cents: number) => + new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(cents / 100); + const openCreate = () => { setEditingId(null); setForm({ ...emptyForm }); @@ -181,6 +204,47 @@ function ServicesPage() { + 0}> +
+
+

{data.i18n.t("dashboard.services.performance")}

+ {data.i18n.t("dashboard.staff.performanceWindow")} +
+
+ + {(m: any) => ( +
+

{m.name}

+
+
+

{data.i18n.t("dashboard.bookings")}

+

{m.bookings}

+
+
+

{data.i18n.t("dashboard.completed")}

+

{m.completed}

+
+
+

{data.i18n.t("dashboard.cancelled")}

+

{m.cancelled}

+
+
+

{data.i18n.t("dashboard.staff.revenue")}

+

{formatRevenue(m.revenueCents)}

+
+
+
+ )} +
+
+ 0}> +

+ {serviceMetrics()!.classBookings} {data.i18n.t("dashboard.services.classBookings")} +

+
+
+
+
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 8ce541f..71f7fea 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -848,6 +848,26 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/metrics/services": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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`. + */ + get: operations["getServiceMetrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/metrics/staff": { parameters: { query?: never; @@ -1482,6 +1502,10 @@ export interface components { intake?: { [key: string]: string; }; + /** @description Maps intake answer keys to the service's declared field labels; absent for services without intake */ + intakeLabels?: { + [key: string]: string; + }; location?: string; notes?: string; reference?: string; @@ -1670,6 +1694,28 @@ export interface components { /** Format: uuid */ tenantId: string; }; + ServiceMetric: { + bookings: number; + cancelled: number; + completed: number; + name: string; + /** + * Format: int64 + * @description Sum of the service's price for non-cancelled bookings + */ + revenueCents: number; + /** Format: uuid */ + serviceId: string; + }; + ServiceMetricsResponse: { + /** @description Session bookings in the window; reported separately since they carry no service or price */ + classBookings: number; + /** Format: date-time */ + from: string; + services: components["schemas"]["ServiceMetric"][]; + /** Format: date-time */ + to: string; + }; SessionMoveTarget: { capacity?: number; confirmedCount?: number; @@ -1845,6 +1891,10 @@ export interface components { intake?: { [key: string]: string; }; + /** @description Maps intake answer keys to the service's declared field labels; absent for services without intake */ + intakeLabels?: { + [key: string]: string; + }; label?: string; location?: string; notes?: string; @@ -3910,6 +3960,35 @@ export interface operations { }; }; }; + getServiceMetrics: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Per-service metrics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ServiceMetricsResponse"]; + }; + }; + /** @description Tenant membership not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getStaffMetrics: { parameters: { query?: { diff --git a/project.md b/project.md index f0e03fc..57e94a4 100644 --- a/project.md +++ b/project.md @@ -219,7 +219,15 @@ - intake surfaces: public catalog (`PublicService.intakeFields`), dashboard upcoming bookings (`UpcomingBooking.intake`), customer manage view (`CustomerBookingView.intake`) - services page gains a compact intake editor (label + type + required per row, max 8, client-side slug key generation mirroring the backend); public form renders declared questions under contact details with required markers; dashboard booking modal and manage view list answers (yes/no localized, keys shown normalized — labels are not stored with answers by design) - verified live + in-browser: catalog exposes fields, booking with unknown key stores only declared answers, missing required → 400, UI submit → stored answers visible in dashboard summary and manage view; focused `internal/shared` tests cover normalization edge cases - - remaining: live staging validation on Neon + pgx repository path; labels shown to staff are key-derived (service lookup for original labels is a possible refinement) + - remaining: live staging validation on Neon + pgx repository path; label join landed in M31 +- `[done]` Milestone 31: Data surfacing — labels, service metrics, honest capacity + - booking views gain `intakeLabels` (`shared.IntakeLabels` maps declared keys → labels); dashboard modal and customer manage view now render real question labels with slug fallback, closing M30's remaining loop + - `GET /v1/metrics/services?days=` aggregates bookings/completed/cancelled/revenue per service plus a separate `classBookings` count; `WriteServiceReportCSV` now delegates to `bookings.ServiceMetrics` (single aggregation point, same as the staff report) + - services page gains a "Service performance" card mirroring the staff panel (per-service stat grid, CZK revenue per locale, class-bookings footer note); demo rows match the fabricated service list + - staff capacity utilization now subtracts blocked days — tenant-wide blocks and staff-scoped blocks each remove that day's rule minutes via the shared `blockedAt` overlap semantics + - `service-report.csv` gains a `completed` column + - verified live: `/v1/metrics/services` returns correct aggregates for seeded trailing-window bookings; `intakeLabels` present in dashboard summary; modal renders declared labels in CS; focused tests cover blocked-day capacity (tenant + staff scope) and service aggregation + - remaining: live staging validation on Neon + pgx repository path ## Overview Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP. @@ -471,10 +479,10 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal - **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request. - **Series-aware session edits**: M29's rules generate sessions, but editing a rule means delete-and-recreate; an "apply to future sessions of this rule" update (time shift, capacity change) would remove the workaround. -- **Intake answer surfacing**: M30 stores answers keyed by slug, so staff see normalized keys rather than original labels; joining the service's declared labels into the booking view (and filtering/reporting on yes-no answers) would complete the loop without changing the schema. - **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. -- **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe. - **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints. +- **Intake reporting**: M31 shows labels, but answers aren't queryable — filtering or aggregating yes-no answers (e.g. "first visits this month") needs a stored projection, not JSONB scans; defer until a reporting request exists. +- **Waitlist ledger cleanup**: `waitlist_entries` is vestigial — every displayed position derives from booking `created_at`, so the table is written but never read; drop it or wire it as the source of truth, don't leave both half-alive. ## Product Principles - calm, premium, structured UX