mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #16 from Dvorinka/feat/m31-data-surfacing
feat: data surfacing — intake labels, service metrics, honest capacity
This commit is contained in:
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)
|
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) {
|
protected.GET("/export/bookings.csv", func(c *gin.Context) {
|
||||||
from, to := exportRange(c)
|
from, to := exportRange(c)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|||||||
@@ -98,6 +98,19 @@ func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRec
|
|||||||
return serviceName, locationName
|
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
|
// GetBookingByReference returns booking details for customer management link
|
||||||
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
|
func (s *CustomerService) GetBookingByReference(ctx context.Context, reference string, token string) (domain.CustomerBookingView, error) {
|
||||||
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
booking, err := s.repo.GetBookingByReference(ctx, reference)
|
||||||
@@ -136,6 +149,7 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
|||||||
Status: booking.Status,
|
Status: booking.Status,
|
||||||
Notes: booking.Notes,
|
Notes: booking.Notes,
|
||||||
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
Intake: shared.ParseIntakeAnswers(booking.Intake),
|
||||||
|
IntakeLabels: s.intakeLabels(ctx, booking),
|
||||||
WaitlistPosition: position,
|
WaitlistPosition: position,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -548,9 +548,11 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
}
|
}
|
||||||
|
|
||||||
serviceNames := map[string]string{}
|
serviceNames := map[string]string{}
|
||||||
|
serviceIntakeLabels := map[string]map[string]string{}
|
||||||
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
|
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
serviceNames[service.ID] = service.Name
|
serviceNames[service.ID] = service.Name
|
||||||
|
serviceIntakeLabels[service.ID] = shared.IntakeLabels(service.IntakeFields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
locationNames := map[string]string{}
|
locationNames := map[string]string{}
|
||||||
@@ -580,6 +582,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
}
|
}
|
||||||
if booking.ServiceID != nil {
|
if booking.ServiceID != nil {
|
||||||
out.Service = serviceNames[*booking.ServiceID]
|
out.Service = serviceNames[*booking.ServiceID]
|
||||||
|
out.IntakeLabels = serviceIntakeLabels[*booking.ServiceID]
|
||||||
}
|
}
|
||||||
if booking.LocationID != nil {
|
if booking.LocationID != nil {
|
||||||
out.Location = locationNames[*booking.LocationID]
|
out.Location = locationNames[*booking.LocationID]
|
||||||
@@ -719,11 +722,15 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
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)
|
loc, err := time.LoadLocation(membership.Tenant.Timezone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
loc = time.UTC
|
loc = time.UTC
|
||||||
}
|
}
|
||||||
capacity := staffCapacityMinutes(rules, from, to, loc)
|
capacity := staffCapacityMinutes(rules, blockedDays, from, to, loc)
|
||||||
|
|
||||||
metrics := make([]domain.StaffMetric, 0, len(staff))
|
metrics := make([]domain.StaffMetric, 0, len(staff))
|
||||||
for _, member := range 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
|
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
|
// staffCapacityMinutes sums each member's availability-rule minutes across the
|
||||||
// window, evaluated on local calendar dates in the tenant timezone. Rules
|
// 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.
|
// without a staff assignment are tenant-wide and don't map to a member. A day
|
||||||
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, from, to time.Time, loc *time.Location) map[string]int {
|
// 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{}
|
byStaff := map[string]int{}
|
||||||
for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) {
|
for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) {
|
||||||
weekday := int(day.Weekday())
|
weekday := int(day.Weekday())
|
||||||
|
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if rule.StaffID == nil || rule.DayOfWeek != weekday {
|
if rule.StaffID == nil || rule.DayOfWeek != weekday {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if blockedAt(blockedDays, rule.StaffID, dayStart, dayStart.AddDate(0, 0, 1)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal)
|
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)
|
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"`
|
StaffName string `json:"staffName,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Intake map[string]string `json:"intake,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"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -545,6 +547,8 @@ type CustomerBookingView struct {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Intake map[string]string `json:"intake,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
|
// WaitlistPosition is the 1-based queue spot for waitlisted class
|
||||||
// bookings; zero for any other status.
|
// bookings; zero for any other status.
|
||||||
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||||
@@ -624,6 +628,27 @@ type StaffMetricsResponse struct {
|
|||||||
Staff []StaffMetric `json:"staff"`
|
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
|
// ADMIN MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -216,57 +216,16 @@ func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Prin
|
|||||||
return out.Error()
|
return out.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteServiceReportCSV aggregates appointment bookings per catalog service
|
// WriteServiceReportCSV exports the per-service performance metrics over a
|
||||||
// over a trailing window. Class bookings have no service or price and roll
|
// trailing window — the same aggregation the dashboard services panel shows.
|
||||||
// into a final "class sessions" row when present.
|
|
||||||
func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error {
|
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 {
|
if err != nil {
|
||||||
|
if errors.Is(err, bookings.ErrTenantMembership) {
|
||||||
|
return ErrTenantMembership
|
||||||
|
}
|
||||||
return err
|
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 {
|
if _, err := w.Write(utf8BOM); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -274,25 +233,22 @@ func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Pr
|
|||||||
out := csv.NewWriter(w)
|
out := csv.NewWriter(w)
|
||||||
defer out.Flush()
|
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
|
return err
|
||||||
}
|
}
|
||||||
for _, service := range services {
|
for _, m := range metrics.Services {
|
||||||
agg := byService[service.ID]
|
|
||||||
if agg == nil {
|
|
||||||
agg = &aggregate{}
|
|
||||||
}
|
|
||||||
if err := out.Write([]string{
|
if err := out.Write([]string{
|
||||||
service.Name,
|
m.Name,
|
||||||
strconv.Itoa(agg.bookings),
|
strconv.Itoa(m.Bookings),
|
||||||
strconv.Itoa(agg.cancelled),
|
strconv.Itoa(m.Completed),
|
||||||
strconv.FormatInt(agg.revenue, 10),
|
strconv.Itoa(m.Cancelled),
|
||||||
|
strconv.FormatInt(m.RevenueCents, 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if classBookings > 0 {
|
if metrics.ClassBookings > 0 {
|
||||||
if err := out.Write([]string{"class sessions", strconv.Itoa(classBookings), "0", "0"}); err != nil {
|
if err := out.Write([]string{"class sessions", strconv.Itoa(metrics.ClassBookings), "0", "0", "0"}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ func TestWriteServiceReportCSV(t *testing.T) {
|
|||||||
t.Fatalf("expected header + service row + class row, got %d rows", len(records))
|
t.Fatalf("expected header + service row + class row, got %d rows", len(records))
|
||||||
}
|
}
|
||||||
svcRow := records[1]
|
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)
|
t.Fatalf("unexpected service row: %v", svcRow)
|
||||||
}
|
}
|
||||||
classRow := records[2]
|
classRow := records[2]
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ func ParseIntakeFields(raw string) []domain.IntakeField {
|
|||||||
return fields
|
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
|
// NormalizeIntakeFields validates a service's declared intake fields and
|
||||||
// returns the canonical JSON string for storage. Keys are slugified from the
|
// 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.
|
// provided key or the label, must be unique, and fields are capped at 8.
|
||||||
|
|||||||
@@ -1545,6 +1545,33 @@ paths:
|
|||||||
$ref: "#/components/schemas/StaffMetricsResponse"
|
$ref: "#/components/schemas/StaffMetricsResponse"
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
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:
|
/v1/export/bookings.csv:
|
||||||
get:
|
get:
|
||||||
tags: [Export]
|
tags: [Export]
|
||||||
@@ -2219,6 +2246,11 @@ components:
|
|||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
description: Structured intake answers keyed by the service's declared intakeFields keys
|
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:
|
classSessionId:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
@@ -2266,6 +2298,11 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
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:
|
waitlistPosition:
|
||||||
type: integer
|
type: integer
|
||||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||||
@@ -2408,6 +2445,42 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/StaffMetric"
|
$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:
|
DashboardBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [customerName, customerEmail, startsAt]
|
required: [customerName, customerEmail, startsAt]
|
||||||
|
|||||||
@@ -464,6 +464,8 @@ const dictionaries = {
|
|||||||
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
|
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
|
||||||
"dashboard.services": "Služby",
|
"dashboard.services": "Služby",
|
||||||
"dashboard.services.subtitle": "služeb v nabídce",
|
"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.add": "Přidat službu",
|
||||||
"dashboard.service.edit": "Upravit službu",
|
"dashboard.service.edit": "Upravit službu",
|
||||||
"dashboard.service.name": "Název služby",
|
"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.booking.promoteFull": "The session is full. Confirm the booking anyway?",
|
||||||
"dashboard.services": "Services",
|
"dashboard.services": "Services",
|
||||||
"dashboard.services.subtitle": "services offered",
|
"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.add": "Add service",
|
||||||
"dashboard.service.edit": "Edit service",
|
"dashboard.service.edit": "Edit service",
|
||||||
"dashboard.service.name": "Service name",
|
"dashboard.service.name": "Service name",
|
||||||
|
|||||||
@@ -398,9 +398,10 @@ export function BookingManageRoute() {
|
|||||||
<For each={Object.entries(b().intake)}>
|
<For each={Object.entries(b().intake)}>
|
||||||
{([key, raw]) => {
|
{([key, raw]) => {
|
||||||
const value = String(raw);
|
const value = String(raw);
|
||||||
|
const label = b().intakeLabels?.[key] ?? key.replace(/_/g, " ");
|
||||||
return (
|
return (
|
||||||
<p class="text-sm text-ink">
|
<p class="text-sm text-ink">
|
||||||
<span class="text-ink-muted">{key.replace(/_/g, " ")}: </span>
|
<span class="text-ink-muted">{label}: </span>
|
||||||
{value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
|
{value === "yes" || value === "no" ? i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
|||||||
{ 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: "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: "" },
|
{ 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
|
// 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: "" },
|
{ 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
|
// 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: "" },
|
{ 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: "" },
|
||||||
|
|||||||
@@ -336,9 +336,10 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
<For each={Object.entries(data.selectedBooking()!.intake!)}>
|
<For each={Object.entries(data.selectedBooking()!.intake!)}>
|
||||||
{([key, raw]) => {
|
{([key, raw]) => {
|
||||||
const value = String(raw);
|
const value = String(raw);
|
||||||
|
const label = data.selectedBooking()!.intakeLabels?.[key] ?? key.replace(/_/g, " ");
|
||||||
return (
|
return (
|
||||||
<div class="flex items-baseline justify-between gap-3">
|
<div class="flex items-baseline justify-between gap-3">
|
||||||
<span class="text-xs text-ink-muted">{key.replace(/_/g, " ")}</span>
|
<span class="text-xs text-ink-muted">{label}</span>
|
||||||
<span class="text-sm text-ink">{value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}</span>
|
<span class="text-sm text-ink">{value === "yes" || value === "no" ? data.i18n.t(`booking.intake${value === "yes" ? "Yes" : "No"}`) : value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 resolvedServices = () => services() ?? [];
|
||||||
|
|
||||||
const formatPrice = (priceCents: number) => {
|
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);
|
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 = () => {
|
const openCreate = () => {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setForm({ ...emptyForm });
|
setForm({ ...emptyForm });
|
||||||
@@ -181,6 +204,47 @@ function ServicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={(serviceMetrics()?.services?.length ?? 0) > 0}>
|
||||||
|
<div class="surface-card p-6">
|
||||||
|
<div class="flex items-baseline justify-between gap-4 mb-5">
|
||||||
|
<h2 class="text-lg font-semibold text-ink font-display">{data.i18n.t("dashboard.services.performance")}</h2>
|
||||||
|
<span class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.performanceWindow")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-border/60">
|
||||||
|
<For each={serviceMetrics()!.services}>
|
||||||
|
{(m: any) => (
|
||||||
|
<div class="py-4 first:pt-0 last:pb-0">
|
||||||
|
<p class="font-medium text-ink mb-3">{m.name}</p>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.bookings")}</p>
|
||||||
|
<p class="text-lg font-semibold text-ink tabular-nums">{m.bookings}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.completed")}</p>
|
||||||
|
<p class="text-lg font-semibold text-ink tabular-nums">{m.completed}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.cancelled")}</p>
|
||||||
|
<p class="text-lg font-semibold text-ink tabular-nums">{m.cancelled}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.revenue")}</p>
|
||||||
|
<p class="text-lg font-semibold text-ink tabular-nums">{formatRevenue(m.revenueCents)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<Show when={(serviceMetrics()?.classBookings ?? 0) > 0}>
|
||||||
|
<p class="text-xs text-ink-subtle mt-4 pt-4 border-t border-border/60">
|
||||||
|
{serviceMetrics()!.classBookings} {data.i18n.t("dashboard.services.classBookings")}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="surface-card overflow-hidden">
|
<div class="surface-card overflow-hidden">
|
||||||
<Show when={showForm()}>
|
<Show when={showForm()}>
|
||||||
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
<div class="p-6 border-b border-border bg-canvas-subtle/50">
|
||||||
|
|||||||
@@ -848,6 +848,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/metrics/staff": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1482,6 +1502,10 @@ export interface components {
|
|||||||
intake?: {
|
intake?: {
|
||||||
[key: string]: string;
|
[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;
|
location?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
reference?: string;
|
reference?: string;
|
||||||
@@ -1670,6 +1694,28 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
tenantId: string;
|
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: {
|
SessionMoveTarget: {
|
||||||
capacity?: number;
|
capacity?: number;
|
||||||
confirmedCount?: number;
|
confirmedCount?: number;
|
||||||
@@ -1845,6 +1891,10 @@ export interface components {
|
|||||||
intake?: {
|
intake?: {
|
||||||
[key: string]: string;
|
[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;
|
label?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
notes?: 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: {
|
getStaffMetrics: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
+11
-3
@@ -219,7 +219,15 @@
|
|||||||
- intake surfaces: public catalog (`PublicService.intakeFields`), dashboard upcoming bookings (`UpcomingBooking.intake`), customer manage view (`CustomerBookingView.intake`)
|
- 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)
|
- 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
|
- 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
|
## 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.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user