mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat: data surfacing — intake labels, service metrics, honest capacity
- booking views gain intakeLabels (declared key→label join); dashboard modal + manage view render real question labels with slug fallback - GET /v1/metrics/services aggregates bookings/completed/cancelled/ revenue per service + classBookings; service-report.csv delegates to the same ServiceMetrics aggregation and gains a completed column - services page gains a Service performance card (per-service stat grid, locale CZK revenue, class-bookings footer); demo rows match the fabricated service list - staff capacity utilization subtracts blocked days — tenant-wide and staff-scoped blocks remove that day's rule minutes via blockedAt - tests: blocked-day capacity (tenant + staff scope), service aggregation Authored By: TDvorak <[email protected]> Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
34becc1bf4
commit
5c860a1264
16 files changed
+560
-69
No files matched your search
@@ -548,9 +548,11 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
}
|
||||
|
||||
serviceNames := map[string]string{}
|
||||
serviceIntakeLabels := map[string]map[string]string{}
|
||||
if services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID); err == nil {
|
||||
for _, service := range services {
|
||||
serviceNames[service.ID] = service.Name
|
||||
serviceIntakeLabels[service.ID] = shared.IntakeLabels(service.IntakeFields)
|
||||
}
|
||||
}
|
||||
locationNames := map[string]string{}
|
||||
@@ -580,6 +582,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
||||
}
|
||||
if booking.ServiceID != nil {
|
||||
out.Service = serviceNames[*booking.ServiceID]
|
||||
out.IntakeLabels = serviceIntakeLabels[*booking.ServiceID]
|
||||
}
|
||||
if booking.LocationID != nil {
|
||||
out.Location = locationNames[*booking.LocationID]
|
||||
@@ -719,11 +722,15 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
||||
if err != nil {
|
||||
return domain.StaffMetricsResponse{}, err
|
||||
}
|
||||
blockedDays, err := s.repo.ListBlockedDaysByTenant(ctx, membership.Tenant.ID, from, to)
|
||||
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)
|
||||
capacity := staffCapacityMinutes(rules, blockedDays, from, to, loc)
|
||||
|
||||
metrics := make([]domain.StaffMetric, 0, len(staff))
|
||||
for _, member := range staff {
|
||||
@@ -748,17 +755,105 @@ func (s *Service) StaffMetrics(ctx context.Context, principal domain.Principal,
|
||||
return domain.StaffMetricsResponse{From: from, To: to, Staff: metrics}, nil
|
||||
}
|
||||
|
||||
// ServiceMetrics aggregates appointment bookings per catalog service over a
|
||||
// trailing window — every service appears, even with zero bookings. Class
|
||||
// bookings carry no service and are counted separately.
|
||||
func (s *Service) ServiceMetrics(ctx context.Context, principal domain.Principal, days int) (domain.ServiceMetricsResponse, error) {
|
||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.ServiceMetricsResponse{}, ErrTenantMembership
|
||||
}
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
if days <= 0 || days > 365 {
|
||||
days = 30
|
||||
}
|
||||
to := time.Now().UTC()
|
||||
from := to.AddDate(0, 0, -days)
|
||||
|
||||
bookingsList, err := s.repo.ListBookingsByTenantBetween(ctx, membership.Tenant.ID, from, to)
|
||||
if err != nil {
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
services, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
|
||||
if err != nil {
|
||||
return domain.ServiceMetricsResponse{}, err
|
||||
}
|
||||
prices := make(map[string]int, len(services))
|
||||
for _, service := range services {
|
||||
prices[service.ID] = service.PriceCents
|
||||
}
|
||||
|
||||
type aggregate struct {
|
||||
bookings int
|
||||
completed 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++
|
||||
switch booking.Status {
|
||||
case "cancelled":
|
||||
agg.cancelled++
|
||||
case "completed":
|
||||
agg.completed++
|
||||
}
|
||||
if booking.Status != "cancelled" {
|
||||
agg.revenue += int64(prices[*booking.ServiceID])
|
||||
}
|
||||
}
|
||||
|
||||
metrics := make([]domain.ServiceMetric, 0, len(services))
|
||||
for _, service := range services {
|
||||
agg := byService[service.ID]
|
||||
if agg == nil {
|
||||
agg = &aggregate{}
|
||||
}
|
||||
metrics = append(metrics, domain.ServiceMetric{
|
||||
ServiceID: service.ID,
|
||||
Name: service.Name,
|
||||
Bookings: agg.bookings,
|
||||
Completed: agg.completed,
|
||||
Cancelled: agg.cancelled,
|
||||
RevenueCents: agg.revenue,
|
||||
})
|
||||
}
|
||||
return domain.ServiceMetricsResponse{
|
||||
From: from,
|
||||
To: to,
|
||||
Services: metrics,
|
||||
ClassBookings: classBookings,
|
||||
}, 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 {
|
||||
// without a staff assignment are tenant-wide and don't map to a member. A day
|
||||
// blocked for the member (or tenant-wide) contributes no capacity.
|
||||
func staffCapacityMinutes(rules []db.AvailabilityRuleRecord, blockedDays []db.BlockedDayRecord, 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())
|
||||
dayStart := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
|
||||
for _, rule := range rules {
|
||||
if rule.StaffID == nil || rule.DayOfWeek != weekday {
|
||||
continue
|
||||
}
|
||||
if blockedAt(blockedDays, rule.StaffID, dayStart, dayStart.AddDate(0, 0, 1)) {
|
||||
continue
|
||||
}
|
||||
byStaff[*rule.StaffID] += localMinutesBetween(rule.StartsLocal, rule.EndsLocal)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user