feat(metrics): per-staff performance view in dashboard

GET /v1/metrics/staff returns bookings, completed, cancelled,
utilization, and revenue per staff member over a trailing window
(default 30d, max 365d). Appointment bookings only — class sessions
have no staff assignment. All active staff appear, including
zero-booking rows.

Staff page gains a localized Performance panel; demo mode seeds
trailing-window bookings and matching metric rows.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 16:34:45 +02:00
1 parent 4674203502
commit 76e45d4178
11 files changed
+496 -3

No files matched your search

+14
View File
@@ -1296,6 +1296,20 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, response)
})
protected.GET("/metrics/staff", func(c *gin.Context) {
days, _ := strconv.Atoi(c.DefaultQuery("days", "30"))
response, err := bookingService.StaffMetrics(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
+45
View File
@@ -672,6 +672,51 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}, nil
}
// StaffMetrics returns per-staff appointment workload over a trailing
// window — every active staff member appears, even with zero bookings.
func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal, days int) (domain.StaffMetricsResponse, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.StaffMetricsResponse{}, ErrTenantMembership
}
return domain.StaffMetricsResponse{}, err
}
if days <= 0 || days > 365 {
days = 30
}
to := time.Now().UTC()
from := to.AddDate(0, 0, -days)
records, err := s.repo.GetStaffMetrics(ctx, membership.Tenant.ID, from, to)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
byStaff := make(map[string]db.StaffMetricsRecord, len(records))
for _, rec := range records {
byStaff[rec.StaffID] = rec
}
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err != nil {
return domain.StaffMetricsResponse{}, err
}
metrics := make([]domain.StaffMetric, 0, len(staff))
for _, member := range staff {
rec := byStaff[member.ID]
metrics = append(metrics, domain.StaffMetric{
StaffID: member.ID,
DisplayName: member.DisplayName,
Bookings: rec.BookingsCount,
Completed: rec.CompletedCount,
Cancelled: rec.CancellationsCount,
UtilizationPercent: rec.UtilizationPercent,
RevenueCents: rec.RevenueCents,
})
}
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
}
// setupCompletion scores onboarding readiness across five equal-weight checks:
// brand profile, location, service catalog, staff roster, and schedule rules.
func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int {
@@ -766,3 +766,77 @@ func TestWaitlistPositionReflectsQueueOrder(t *testing.T) {
t.Fatalf("expected promoted booking to report no position, got %d", got)
}
}
func TestStaffMetricsAggregatesPerStaff(t *testing.T) {
repo := db.NewMemoryRepository()
service := NewService(repo, nil, "http://localhost:3000")
ctx := context.Background()
tenantID := "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" // Demo Stylist
serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" // Signature treatment, 1200 Kč
now := time.Now().UTC()
seed := func(offsetDays int, status string, withService bool) {
t.Helper()
params := db.CreateBookingParams{
TenantID: tenantID,
StaffID: &staffID,
CustomerName: "Client",
CustomerEmail: "[email protected]",
StartsAt: now.AddDate(0, 0, offsetDays),
EndsAt: now.AddDate(0, 0, offsetDays).Add(time.Hour),
Status: status,
Reference: "BK-SM",
}
if withService {
params.ServiceID = &serviceID
}
if _, err := repo.CreateBooking(ctx, params); err != nil {
t.Fatalf("seed booking: %v", err)
}
}
seed(-5, "confirmed", true)
seed(-3, "confirmed", true) // already ended → completed too
seed(-2, "cancelled", false) // cancelled → no revenue
seed(-40, "confirmed", true) // outside the 30-day window
// Class booking (no staff) must not appear in per-staff metrics.
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
if _, err := repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: tenantID,
ClassSessionID: &sessionID,
CustomerName: "Class Client",
CustomerEmail: "[email protected]",
StartsAt: now.AddDate(0, 0, -1),
EndsAt: now.AddDate(0, 0, -1).Add(time.Hour),
Status: "confirmed",
Reference: "BK-SM-CLASS",
}); err != nil {
t.Fatalf("seed class booking: %v", err)
}
response, err := service.StaffMetrics(ctx, domain.Principal{Subject: "demo-owner", Role: "owner"}, 30)
if err != nil {
t.Fatalf("staff metrics: %v", err)
}
if len(response.Staff) != 1 {
t.Fatalf("expected 1 staff row, got %d", len(response.Staff))
}
row := response.Staff[0]
if row.StaffID != staffID || row.DisplayName != "Demo Stylist" {
t.Fatalf("unexpected staff row identity: %+v", row)
}
if row.Bookings != 3 {
t.Fatalf("expected 3 bookings in window, got %d", row.Bookings)
}
if row.Cancelled != 1 {
t.Fatalf("expected 1 cancellation, got %d", row.Cancelled)
}
if row.UtilizationPercent != 66 {
t.Fatalf("expected 66%% utilization, got %d", row.UtilizationPercent)
}
if row.RevenueCents != 240000 {
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
}
}
+38
View File
@@ -251,6 +251,44 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
return metrics, err
}
// GetStaffMetrics mirrors GetDashboardMetrics grouped by staff_id. Class
// bookings have no staff_id and fall out naturally; revenue sums the linked
// service price for non-cancelled bookings.
func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT
b.staff_id,
COUNT(*) AS bookings_count,
COUNT(*) FILTER (WHERE b.status = 'cancelled') AS cancellations_count,
COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count,
COALESCE(
ROUND(100.0 * COUNT(*) FILTER (WHERE b.status = 'confirmed') / NULLIF(COUNT(*), 0)),
0
)::integer AS utilization_percent,
COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents
FROM bookings b
LEFT JOIN services s ON s.id = b.service_id
WHERE b.tenant_id = $1 AND b.staff_id IS NOT NULL AND b.starts_at >= $2 AND b.starts_at < $3
GROUP BY b.staff_id
ORDER BY bookings_count DESC
`, tenantID, startsAt, endsAt)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffMetricsRecord
for rows.Next() {
var rec StaffMetricsRecord
if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount,
&rec.CompletedCount, &rec.UtilizationPercent, &rec.RevenueCents); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
var position int
err := r.pool.QueryRow(ctx, `
+90 -1
View File
@@ -146,6 +146,9 @@ type Repository interface {
// Activity feed
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, error)
// Staff metrics
GetStaffMetrics(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]StaffMetricsRecord, error)
}
type TenantRecord struct {
@@ -302,6 +305,17 @@ type StaffFeedTokenRecord struct {
FeedToken string
}
// StaffMetricsRecord is a per-staff aggregation of appointment bookings —
// class bookings carry no staff_id and are excluded by design.
type StaffMetricsRecord struct {
StaffID string
BookingsCount int
CancellationsCount int
CompletedCount int
UtilizationPercent int
RevenueCents int64
}
// ActivityEventRecord / ActivityEventParams back the per-tenant audit feed.
type ActivityEventRecord struct {
ID string
@@ -653,7 +667,9 @@ type PGRepository struct {
func NewRepository(pools *Pools, demoMode bool) Repository {
if demoMode {
return NewMemoryRepository()
repo := NewMemoryRepository()
repo.seedDemoBookings()
return repo
}
if pools != nil && pools.App != nil {
return &PGRepository{pool: pools.App}
@@ -1574,6 +1590,34 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
return created, nil
}
func (r *MemoryRepository) seedDemoBookings() {
now := time.Now().UTC()
serviceID := r.services[0].ID
staffID := r.staff[0].ID
locationID := r.locations[0].ID
seed := func(name, email, status string, startsAt time.Time) {
r.bookings = append(r.bookings, BookingRecord{
ID: uuid.NewString(),
TenantID: r.tenant.ID,
ServiceID: &serviceID,
StaffID: &staffID,
LocationID: &locationID,
CustomerName: name,
CustomerEmail: email,
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: status,
Reference: "BK-DEMO-" + uuid.NewString()[:8],
ManageToken: uuid.NewString(),
CreatedAt: startsAt.Add(-48 * time.Hour),
})
}
seed("Alice Johnson", "[email protected]", "confirmed", now.Add(-4*time.Hour))
seed("Bob Smith", "[email protected]", "confirmed", now.Add(-26*time.Hour))
seed("Carol White", "[email protected]", "confirmed", now.Add(-50*time.Hour))
seed("Alice Johnson", "[email protected]", "cancelled", now.Add(-74*time.Hour))
}
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
r.waitlistSize++
return nil
@@ -1721,6 +1765,51 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
return metrics, nil
}
func (r *MemoryRepository) GetStaffMetrics(_ context.Context, tenantID string, startsAt time.Time, endsAt time.Time) ([]StaffMetricsRecord, error) {
servicePrices := make(map[string]int, len(r.services))
for _, service := range r.services {
servicePrices[service.ID] = service.PriceCents
}
byStaff := map[string]*StaffMetricsRecord{}
for _, booking := range r.bookings {
if booking.TenantID != tenantID || booking.StaffID == nil {
continue
}
if booking.StartsAt.Before(startsAt) || !booking.StartsAt.Before(endsAt) {
continue
}
rec := byStaff[*booking.StaffID]
if rec == nil {
rec = &StaffMetricsRecord{StaffID: *booking.StaffID}
byStaff[*booking.StaffID] = rec
}
rec.BookingsCount++
switch {
case booking.Status == "cancelled":
rec.CancellationsCount++
case booking.Status == "confirmed":
rec.UtilizationPercent++
if booking.EndsAt.Before(time.Now().UTC()) {
rec.CompletedCount++
}
}
if booking.Status != "cancelled" && booking.ServiceID != nil {
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
}
}
records := make([]StaffMetricsRecord, 0, len(byStaff))
for _, rec := range byStaff {
if rec.BookingsCount == 0 {
rec.UtilizationPercent = 0
} else {
rec.UtilizationPercent = int(float64(rec.UtilizationPercent) / float64(rec.BookingsCount) * 100)
}
records = append(records, *rec)
}
sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount })
return records, nil
}
func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) {
if tenantID != r.tenant.ID {
return BillingSnapshotRecord{}, pgx.ErrNoRows
+18
View File
@@ -556,6 +556,24 @@ type ActivityFeedResponse struct {
Events []ActivityEvent `json:"events"`
}
// StaffMetric aggregates appointment workload for one staff member over a
// trailing window. Class bookings carry no staff assignment and are excluded.
type StaffMetric struct {
StaffID string `json:"staffId"`
DisplayName string `json:"displayName"`
Bookings int `json:"bookings"`
Completed int `json:"completed"`
Cancelled int `json:"cancelled"`
UtilizationPercent int `json:"utilizationPercent"`
RevenueCents int64 `json:"revenueCents"`
}
type StaffMetricsResponse struct {
From time.Time `json:"from"`
To time.Time `json:"to"`
Staff []StaffMetric `json:"staff"`
}
// ============================================
// ADMIN MODELS
// ============================================