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
@@ -1296,6 +1296,20 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
c.JSON(http.StatusOK, response)
|
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) {
|
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
|
||||||
|
|||||||
@@ -672,6 +672,51 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
}, nil
|
}, 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:
|
// setupCompletion scores onboarding readiness across five equal-weight checks:
|
||||||
// brand profile, location, service catalog, staff roster, and schedule rules.
|
// brand profile, location, service catalog, staff roster, and schedule rules.
|
||||||
func setupCompletion(brandName string, locationCount, serviceCount, staffCount, scheduleCount int) int {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -251,6 +251,44 @@ func (r *PGRepository) GetDashboardMetrics(ctx context.Context, tenantID string,
|
|||||||
return metrics, err
|
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) {
|
func (r *PGRepository) GetWaitlistPosition(ctx context.Context, sessionID, bookingID string) (int, error) {
|
||||||
var position int
|
var position int
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -146,6 +146,9 @@ type Repository interface {
|
|||||||
// Activity feed
|
// Activity feed
|
||||||
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
|
AppendActivityEvent(ctx context.Context, params ActivityEventParams) error
|
||||||
ListActivityEvents(ctx context.Context, tenantID string, limit int) ([]ActivityEventRecord, 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 {
|
type TenantRecord struct {
|
||||||
@@ -302,6 +305,17 @@ type StaffFeedTokenRecord struct {
|
|||||||
FeedToken string
|
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.
|
// ActivityEventRecord / ActivityEventParams back the per-tenant audit feed.
|
||||||
type ActivityEventRecord struct {
|
type ActivityEventRecord struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -653,7 +667,9 @@ type PGRepository struct {
|
|||||||
|
|
||||||
func NewRepository(pools *Pools, demoMode bool) Repository {
|
func NewRepository(pools *Pools, demoMode bool) Repository {
|
||||||
if demoMode {
|
if demoMode {
|
||||||
return NewMemoryRepository()
|
repo := NewMemoryRepository()
|
||||||
|
repo.seedDemoBookings()
|
||||||
|
return repo
|
||||||
}
|
}
|
||||||
if pools != nil && pools.App != nil {
|
if pools != nil && pools.App != nil {
|
||||||
return &PGRepository{pool: pools.App}
|
return &PGRepository{pool: pools.App}
|
||||||
@@ -1574,6 +1590,34 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
|
|||||||
return created, nil
|
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 {
|
func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntryParams) error {
|
||||||
r.waitlistSize++
|
r.waitlistSize++
|
||||||
return nil
|
return nil
|
||||||
@@ -1721,6 +1765,51 @@ func (r *MemoryRepository) GetDashboardMetrics(_ context.Context, tenantID strin
|
|||||||
return metrics, nil
|
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) {
|
func (r *MemoryRepository) GetSubscriptionSnapshot(_ context.Context, tenantID string) (BillingSnapshotRecord, error) {
|
||||||
if tenantID != r.tenant.ID {
|
if tenantID != r.tenant.ID {
|
||||||
return BillingSnapshotRecord{}, pgx.ErrNoRows
|
return BillingSnapshotRecord{}, pgx.ErrNoRows
|
||||||
|
|||||||
@@ -556,6 +556,24 @@ type ActivityFeedResponse struct {
|
|||||||
Events []ActivityEvent `json:"events"`
|
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
|
// ADMIN MODELS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -1431,6 +1431,34 @@ paths:
|
|||||||
$ref: "#/components/schemas/ActivityFeedResponse"
|
$ref: "#/components/schemas/ActivityFeedResponse"
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
description: Tenant membership not found
|
||||||
|
/v1/metrics/staff:
|
||||||
|
get:
|
||||||
|
tags: [Metrics]
|
||||||
|
operationId: getStaffMetrics
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
summary: Per-staff appointment metrics over a trailing window
|
||||||
|
description: >-
|
||||||
|
Bookings, completions, cancellations, utilization, and revenue per
|
||||||
|
staff member. Only appointment bookings carry a staff assignment —
|
||||||
|
class attendance is excluded by design. Every active staff member
|
||||||
|
appears, including zero rows.
|
||||||
|
parameters:
|
||||||
|
- name: days
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
default: 30
|
||||||
|
maximum: 365
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Per-staff metrics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/StaffMetricsResponse"
|
||||||
|
"404":
|
||||||
|
description: Tenant membership not found
|
||||||
/v1/export/bookings.csv:
|
/v1/export/bookings.csv:
|
||||||
get:
|
get:
|
||||||
tags: [Export]
|
tags: [Export]
|
||||||
@@ -2158,6 +2186,42 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/ActivityEvent"
|
$ref: "#/components/schemas/ActivityEvent"
|
||||||
|
StaffMetric:
|
||||||
|
type: object
|
||||||
|
required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents]
|
||||||
|
properties:
|
||||||
|
staffId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
displayName:
|
||||||
|
type: string
|
||||||
|
bookings:
|
||||||
|
type: integer
|
||||||
|
completed:
|
||||||
|
type: integer
|
||||||
|
cancelled:
|
||||||
|
type: integer
|
||||||
|
utilizationPercent:
|
||||||
|
type: integer
|
||||||
|
description: Share of bookings that stayed confirmed, 0-100
|
||||||
|
revenueCents:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Sum of linked service prices for non-cancelled bookings
|
||||||
|
StaffMetricsResponse:
|
||||||
|
type: object
|
||||||
|
required: [from, to, staff]
|
||||||
|
properties:
|
||||||
|
from:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
to:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
staff:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/StaffMetric"
|
||||||
DashboardBookingRequest:
|
DashboardBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [customerName, customerEmail, startsAt]
|
required: [customerName, customerEmail, startsAt]
|
||||||
|
|||||||
@@ -475,6 +475,9 @@ const dictionaries = {
|
|||||||
"dashboard.staff.inactive": "Neaktivní",
|
"dashboard.staff.inactive": "Neaktivní",
|
||||||
"dashboard.staff.noStaff": "Zatím žádní zaměstnanci.",
|
"dashboard.staff.noStaff": "Zatím žádní zaměstnanci.",
|
||||||
"dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.",
|
"dashboard.staff.noStaffDesc": "Přidejte členy týmu, kteří poskytují služby.",
|
||||||
|
"dashboard.staff.performance": "Výkonnost",
|
||||||
|
"dashboard.staff.performanceWindow": "posledních 30 dní",
|
||||||
|
"dashboard.staff.revenue": "Obrat",
|
||||||
"dashboard.staff.limitReached": "Dosažen limit zaměstnanců",
|
"dashboard.staff.limitReached": "Dosažen limit zaměstnanců",
|
||||||
"dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.",
|
"dashboard.staff.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.",
|
||||||
"dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.",
|
"dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.",
|
||||||
@@ -1076,6 +1079,9 @@ const dictionaries = {
|
|||||||
"dashboard.staff.inactive": "Inactive",
|
"dashboard.staff.inactive": "Inactive",
|
||||||
"dashboard.staff.noStaff": "No staff yet.",
|
"dashboard.staff.noStaff": "No staff yet.",
|
||||||
"dashboard.staff.noStaffDesc": "Add team members who provide services.",
|
"dashboard.staff.noStaffDesc": "Add team members who provide services.",
|
||||||
|
"dashboard.staff.performance": "Performance",
|
||||||
|
"dashboard.staff.performanceWindow": "last 30 days",
|
||||||
|
"dashboard.staff.revenue": "Revenue",
|
||||||
"dashboard.staff.limitReached": "Staff limit reached",
|
"dashboard.staff.limitReached": "Staff limit reached",
|
||||||
"dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.",
|
"dashboard.staff.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.",
|
||||||
"dashboard.staff.saveFailed": "Failed to save staff member.",
|
"dashboard.staff.saveFailed": "Failed to save staff member.",
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ function StaffPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [metrics] = createResource(
|
||||||
|
() => data.token(),
|
||||||
|
async (bearer) => {
|
||||||
|
if (!bearer) return null;
|
||||||
|
if (bearer.startsWith("demo.")) {
|
||||||
|
return {
|
||||||
|
staff: [
|
||||||
|
{ staffId: "st1", displayName: "Petra M.", bookings: 12, completed: 9, cancelled: 1, utilizationPercent: 92, revenueCents: 1440000 },
|
||||||
|
{ staffId: "st2", displayName: "Lucie K.", bookings: 8, completed: 5, cancelled: 2, utilizationPercent: 63, revenueCents: 960000 },
|
||||||
|
{ staffId: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", bookings: 0, completed: 0, cancelled: 0, utilizationPercent: 0, revenueCents: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await apiClient.GET("/v1/metrics/staff", authHeaders());
|
||||||
|
return response.data ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatRevenue = (cents: number) =>
|
||||||
|
new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CZK",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(cents / 100);
|
||||||
|
|
||||||
const resolvedStaff = () => staff() ?? [];
|
const resolvedStaff = () => staff() ?? [];
|
||||||
const resolvedLocations = () => locations() ?? [];
|
const resolvedLocations = () => locations() ?? [];
|
||||||
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
|
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
|
||||||
@@ -163,6 +188,46 @@ function StaffPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Show when={(metrics()?.staff?.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.staff.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={metrics()!.staff}>
|
||||||
|
{(m: any) => (
|
||||||
|
<div class="py-4 first:pt-0 last:pb-0">
|
||||||
|
<p class="font-medium text-ink mb-3">{m.displayName}</p>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-5 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.kpi.utilization")}</p>
|
||||||
|
<p class="text-lg font-semibold text-accent tabular-nums">{m.utilizationPercent}%</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>
|
||||||
|
</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">
|
||||||
|
|||||||
@@ -752,6 +752,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/metrics/staff": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Per-staff appointment metrics over a trailing window
|
||||||
|
* @description Bookings, completions, cancellations, utilization, and revenue per staff member. Only appointment bookings carry a staff assignment — class attendance is excluded by design. Every active staff member appears, including zero rows.
|
||||||
|
*/
|
||||||
|
get: operations["getStaffMetrics"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/public/bookings": {
|
"/v1/public/bookings": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1555,6 +1575,28 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
};
|
};
|
||||||
|
StaffMetric: {
|
||||||
|
bookings: number;
|
||||||
|
cancelled: number;
|
||||||
|
completed: number;
|
||||||
|
displayName: string;
|
||||||
|
/**
|
||||||
|
* Format: int64
|
||||||
|
* @description Sum of linked service prices for non-cancelled bookings
|
||||||
|
*/
|
||||||
|
revenueCents: number;
|
||||||
|
/** Format: uuid */
|
||||||
|
staffId: string;
|
||||||
|
/** @description Share of bookings that stayed confirmed, 0-100 */
|
||||||
|
utilizationPercent: number;
|
||||||
|
};
|
||||||
|
StaffMetricsResponse: {
|
||||||
|
/** Format: date-time */
|
||||||
|
from: string;
|
||||||
|
staff: components["schemas"]["StaffMetric"][];
|
||||||
|
/** Format: date-time */
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
SubscriptionSnapshot: {
|
SubscriptionSnapshot: {
|
||||||
cancelAtPeriodEnd: boolean;
|
cancelAtPeriodEnd: boolean;
|
||||||
checkoutUrlAvailable: boolean;
|
checkoutUrlAvailable: boolean;
|
||||||
@@ -3532,6 +3574,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getStaffMetrics: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
days?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Per-staff metrics */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["StaffMetricsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Tenant membership not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
createBooking: {
|
createBooking: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+11
-2
@@ -179,6 +179,15 @@
|
|||||||
- shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label
|
- shared `ExportCsvButton` (fetch + blob + object-URL download; the generated client is JSON-typed so it can't carry file responses) on the bookings and customers page headers, CS/EN label
|
||||||
- verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean
|
- verified live: real booking row with comma-in-name and quotes-in-notes escaped correctly, diacritics preserved, range bounds honored (inclusive `to`), three seeded customers exported; browser download produced the real file on the bookings page, customers button present, console clean
|
||||||
- remaining: live staging validation on Neon + pgx repository path
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
- `[done/partial]` Milestone 26: Staff performance view
|
||||||
|
- `GET /v1/metrics/staff?days=` — authed, tenant-scoped; trailing window defaults to 30 days, invalid/out-of-range values normalize to 30, max 365
|
||||||
|
- appointment bookings only — class sessions carry no `staff_id`, so class attendance is honestly excluded rather than silently misattributed; noted in the OpenAPI description
|
||||||
|
- per-staff metrics: bookings, completed (confirmed with `ends_at < now`), cancelled, utilization percent (confirmed share of the window), revenue cents from linked service prices over non-cancelled bookings
|
||||||
|
- every active staff member appears in the response including zero-booking rows — the service merges repository aggregates with the full staff list
|
||||||
|
- dashboard staff page gains a Performance panel above the member list: per-staff stat grid (bookings/completed/cancelled/utilization/revenue), CZK revenue formatting via `Intl.NumberFormat` per locale, CS/EN labels, responsive 2→5 column grid; demo mode fabricates rows matching the fabricated staff list for page coherence
|
||||||
|
- demo repository seeds four trailing-window bookings (3 confirmed + 1 cancelled) via `NewRepository`'s demo branch only — unit tests constructing `NewMemoryRepository` directly are unaffected
|
||||||
|
- verified live: endpoint returns 4/3/1, 75% utilization, 3 600 Kč; `days=7` and `days=999` normalize correctly; browser-checked both locales and mobile wrap, console clean; focused aggregation test passes
|
||||||
|
- 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.
|
||||||
@@ -432,8 +441,8 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
|
|||||||
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
||||||
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
||||||
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
|
- **Persistent notification center**: M24 put real activity events in the dropdown, but read state is still in-memory and there's no delivery history; a `notifications` table (or read-cursor on `activity_events`) would persist both.
|
||||||
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
- **Capacity-based utilization**: M26 measures utilization as the confirmed share of bookings; a truer measure compares booked minutes against each member's working-hours rules — needs availability-rule integration in the metrics query.
|
||||||
- **CSV export**: bookings and customers export for accounting/backup — landed in M25; a report-style export (per-staff utilization, revenue summaries) can follow once metrics stabilize.
|
- **Report-style exports**: M25 shipped raw row exports; a summarized report (per-staff metrics, revenue by service, period comparisons) can reuse `GetStaffMetrics`-style aggregation once the metric set stabilizes.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user