mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
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:
1 parent
4674203502
commit
76e45d4178
11 files changed
+496
-3
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user