diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go
index b199ba0..91a0cbe 100644
--- a/apps/backend/internal/api/server.go
+++ b/apps/backend/internal/api/server.go
@@ -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
diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go
index a347e65..3ea9cae 100644
--- a/apps/backend/internal/bookings/service.go
+++ b/apps/backend/internal/bookings/service.go
@@ -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 {
diff --git a/apps/backend/internal/bookings/service_test.go b/apps/backend/internal/bookings/service_test.go
index 24488cf..6f9af30 100644
--- a/apps/backend/internal/bookings/service_test.go
+++ b/apps/backend/internal/bookings/service_test.go
@@ -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: "client@example.com",
+ 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: "class@example.com",
+ 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)
+ }
+}
diff --git a/apps/backend/internal/db/bookings.go b/apps/backend/internal/db/bookings.go
index 73061f4..c484e45 100644
--- a/apps/backend/internal/db/bookings.go
+++ b/apps/backend/internal/db/bookings.go
@@ -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, `
diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go
index f2687a3..1e5fc87 100644
--- a/apps/backend/internal/db/repository.go
+++ b/apps/backend/internal/db/repository.go
@@ -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", "alice@example.com", "confirmed", now.Add(-4*time.Hour))
+ seed("Bob Smith", "bob@example.com", "confirmed", now.Add(-26*time.Hour))
+ seed("Carol White", "carol@example.com", "confirmed", now.Add(-50*time.Hour))
+ seed("Alice Johnson", "alice@example.com", "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
diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go
index 7aa0ab7..51187a7 100644
--- a/apps/backend/internal/domain/models.go
+++ b/apps/backend/internal/domain/models.go
@@ -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
// ============================================
diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml
index c5e0bb7..e9f260c 100644
--- a/apps/backend/openapi/bookra.openapi.yaml
+++ b/apps/backend/openapi/bookra.openapi.yaml
@@ -1431,6 +1431,34 @@ paths:
$ref: "#/components/schemas/ActivityFeedResponse"
"404":
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:
get:
tags: [Export]
@@ -2158,6 +2186,42 @@ components:
type: array
items:
$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:
type: object
required: [customerName, customerEmail, startsAt]
diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx
index 1649fab..c50776e 100644
--- a/apps/frontend/src/providers/i18n-provider.tsx
+++ b/apps/frontend/src/providers/i18n-provider.tsx
@@ -475,6 +475,9 @@ const dictionaries = {
"dashboard.staff.inactive": "Neaktivní",
"dashboard.staff.noStaff": "Zatím žádní zaměstnanci.",
"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.limitDesc": "Váš plán omezuje počet zaměstnanců. Pro více míst upgradujte.",
"dashboard.staff.saveFailed": "Uložení zaměstnance selhalo.",
@@ -1076,6 +1079,9 @@ const dictionaries = {
"dashboard.staff.inactive": "Inactive",
"dashboard.staff.noStaff": "No staff yet.",
"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.limitDesc": "Your plan limits the number of staff. Upgrade for more seats.",
"dashboard.staff.saveFailed": "Failed to save staff member.",
diff --git a/apps/frontend/src/routes/dashboard/staff-page.tsx b/apps/frontend/src/routes/dashboard/staff-page.tsx
index 9f2dbc6..509292d 100644
--- a/apps/frontend/src/routes/dashboard/staff-page.tsx
+++ b/apps/frontend/src/routes/dashboard/staff-page.tsx
@@ -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 resolvedLocations = () => locations() ?? [];
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
@@ -163,6 +188,46 @@ function StaffPage() {
+ {m.displayName} {data.i18n.t("dashboard.bookings")} {m.bookings} {data.i18n.t("dashboard.completed")} {m.completed} {data.i18n.t("dashboard.cancelled")} {m.cancelled} {data.i18n.t("dashboard.kpi.utilization")} {m.utilizationPercent}% {data.i18n.t("dashboard.staff.revenue")} {formatRevenue(m.revenueCents)}{data.i18n.t("dashboard.staff.performance")}
+ {data.i18n.t("dashboard.staff.performanceWindow")}
+