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

+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