mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #11 from Dvorinka/feat/reporting-phase
feat(reports): capacity utilization + report exports
This commit is contained in:
13 files changed
+537
-29
No files matched your search
@@ -61,7 +61,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
catalogService := catalog.NewService(repository, billingService, notificationService)
|
catalogService := catalog.NewService(repository, billingService, notificationService)
|
||||||
calendarService := calendar.NewService(repository)
|
calendarService := calendar.NewService(repository)
|
||||||
activityService := activity.NewService(repository)
|
activityService := activity.NewService(repository)
|
||||||
exportService := export.NewService(repository)
|
exportService := export.NewService(repository, bookingService)
|
||||||
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
authService := auth.NewService(repository, cfg.AuthJWTSecret)
|
||||||
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
|
||||||
smsService := sms.NewService(cfg, repository)
|
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())
|
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) {
|
protected.GET("/billing/subscription", func(c *gin.Context) {
|
||||||
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -701,22 +702,83 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.StaffMetricsResponse{}, err
|
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))
|
metrics := make([]domain.StaffMetric, 0, len(staff))
|
||||||
for _, member := range staff {
|
for _, member := range staff {
|
||||||
rec := byStaff[member.ID]
|
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{
|
metrics = append(metrics, domain.StaffMetric{
|
||||||
StaffID: member.ID,
|
StaffID: member.ID,
|
||||||
DisplayName: member.DisplayName,
|
DisplayName: member.DisplayName,
|
||||||
Bookings: rec.BookingsCount,
|
Bookings: rec.BookingsCount,
|
||||||
Completed: rec.CompletedCount,
|
Completed: rec.CompletedCount,
|
||||||
Cancelled: rec.CancellationsCount,
|
Cancelled: rec.CancellationsCount,
|
||||||
UtilizationPercent: rec.UtilizationPercent,
|
BookedMinutes: rec.BookedMinutes,
|
||||||
|
CapacityMinutes: capMinutes,
|
||||||
|
UtilizationPercent: utilization,
|
||||||
RevenueCents: rec.RevenueCents,
|
RevenueCents: rec.RevenueCents,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
|
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:
|
// 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 {
|
||||||
|
|||||||
@@ -833,8 +833,15 @@ func TestStaffMetricsAggregatesPerStaff(t *testing.T) {
|
|||||||
if row.Cancelled != 1 {
|
if row.Cancelled != 1 {
|
||||||
t.Fatalf("expected 1 cancellation, got %d", row.Cancelled)
|
t.Fatalf("expected 1 cancellation, got %d", row.Cancelled)
|
||||||
}
|
}
|
||||||
if row.UtilizationPercent != 66 {
|
if row.BookedMinutes != 120 {
|
||||||
t.Fatalf("expected 66%% utilization, got %d", row.UtilizationPercent)
|
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 {
|
if row.RevenueCents != 240000 {
|
||||||
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
t.Fatalf("expected 240000 revenue cents (2×1200 Kč), got %d", row.RevenueCents)
|
||||||
|
|||||||
@@ -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 = 'cancelled') AS cancellations_count,
|
||||||
COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count,
|
COUNT(*) FILTER (WHERE b.status = 'confirmed' AND b.ends_at < now()) AS completed_count,
|
||||||
COALESCE(
|
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
|
0
|
||||||
)::integer AS utilization_percent,
|
)::integer AS booked_minutes,
|
||||||
COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents
|
COALESCE(SUM(s.price_cents) FILTER (WHERE b.status <> 'cancelled'), 0) AS revenue_cents
|
||||||
FROM bookings b
|
FROM bookings b
|
||||||
LEFT JOIN services s ON s.id = b.service_id
|
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() {
|
for rows.Next() {
|
||||||
var rec StaffMetricsRecord
|
var rec StaffMetricsRecord
|
||||||
if err := rows.Scan(&rec.StaffID, &rec.BookingsCount, &rec.CancellationsCount,
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
records = append(records, rec)
|
records = append(records, rec)
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ type StaffMetricsRecord struct {
|
|||||||
BookingsCount int
|
BookingsCount int
|
||||||
CancellationsCount int
|
CancellationsCount int
|
||||||
CompletedCount int
|
CompletedCount int
|
||||||
UtilizationPercent int
|
BookedMinutes int
|
||||||
RevenueCents int64
|
RevenueCents int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1806,22 +1806,19 @@ func (r *MemoryRepository) GetStaffMetrics(_ context.Context, tenantID string, s
|
|||||||
case booking.Status == "cancelled":
|
case booking.Status == "cancelled":
|
||||||
rec.CancellationsCount++
|
rec.CancellationsCount++
|
||||||
case booking.Status == "confirmed":
|
case booking.Status == "confirmed":
|
||||||
rec.UtilizationPercent++
|
|
||||||
if booking.EndsAt.Before(time.Now().UTC()) {
|
if booking.EndsAt.Before(time.Now().UTC()) {
|
||||||
rec.CompletedCount++
|
rec.CompletedCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if booking.Status != "cancelled" && booking.ServiceID != nil {
|
if booking.Status != "cancelled" {
|
||||||
|
rec.BookedMinutes += int(booking.EndsAt.Sub(booking.StartsAt).Minutes())
|
||||||
|
if booking.ServiceID != nil {
|
||||||
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
|
rec.RevenueCents += int64(servicePrices[*booking.ServiceID])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
records := make([]StaffMetricsRecord, 0, len(byStaff))
|
records := make([]StaffMetricsRecord, 0, len(byStaff))
|
||||||
for _, rec := range 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)
|
records = append(records, *rec)
|
||||||
}
|
}
|
||||||
sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount })
|
sort.SliceStable(records, func(i, j int) bool { return records[i].BookingsCount > records[j].BookingsCount })
|
||||||
|
|||||||
@@ -560,12 +560,16 @@ type ActivityFeedResponse struct {
|
|||||||
|
|
||||||
// StaffMetric aggregates appointment workload for one staff member over a
|
// StaffMetric aggregates appointment workload for one staff member over a
|
||||||
// trailing window. Class bookings carry no staff assignment and are excluded.
|
// 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 {
|
type StaffMetric struct {
|
||||||
StaffID string `json:"staffId"`
|
StaffID string `json:"staffId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Bookings int `json:"bookings"`
|
Bookings int `json:"bookings"`
|
||||||
Completed int `json:"completed"`
|
Completed int `json:"completed"`
|
||||||
Cancelled int `json:"cancelled"`
|
Cancelled int `json:"cancelled"`
|
||||||
|
BookedMinutes int `json:"bookedMinutes"`
|
||||||
|
CapacityMinutes int `json:"capacityMinutes"`
|
||||||
UtilizationPercent int `json:"utilizationPercent"`
|
UtilizationPercent int `json:"utilizationPercent"`
|
||||||
RevenueCents int64 `json:"revenueCents"`
|
RevenueCents int64 `json:"revenueCents"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/bookings"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
)
|
)
|
||||||
@@ -20,10 +22,11 @@ const customerPageSize = 500
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repo db.Repository
|
repo db.Repository
|
||||||
|
bookings *bookings.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(repo db.Repository) *Service {
|
func NewService(repo db.Repository, bookingsService *bookings.Service) *Service {
|
||||||
return &Service{repo: repo}
|
return &Service{repo: repo, bookings: bookingsService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
|
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()
|
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()
|
||||||
|
}
|
||||||
@@ -8,12 +8,17 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/bookings"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var demoPrincipal = domain.Principal{Subject: "demo-owner", Role: "owner"}
|
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"
|
const demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
|
||||||
|
|
||||||
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
|
func readCSV(t *testing.T, buf *bytes.Buffer) [][]string {
|
||||||
@@ -50,7 +55,7 @@ func TestWriteBookingsCSVResolvesLabelsAndEscapes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
svc := NewService(repo)
|
svc := newTestService(repo)
|
||||||
from := time.Now().Add(-time.Hour)
|
from := time.Now().Add(-time.Hour)
|
||||||
to := time.Now().Add(72 * time.Hour)
|
to := time.Now().Add(72 * time.Hour)
|
||||||
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
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
|
var buf bytes.Buffer
|
||||||
svc := NewService(repo)
|
svc := newTestService(repo)
|
||||||
from := time.Now().Add(-time.Hour)
|
from := time.Now().Add(-time.Hour)
|
||||||
to := time.Now().Add(24 * time.Hour) // far-future booking outside
|
to := time.Now().Add(24 * time.Hour) // far-future booking outside
|
||||||
if err := svc.WriteBookingsCSV(context.Background(), demoPrincipal, &buf, from, to); err != nil {
|
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) {
|
func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
|
||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
svc := NewService(repo)
|
svc := newTestService(repo)
|
||||||
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
|
if err := svc.WriteCustomersCSV(context.Background(), demoPrincipal, &buf); err != nil {
|
||||||
t.Fatalf("write csv: %v", err)
|
t.Fatalf("write csv: %v", err)
|
||||||
}
|
}
|
||||||
@@ -136,7 +141,7 @@ func TestWriteCustomersCSVPagesAndStrips(t *testing.T) {
|
|||||||
|
|
||||||
func TestExportRequiresMembership(t *testing.T) {
|
func TestExportRequiresMembership(t *testing.T) {
|
||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
svc := NewService(repo)
|
svc := newTestService(repo)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
|
err := svc.WriteCustomersCSV(context.Background(), domain.Principal{Subject: "nobody"}, &buf)
|
||||||
if err != ErrTenantMembership {
|
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: "[email protected]",
|
||||||
|
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: "[email protected]",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1531,6 +1531,60 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
"404":
|
"404":
|
||||||
description: Tenant membership not found
|
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}:
|
/v1/public/calendar/{token}:
|
||||||
get:
|
get:
|
||||||
tags: [Calendar]
|
tags: [Calendar]
|
||||||
@@ -2219,7 +2273,7 @@ components:
|
|||||||
description: Caller's read cursor; absent until the first mark-read
|
description: Caller's read cursor; absent until the first mark-read
|
||||||
StaffMetric:
|
StaffMetric:
|
||||||
type: object
|
type: object
|
||||||
required: [staffId, displayName, bookings, completed, cancelled, utilizationPercent, revenueCents]
|
required: [staffId, displayName, bookings, completed, cancelled, bookedMinutes, capacityMinutes, utilizationPercent, revenueCents]
|
||||||
properties:
|
properties:
|
||||||
staffId:
|
staffId:
|
||||||
type: string
|
type: string
|
||||||
@@ -2232,9 +2286,15 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
cancelled:
|
cancelled:
|
||||||
type: integer
|
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:
|
utilizationPercent:
|
||||||
type: integer
|
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:
|
revenueCents:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { apiClient } from "../../lib/api-client";
|
|||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
import { PlusIcon, ScissorsIcon, Trash2Icon, EditIcon, ClockIcon } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||||
|
|
||||||
interface ServiceForm {
|
interface ServiceForm {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -122,8 +123,11 @@ function ServicesPage() {
|
|||||||
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.services")}</h1>
|
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.services")}</h1>
|
||||||
<p class="text-ink-muted mt-1">{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}</p>
|
<p class="text-ink-muted mt-1">{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<ExportCsvButton path="/v1/export/service-report.csv?days=30" filename="bookra-service-report.csv" token={data.token} />
|
||||||
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
|
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="surface-card overflow-hidden">
|
<div class="surface-card overflow-hidden">
|
||||||
<Show when={showForm()}>
|
<Show when={showForm()}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input } from "../../components/ui/input";
|
|||||||
import { Select } from "../../components/ui/select";
|
import { Select } from "../../components/ui/select";
|
||||||
import { PlusIcon, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons";
|
import { PlusIcon, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
import { ExportCsvButton } from "../../components/dashboard/export-csv-button";
|
||||||
|
|
||||||
interface StaffForm {
|
interface StaffForm {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -62,9 +63,9 @@ function StaffPage() {
|
|||||||
if (bearer.startsWith("demo.")) {
|
if (bearer.startsWith("demo.")) {
|
||||||
return {
|
return {
|
||||||
staff: [
|
staff: [
|
||||||
{ staffId: "st1", displayName: "Petra M.", bookings: 12, completed: 9, cancelled: 1, utilizationPercent: 92, revenueCents: 1440000 },
|
{ staffId: "st1", displayName: "Petra M.", bookings: 12, completed: 9, cancelled: 1, bookedMinutes: 9300, capacityMinutes: 10080, utilizationPercent: 92, revenueCents: 1440000 },
|
||||||
{ staffId: "st2", displayName: "Lucie K.", bookings: 8, completed: 5, cancelled: 2, utilizationPercent: 63, revenueCents: 960000 },
|
{ staffId: "st2", displayName: "Lucie K.", bookings: 8, completed: 5, cancelled: 2, bookedMinutes: 6350, capacityMinutes: 10080, utilizationPercent: 63, revenueCents: 960000 },
|
||||||
{ staffId: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", bookings: 0, completed: 0, cancelled: 0, utilizationPercent: 0, revenueCents: 0 },
|
{ staffId: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", bookings: 0, completed: 0, cancelled: 0, bookedMinutes: 0, capacityMinutes: 10080, utilizationPercent: 0, revenueCents: 0 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -73,6 +74,9 @@ function StaffPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatHours = (minutes: number) =>
|
||||||
|
minutes >= 60 ? `${Math.round(minutes / 60)} h` : `${minutes} min`;
|
||||||
|
|
||||||
const formatRevenue = (cents: number) =>
|
const formatRevenue = (cents: number) =>
|
||||||
new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", {
|
new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -192,7 +196,10 @@ function StaffPage() {
|
|||||||
<div class="surface-card p-6">
|
<div class="surface-card p-6">
|
||||||
<div class="flex items-baseline justify-between gap-4 mb-5">
|
<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>
|
<h2 class="text-lg font-semibold text-ink font-display">{data.i18n.t("dashboard.staff.performance")}</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.performanceWindow")}</span>
|
<span class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.performanceWindow")}</span>
|
||||||
|
<ExportCsvButton path="/v1/export/staff-report.csv?days=30" filename="bookra-staff-report.csv" token={data.token} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-border/60">
|
<div class="divide-y divide-border/60">
|
||||||
<For each={metrics()!.staff}>
|
<For each={metrics()!.staff}>
|
||||||
@@ -215,6 +222,7 @@ function StaffPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.kpi.utilization")}</p>
|
<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>
|
<p class="text-lg font-semibold text-accent tabular-nums">{m.utilizationPercent}%</p>
|
||||||
|
<p class="text-[10px] text-ink-subtle tabular-nums">{formatHours(m.bookedMinutes)} / {formatHours(m.capacityMinutes)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.revenue")}</p>
|
<p class="text-xs text-ink-subtle">{data.i18n.t("dashboard.staff.revenue")}</p>
|
||||||
|
|||||||
@@ -724,6 +724,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/v1/internal/jobs/reminders/dispatch": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1603,8 +1643,12 @@ export interface components {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
};
|
};
|
||||||
StaffMetric: {
|
StaffMetric: {
|
||||||
|
/** @description Sum of non-cancelled booking durations in the window */
|
||||||
|
bookedMinutes: number;
|
||||||
bookings: number;
|
bookings: number;
|
||||||
cancelled: 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;
|
completed: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/**
|
/**
|
||||||
@@ -1614,7 +1658,7 @@ export interface components {
|
|||||||
revenueCents: number;
|
revenueCents: number;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
staffId: string;
|
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;
|
utilizationPercent: number;
|
||||||
};
|
};
|
||||||
StaffMetricsResponse: {
|
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: {
|
dispatchReminderJobs: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+10
-2
@@ -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)
|
- 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
|
- 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
|
- 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
|
## 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.
|
||||||
@@ -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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user