diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index c7bb66e..c532064 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -61,7 +61,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { catalogService := catalog.NewService(repository, billingService, notificationService) calendarService := calendar.NewService(repository) activityService := activity.NewService(repository) - exportService := export.NewService(repository) + exportService := export.NewService(repository, bookingService) authService := auth.NewService(repository, cfg.AuthJWTSecret) adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey) smsService := sms.NewService(cfg, repository) @@ -1354,6 +1354,38 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) }) + protected.GET("/export/staff-report.csv", func(c *gin.Context) { + days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) + var buf bytes.Buffer + err := exportService.WriteStaffReportCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf, days) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, export.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Disposition", `attachment; filename="bookra-staff-report.csv"`) + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) + }) + + protected.GET("/export/service-report.csv", func(c *gin.Context) { + days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) + var buf bytes.Buffer + err := exportService.WriteServiceReportCSV(c.Request.Context(), auth.PrincipalFromContext(c), &buf, days) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, export.ErrTenantMembership) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.Header("Content-Disposition", `attachment; filename="bookra-service-report.csv"`) + c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes()) + }) + protected.GET("/billing/subscription", func(c *gin.Context) { response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c)) if err != nil { diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 3ea9cae..268d22d 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -8,6 +8,7 @@ import ( "fmt" "net/mail" "sort" + "strconv" "strings" "time" @@ -701,22 +702,83 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal, if err != nil { return domain.StaffMetricsResponse{}, err } + rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID) + if err != nil { + return domain.StaffMetricsResponse{}, err + } + loc, err := time.LoadLocation(membership.Tenant.Timezone) + if err != nil { + loc = time.UTC + } + capacity := staffCapacityMinutes(rules, from, to, loc) + metrics := make([]domain.StaffMetric, 0, len(staff)) for _, member := range staff { rec := byStaff[member.ID] + capMinutes := capacity[member.ID] + utilization := 0 + if capMinutes > 0 { + utilization = int(float64(rec.BookedMinutes) / float64(capMinutes) * 100) + } metrics = append(metrics, domain.StaffMetric{ StaffID: member.ID, DisplayName: member.DisplayName, Bookings: rec.BookingsCount, Completed: rec.CompletedCount, Cancelled: rec.CancellationsCount, - UtilizationPercent: rec.UtilizationPercent, + BookedMinutes: rec.BookedMinutes, + CapacityMinutes: capMinutes, + UtilizationPercent: utilization, RevenueCents: rec.RevenueCents, }) } return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil } +// staffCapacityMinutes sums each member's availability-rule minutes across the +// window, evaluated on local calendar dates in the tenant timezone. Rules +// without a staff assignment are tenant-wide and don't map to a member. +func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, from, to time.Time, loc *time.Location) map[string]int { + byStaff := map[string]int{} + for day := from.In(loc); day.Before(to.In(loc)); day = day.AddDate(0, 0, 1) { + weekday := int(day.Weekday()) + for _, rule := range rules { + if rule.StaffID == nil || rule.DayOfWeek != weekday { + continue + } + byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal) + } + } + return byStaff +} + +// localMinutesBetween returns the minutes between two "HH:MM[:SS]" local times, +// or zero when the range is empty or malformed. +func localMinutesBetween(starts, ends string) int { + start := parseLocalMinutes(starts) + end := parseLocalMinutes(ends) + if end <= start { + return 0 + } + return end - start +} + +func parseLocalMinutes(hhmm string) int { + parts := strings.SplitN(hhmm, ":", 3) + if len(parts) < 2 { + return 0 + } + hours, err := strconv.Atoi(parts[0]) + if err != nil { + return 0 + } + minutes, err := strconv.Atoi(parts[1]) + if err != nil { + return 0 + } + return hours*60 + minutes +} + // 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 6f9af30..7a68eee 100644 --- a/apps/backend/internal/bookings/service_test.go +++ b/apps/backend/internal/bookings/service_test.go @@ -833,8 +833,15 @@ func TestStaffMetricsAggregatesPerStaff(t *testing.T) { 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.BookedMinutes != 120 { + t.Fatalf("expected 120 booked minutes (2×60min confirmed), got %d", row.BookedMinutes) + } + if row.CapacityMinutes <= 0 { + t.Fatalf("expected positive capacity from Mon–Fri 9–17 rules, got %d", row.CapacityMinutes) + } + expectedUtilization := row.BookedMinutes * 100 / row.CapacityMinutes + if row.UtilizationPercent != expectedUtilization { + t.Fatalf("expected utilization %d (booked/capacity), got %d", expectedUtilization, 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 c484e45..9482b6b 100644 --- a/apps/backend/internal/db/bookings.go +++ b/apps/backend/internal/db/bookings.go @@ -262,9 +262,9 @@ func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, sta 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)), + SUM(EXTRACT(EPOCH FROM (b.ends_at - b.starts_at)) / 60) FILTER (WHERE b.status <> 'cancelled'), 0 - )::integer AS utilization_percent, + )::integer AS booked_minutes, 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 @@ -281,7 +281,7 @@ func (r *PGRepository) GetStaffMetrics(ctx context.Context, tenantID string, sta for rows.Next() { var rec StaffMetricsRecord if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount, - &rec.CompletedCount, &rec.UtilizationPercent, &rec.RevenueCents); err != nil { + &rec.CompletedCount, &rec.BookedMinutes, &rec.RevenueCents); err != nil { return nil, err } records = append(records, rec) diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 435421d..50decbc 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -315,7 +315,7 @@ type StaffMetricsRecord struct { BookingsCount int CancellationsCount int CompletedCount int - UtilizationPercent int + BookedMinutes int RevenueCents int64 } @@ -1806,22 +1806,19 @@ func (r *MemoryRepository) GetStaffMetrics(_ context.Context, tenantID string, s 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]) + if booking.Status != "cancelled" { + rec.BookedMinutes += int(booking.EndsAt.Sub(booking.StartsAt).Minutes()) + if 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 }) diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 85664fd..22e1728 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -560,12 +560,16 @@ type ActivityFeedResponse struct { // StaffMetric aggregates appointment workload for one staff member over a // trailing window. Class bookings carry no staff assignment and are excluded. +// UtilizationPercent is bookedMinutes / capacityMinutes — booked time against +// the member's availability-rule capacity in the tenant timezone. type StaffMetric struct { StaffID string `json:"staffId"` DisplayName string `json:"displayName"` Bookings int `json:"bookings"` Completed int `json:"completed"` Cancelled int `json:"cancelled"` + BookedMinutes int `json:"bookedMinutes"` + CapacityMinutes int `json:"capacityMinutes"` UtilizationPercent int `json:"utilizationPercent"` RevenueCents int64 `json:"revenueCents"` } diff --git a/apps/backend/internal/export/service.go b/apps/backend/internal/export/service.go index 383e5a9..3a50116 100644 --- a/apps/backend/internal/export/service.go +++ b/apps/backend/internal/export/service.go @@ -5,8 +5,10 @@ import ( "encoding/csv" "errors" "io" + "strconv" "time" + "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) @@ -19,11 +21,12 @@ var utf8BOM = []byte{0xEF, 0xBB, 0xBF} const customerPageSize = 500 type Service struct { - repo db.Repository + repo db.Repository + bookings *bookings.Service } -func NewService(repo db.Repository) *Service { - return &Service{repo: repo} +func NewService(repo db.Repository, bookingsService *bookings.Service) *Service { + return &Service{repo: repo, bookings: bookingsService} } func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) { @@ -172,3 +175,126 @@ func (s *Service) WriteCustomersCSV(ctx context.Context, principal domain.Princi } return out.Error() } + +// WriteStaffReportCSV exports the per-staff performance metrics over a +// trailing window — the same numbers the dashboard performance panel shows. +func (s *Service) WriteStaffReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error { + metrics, err := s.bookings.StaffMetrics(ctx, principal, days) + if err != nil { + if errors.Is(err, bookings.ErrTenantMembership) { + return ErrTenantMembership + } + return err + } + + if _, err := w.Write(utf8BOM); err != nil { + return err + } + out := csv.NewWriter(w) + defer out.Flush() + + if err := out.Write([]string{ + "staff", "bookings", "completed", "cancelled", + "booked_minutes", "capacity_minutes", "utilization_percent", "revenue_cents", + }); err != nil { + return err + } + for _, m := range metrics.Staff { + if err := out.Write([]string{ + m.DisplayName, + strconv.Itoa(m.Bookings), + strconv.Itoa(m.Completed), + strconv.Itoa(m.Cancelled), + strconv.Itoa(m.BookedMinutes), + strconv.Itoa(m.CapacityMinutes), + strconv.Itoa(m.UtilizationPercent), + strconv.FormatInt(m.RevenueCents, 10), + }); err != nil { + return err + } + } + return out.Error() +} + +// WriteServiceReportCSV aggregates appointment bookings per catalog service +// over a trailing window. Class bookings have no service or price and roll +// into a final "class sessions" row when present. +func (s *Service) WriteServiceReportCSV(ctx context.Context, principal domain.Principal, w io.Writer, days int) error { + tenant, err := s.tenantFor(ctx, principal) + if err != nil { + return err + } + if days <= 0 || days > 365 { + days = 30 + } + to := time.Now().UTC() + from := to.AddDate(0, 0, -days) + + bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, tenant.ID, from, to) + if err != nil { + return err + } + services, err := s.repo.ListServicesByTenant(ctx, tenant.ID) + if err != nil { + return err + } + prices := make(map[string]int, len(services)) + for _, service := range services { + prices[service.ID] = service.PriceCents + } + + type aggregate struct { + bookings int + cancelled int + revenue int64 + } + byService := make(map[string]*aggregate, len(services)) + classBookings := 0 + for _, booking := range bookingsList { + if booking.ServiceID == nil { + classBookings++ + continue + } + agg := byService[*booking.ServiceID] + if agg == nil { + agg = &aggregate{} + byService[*booking.ServiceID] = agg + } + agg.bookings++ + if booking.Status == "cancelled" { + agg.cancelled++ + } else { + agg.revenue += int64(prices[*booking.ServiceID]) + } + } + + if _, err := w.Write(utf8BOM); err != nil { + return err + } + out := csv.NewWriter(w) + defer out.Flush() + + if err := out.Write([]string{"service", "bookings", "cancelled", "revenue_cents"}); err != nil { + return err + } + for _, service := range services { + agg := byService[service.ID] + if agg == nil { + agg = &aggregate{} + } + if err := out.Write([]string{ + service.Name, + strconv.Itoa(agg.bookings), + strconv.Itoa(agg.cancelled), + strconv.FormatInt(agg.revenue, 10), + }); err != nil { + return err + } + } + if classBookings > 0 { + if err := out.Write([]string{"class sessions", strconv.Itoa(classBookings), "0", "0"}); err != nil { + return err + } + } + return out.Error() +} diff --git a/apps/backend/internal/export/service_test.go b/apps/backend/internal/export/service_test.go index 45a7ab0..2c22442 100644 --- a/apps/backend/internal/export/service_test.go +++ b/apps/backend/internal/export/service_test.go @@ -8,12 +8,17 @@ import ( "testing" "time" + "bookra/apps/backend/internal/bookings" "bookra/apps/backend/internal/db" "bookra/apps/backend/internal/domain" ) var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"} +func newTestService(repo *db.MemoryRepository) *Service { + return NewService(repo, bookings.NewService(repo, nil, "http://localhost:3000")) +} + const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148" func readCSV(t *testing.T, buf *bytes.Buffer) [][]string { @@ -50,7 +55,7 @@ func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) { } var buf bytes.Buffer - svc := NewService(repo) + svc := newTestService(repo) from := time.Now().Add(-time.Hour) to := time.Now().Add(72 * time.Hour) if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil { @@ -96,7 +101,7 @@ func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) { } var buf bytes.Buffer - svc := NewService(repo) + svc := newTestService(repo) from := time.Now().Add(-time.Hour) to := time.Now().Add(24 * time.Hour) // far-future booking outside if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil { @@ -112,7 +117,7 @@ func TestWriteBookingsCSVRangeExcludesOutOfWindow(t *testing.T) { func TestWriteCustomersCSVPagesAndStrips(t *testing.T) { repo := db.NewMemoryRepository() var buf bytes.Buffer - svc := NewService(repo) + svc := newTestService(repo) if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil { t.Fatalf("write csv: %v", err) } @@ -136,7 +141,7 @@ func TestWriteCustomersCSVPagesAndStrips(t *testing.T) { func TestExportRequiresMembership(t *testing.T) { repo := db.NewMemoryRepository() - svc := NewService(repo) + svc := newTestService(repo) var buf bytes.Buffer err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf) if err != ErrTenantMembership { @@ -147,3 +152,96 @@ func TestExportRequiresMembership(t *testing.T) { } } + +func TestWriteStaffReportCSV(t *testing.T) { + repo := db.NewMemoryRepository() + serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" + staffID := "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0" + now := time.Now().UTC() + seed := func(status string, offsetHours int) { + _, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: demoTenantID, + ServiceID: &serviceID, + StaffID: &staffID, + CustomerName: "Report Customer", + CustomerEmail: "report@example.com", + StartsAt: now.Add(time.Duration(offsetHours) * time.Hour), + EndsAt: now.Add(time.Duration(offsetHours+1) * time.Hour), + Status: status, + Reference: "BK-REP-" + status, + }) + if err != nil { + t.Fatalf("seed booking: %v", err) + } + } + seed("confirmed", -5) + seed("confirmed", -20) + seed("cancelled", -30) + + var buf bytes.Buffer + svc := newTestService(repo) + if err := svc.WriteStaffReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil { + t.Fatalf("staff report csv: %v", err) + } + records := readCSV(t, &buf) + if len(records) != 2 { + t.Fatalf("expected header + 1 staff row, got %d rows", len(records)) + } + row := records[1] + if row[0] != "Demo Stylist" { + t.Fatalf("expected staff name, got %q", row[0]) + } + if row[1] != "3" || row[2] != "2" || row[3] != "1" { + t.Fatalf("expected bookings=3 completed=2 cancelled=1, got %v", row[1:4]) + } + if row[4] != "120" { + t.Fatalf("expected 120 booked minutes, got %q", row[4]) + } + if row[7] != "240000" { + t.Fatalf("expected 240000 revenue cents, got %q", row[7]) + } +} + +func TestWriteServiceReportCSV(t *testing.T) { + repo := db.NewMemoryRepository() + serviceID := "d5d76a61-3d49-467c-8dd4-bf61ee754e39" + sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435" + now := time.Now().UTC() + seed := func(service *string, session *string, status string, ref string) { + _, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{ + TenantID: demoTenantID, + ServiceID: service, + ClassSessionID: session, + CustomerName: "Svc Customer", + CustomerEmail: "svc@example.com", + StartsAt: now.Add(-5 * time.Hour), + EndsAt: now.Add(-4 * time.Hour), + Status: status, + Reference: ref, + }) + if err != nil { + t.Fatalf("seed booking: %v", err) + } + } + seed(&serviceID, nil, "confirmed", "BK-SVC-1") + seed(&serviceID, nil, "cancelled", "BK-SVC-2") + seed(nil, &sessionID, "confirmed", "BK-SVC-3") + + var buf bytes.Buffer + svc := newTestService(repo) + if err := svc.WriteServiceReportCSV(context.Background(), demoPrincipal, &buf, 30); err != nil { + t.Fatalf("service report csv: %v", err) + } + records := readCSV(t, &buf) + if len(records) != 3 { + t.Fatalf("expected header + service row + class row, got %d rows", len(records)) + } + svcRow := records[1] + if svcRow[0] != "Signature treatment" || svcRow[1] != "2" || svcRow[2] != "1" || svcRow[3] != "120000" { + t.Fatalf("unexpected service row: %v", svcRow) + } + classRow := records[2] + if classRow[0] != "class sessions" || classRow[1] != "1" { + t.Fatalf("unexpected class row: %v", classRow) + } +} diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index e46b493..6322a90 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -1531,6 +1531,60 @@ paths: type: string "404": description: Tenant membership not found + /v1/export/staff-report.csv: + get: + tags: [Export] + operationId: exportStaffReportCsv + security: + - bearerAuth: [] + summary: Export per-staff performance report as CSV + description: >- + Summarized per-staff metrics — bookings, completed, cancelled, booked + and capacity minutes, capacity-based utilization, revenue — over a + trailing window. Same aggregation as `GET /v1/metrics/staff`. + parameters: + - name: days + in: query + schema: + type: integer + default: 30 + maximum: 365 + responses: + "200": + description: CSV file download + content: + text/csv: + schema: + type: string + "404": + description: Tenant membership not found + /v1/export/service-report.csv: + get: + tags: [Export] + operationId: exportServiceReportCsv + security: + - bearerAuth: [] + summary: Export per-service booking report as CSV + description: >- + Bookings, cancellations, and revenue aggregated per catalog service + over a trailing window. Class bookings roll into a final + "class sessions" row when present. + parameters: + - name: days + in: query + schema: + type: integer + default: 30 + maximum: 365 + responses: + "200": + description: CSV file download + content: + text/csv: + schema: + type: string + "404": + description: Tenant membership not found /v1/public/calendar/{token}: get: tags: [Calendar] @@ -2219,7 +2273,7 @@ components: description: Caller's read cursor; absent until the first mark-read StaffMetric: type: object - required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents] + required: [staffId, displayName, bookings, completed, cancelled, bookedMinutes, capacityMinutes, utilizationPercent, revenueCents] properties: staffId: type: string @@ -2232,9 +2286,15 @@ components: type: integer cancelled: type: integer + bookedMinutes: + type: integer + description: Sum of non-cancelled booking durations in the window + capacityMinutes: + type: integer + description: Sum of the member's availability-rule minutes across the window, evaluated on local dates in the tenant timezone utilizationPercent: type: integer - description: Share of bookings that stayed confirmed, 0-100 + description: bookedMinutes / capacityMinutes as a percentage; 0 when the member has no availability rules revenueCents: type: integer format: int64 diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx index 273c633..148c446 100644 --- a/apps/frontend/src/routes/dashboard/services-page.tsx +++ b/apps/frontend/src/routes/dashboard/services-page.tsx @@ -3,6 +3,7 @@ import { apiClient } from "../../lib/api-client"; import { Input } from "../../components/ui/input"; import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons"; import { DashboardLayout, useDashboardData } from "./layout"; +import { ExportCsvButton } from "../../components/dashboard/export-csv-button"; interface ServiceForm { name: string; @@ -122,7 +123,10 @@ function ServicesPage() {
{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}
- +{data.i18n.t("dashboard.kpi.utilization")}
{m.utilizationPercent}%
+{formatHours(m.bookedMinutes)} / {formatHours(m.capacityMinutes)}
{data.i18n.t("dashboard.staff.revenue")}
diff --git a/packages/api-client/src/generated/types.ts b/packages/api-client/src/generated/types.ts index 8ac0515..60e3fe5 100644 --- a/packages/api-client/src/generated/types.ts +++ b/packages/api-client/src/generated/types.ts @@ -724,6 +724,46 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/export/service-report.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export per-service booking report as CSV + * @description Bookings, cancellations, and revenue aggregated per catalog service over a trailing window. Class bookings roll into a final "class sessions" row when present. + */ + get: operations["exportServiceReportCsv"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/export/staff-report.csv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export per-staff performance report as CSV + * @description Summarized per-staff metrics — bookings, completed, cancelled, booked and capacity minutes, capacity-based utilization, revenue — over a trailing window. Same aggregation as `GET /v1/metrics/staff`. + */ + get: operations["exportStaffReportCsv"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/internal/jobs/reminders/dispatch": { parameters: { query?: never; @@ -1603,8 +1643,12 @@ export interface components { tenantId: string; }; StaffMetric: { + /** @description Sum of non-cancelled booking durations in the window */ + bookedMinutes: number; bookings: number; cancelled: number; + /** @description Sum of the member's availability-rule minutes across the window, evaluated on local dates in the tenant timezone */ + capacityMinutes: number; completed: number; displayName: string; /** @@ -1614,7 +1658,7 @@ export interface components { revenueCents: number; /** Format: uuid */ staffId: string; - /** @description Share of bookings that stayed confirmed, 0-100 */ + /** @description bookedMinutes / capacityMinutes as a percentage; 0 when the member has no availability rules */ utilizationPercent: number; }; StaffMetricsResponse: { @@ -3555,6 +3599,64 @@ export interface operations { }; }; }; + exportServiceReportCsv: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV file download */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + /** @description Tenant membership not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + exportStaffReportCsv: { + parameters: { + query?: { + days?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description CSV file download */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/csv": string; + }; + }; + /** @description Tenant membership not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; dispatchReminderJobs: { parameters: { query?: never; diff --git a/project.md b/project.md index 29b1ed7..e97a8fc 100644 --- a/project.md +++ b/project.md @@ -195,6 +195,14 @@ - dropdown derives per-item read state from `createdAt <= readAt` — only post-cursor events carry the unread dot; per-item clicks still mark locally for the session; the billing/trial nag stays session-scoped (state badge, not an event) - verified live: 3 unread → mark → 0 → new booking → exactly 1 unread; browser-confirmed the badge and dimming persist across reload; focused tests cover cursor lifecycle and the reminder event - remaining: live staging validation on Neon + pgx repository path +- `[done/partial]` Milestone 28: Reporting phase — capacity utilization + report exports + - utilization is now capacity-based: `bookedMinutes` (sum of non-cancelled booking durations) over `capacityMinutes` (sum of the member's availability-rule minutes across the window, evaluated on local calendar dates in the tenant timezone); the old confirmed-share measure was honest about cancellations but not workload + - `StaffMetric` exposes `bookedMinutes`/`capacityMinutes` so the basis is inspectable; the performance panel shows "booked h / capacity h" under the percentage + - export service now takes `bookings.Service` — `WriteStaffReportCSV` reuses `StaffMetrics` directly, no re-aggregation + - `GET /v1/export/staff-report.csv?days=` — per-staff summary rows; `GET /v1/export/service-report.csv?days=` — bookings/cancelled/revenue per catalog service, zero-filled, class bookings roll into a "class sessions" row; `days` normalizes like the metrics endpoint (default 30, max 365) + - `ExportCsvButton` on the staff Performance panel and the services page header + - verified live: staff report shows 4/3/1, 180/10560 minutes, 1% utilization, 3 600 Kč; service report aggregates correctly; browser download produced the real file; focused tests cover both reports and the capacity relationship + - remaining: live staging validation on Neon + pgx repository path ## 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. @@ -448,8 +456,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. - **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light. - **Browser push notifications**: M27 persists read state and the feed covers deliveries; Web Push (service worker + VAPID) would notify owners about new bookings without keeping the dashboard open — defer until a concrete request, email already covers off-site reach. -- **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. -- **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. +- **Service performance panel**: M28's service-report aggregation could surface in the dashboard (revenue by service, top performers) once the metric set proves stable — the CSV is the probe. +- **Scheduled report delivery**: period-end exports emailed automatically (cron + existing mail transport) instead of manual downloads — pairs naturally with M28's report endpoints. ## Product Principles - calm, premium, structured UX