feat(catalog): add service and staff management end-to-end

Services and staff existed as schema + booking FK targets but had no
management surface. Adds full CRUD: repository (pgx + in-memory with
matching FK semantics), catalog service validation with tenant isolation,
MaxStaff entitlement enforcement, protected API routes, OpenAPI schemas,
and dashboard screens for both entities.

Dashboard summary now resolves staff names on bookings and computes
setup completion from real configured state. Booking modal selects from
the tenant's actual services; blocked days can be scoped per staff member.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 08:52:25 +02:00
1 parent 7a6223c07b
commit 09f780e7a6
21 files changed
+2421 -14

No files matched your search

+138
View File
@@ -541,6 +541,144 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "deleted"}) c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}) })
// ============================================
// CATALOG API - Services
// ============================================
protected.GET("/catalog/services", func(c *gin.Context) {
response, err := catalogService.ListServices(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/services", func(c *gin.Context) {
var request domain.CreateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateService(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/services/:serviceID", func(c *gin.Context) {
var request domain.UpdateServiceRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrServiceNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/services/:serviceID", func(c *gin.Context) {
err := catalogService.DeleteService(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("serviceID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrServiceNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
// ============================================
// CATALOG API - Staff
// ============================================
protected.GET("/catalog/staff", func(c *gin.Context) {
response, err := catalogService.ListStaff(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/catalog/staff", func(c *gin.Context) {
var request domain.CreateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.CreateStaff(c.Request.Context(), auth.PrincipalFromContext(c), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrPlanLimitReached):
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, response)
})
protected.PUT("/catalog/staff/:staffID", func(c *gin.Context) {
var request domain.UpdateStaffRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := catalogService.UpdateStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"), request)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrStaffNotFound):
status = http.StatusNotFound
case errors.Is(err, catalog.ErrInvalidInput):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.DELETE("/catalog/staff/:staffID", func(c *gin.Context) {
err := catalogService.DeleteStaff(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("staffID"))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, catalog.ErrTenantMembership) || errors.Is(err, catalog.ErrStaffNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
})
protected.POST("/catalog/bookings", func(c *gin.Context) { protected.POST("/catalog/bookings", func(c *gin.Context) {
var request domain.DashboardBookingRequest var request domain.DashboardBookingRequest
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
+41 -1
View File
@@ -355,6 +355,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
locationNames[location.ID] = location.Name locationNames[location.ID] = location.Name
} }
} }
staffNames := map[string]string{}
if staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID); err == nil {
for _, member := range staff {
staffNames[member.ID] = member.DisplayName
}
}
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking { toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
out := domain.UpcomingBooking{ out := domain.UpcomingBooking{
ID: booking.ID, ID: booking.ID,
@@ -372,6 +378,9 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
if booking.LocationID != nil { if booking.LocationID != nil {
out.Location = locationNames[*booking.LocationID] out.Location = locationNames[*booking.LocationID]
} }
if booking.StaffID != nil {
out.StaffName = staffNames[*booking.StaffID]
}
return out return out
} }
@@ -408,6 +417,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
allBookings = append(allBookings, toDomain(booking)) allBookings = append(allBookings, toDomain(booking))
} }
brandName := membership.Tenant.Name
if brand, err := s.repo.GetBrandProfile(ctx, membership.Tenant.ID); err == nil && strings.TrimSpace(brand.Name) != "" {
brandName = brand.Name
}
scheduleCount := 0
if rules, err := s.repo.ListAvailabilityRulesByTenant(ctx, membership.Tenant.ID); err == nil {
scheduleCount = len(rules)
}
return domain.DashboardSummary{ return domain.DashboardSummary{
TenantName: membership.Tenant.Name, TenantName: membership.Tenant.Name,
TenantSlug: membership.Tenant.Slug, TenantSlug: membership.Tenant.Slug,
@@ -415,7 +433,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
Timezone: membership.Tenant.Timezone, Timezone: membership.Tenant.Timezone,
PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode),
PublicBookingURL: "/book/" + membership.Tenant.Slug, PublicBookingURL: "/book/" + membership.Tenant.Slug,
SetupCompletion: 100, SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount),
KPIs: []domain.DashboardKPI{ KPIs: []domain.DashboardKPI{
{Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)}, {Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)},
{Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)}, {Code: "cancellations", Label: "Cancellations", Value: fmt.Sprintf("%d", metrics.CancellationsCount)},
@@ -428,6 +446,28 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
}, nil }, 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 {
score := 0
if strings.TrimSpace(brandName) != "" {
score += 20
}
if locationCount > 0 {
score += 20
}
if serviceCount > 0 {
score += 20
}
if staffCount > 0 {
score += 20
}
if scheduleCount > 0 {
score += 20
}
return score
}
func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet { func widgetSnippets(tenant db.TenantRecord) []domain.WidgetSnippet {
path := "/book/" + tenant.Slug path := "/book/" + tenant.Slug
return []domain.WidgetSnippet{ return []domain.WidgetSnippet{
+307
View File
@@ -18,6 +18,9 @@ var (
ErrBlockedDayNotFound = errors.New("blocked day not found") ErrBlockedDayNotFound = errors.New("blocked day not found")
ErrCustomerNotFound = errors.New("customer not found") ErrCustomerNotFound = errors.New("customer not found")
ErrBookingNotFound = errors.New("booking not found") ErrBookingNotFound = errors.New("booking not found")
ErrServiceNotFound = errors.New("service not found")
ErrStaffNotFound = errors.New("staff member not found")
ErrInvalidInput = errors.New("invalid request")
ErrInvalidBooking = errors.New("invalid booking request") ErrInvalidBooking = errors.New("invalid booking request")
ErrBookingConflict = errors.New("booking conflicts with an existing reservation") ErrBookingConflict = errors.New("booking conflicts with an existing reservation")
ErrTenantNotFound = errors.New("tenant not found") ErrTenantNotFound = errors.New("tenant not found")
@@ -183,6 +186,310 @@ func (s *Service) DeleteLocation(ctx context.Context, principal domain.Principal
return s.repo.DeleteLocation(ctx, locationID) return s.repo.DeleteLocation(ctx, locationID)
} }
// ============================================
// SERVICE CATALOG MANAGEMENT
// ============================================
func serviceToDomain(rec db.ServiceRecord) domain.Service {
return domain.Service{
ID: rec.ID,
TenantID: rec.TenantID,
Name: rec.Name,
DurationMinutes: rec.DurationMinutes,
BufferBeforeMinutes: rec.BufferBeforeMinutes,
BufferAfterMinutes: rec.BufferAfterMinutes,
PriceCents: rec.PriceCents,
CreatedAt: rec.CreatedAt,
}
}
func validateServiceValues(name string, duration, bufferBefore, bufferAfter, priceCents int) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: name must be between 1 and 120 characters", ErrInvalidInput)
}
if duration <= 0 || duration > 24*60 {
return fmt.Errorf("%w: durationMinutes must be between 1 and 1440", ErrInvalidInput)
}
if bufferBefore < 0 || bufferBefore > 480 {
return fmt.Errorf("%w: bufferBeforeMinutes must be between 0 and 480", ErrInvalidInput)
}
if bufferAfter < 0 || bufferAfter > 480 {
return fmt.Errorf("%w: bufferAfterMinutes must be between 0 and 480", ErrInvalidInput)
}
if priceCents < 0 {
return fmt.Errorf("%w: priceCents cannot be negative", ErrInvalidInput)
}
return nil
}
func (s *Service) ListServices(ctx context.Context, principal domain.Principal) ([]domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListServicesByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
services := make([]domain.Service, len(records))
for i, rec := range records {
services[i] = serviceToDomain(rec)
}
return services, nil
}
func (s *Service) CreateService(ctx context.Context, principal domain.Principal, req domain.CreateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
name := strings.TrimSpace(req.Name)
if err := validateServiceValues(name, req.DurationMinutes, req.BufferBeforeMinutes, req.BufferAfterMinutes, req.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.CreateService(ctx, db.CreateServiceParams{
TenantID: membership.Tenant.ID,
Name: name,
DurationMinutes: req.DurationMinutes,
BufferBeforeMinutes: req.BufferBeforeMinutes,
BufferAfterMinutes: req.BufferAfterMinutes,
PriceCents: req.PriceCents,
})
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) UpdateService(ctx context.Context, principal domain.Principal, serviceID string, req domain.UpdateServiceRequest) (domain.Service, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.Service{}, ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.Service{}, ErrServiceNotFound
}
params := db.UpdateServiceParams{}
if req.Name != nil {
name := strings.TrimSpace(*req.Name)
params.Name = &name
}
if req.DurationMinutes != nil {
params.DurationMinutes = req.DurationMinutes
}
if req.BufferBeforeMinutes != nil {
params.BufferBeforeMinutes = req.BufferBeforeMinutes
}
if req.BufferAfterMinutes != nil {
params.BufferAfterMinutes = req.BufferAfterMinutes
}
if req.PriceCents != nil {
params.PriceCents = req.PriceCents
}
// Validate against the merged result so partial updates cannot produce an
// invalid record (e.g. setting duration to 0 while keeping the name).
merged := existing
if params.Name != nil {
merged.Name = *params.Name
}
if params.DurationMinutes != nil {
merged.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
merged.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
merged.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
merged.PriceCents = *params.PriceCents
}
if err := validateServiceValues(merged.Name, merged.DurationMinutes, merged.BufferBeforeMinutes, merged.BufferAfterMinutes, merged.PriceCents); err != nil {
return domain.Service{}, err
}
rec, err := s.repo.UpdateService(ctx, serviceID, params)
if err != nil {
return domain.Service{}, err
}
return serviceToDomain(rec), nil
}
func (s *Service) DeleteService(ctx context.Context, principal domain.Principal, serviceID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetServiceByID(ctx, serviceID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrServiceNotFound
}
return s.repo.DeleteService(ctx, serviceID)
}
// ============================================
// STAFF MANAGEMENT
// ============================================
func staffToDomain(rec db.StaffRecord) domain.StaffMember {
return domain.StaffMember{
ID: rec.ID,
TenantID: rec.TenantID,
LocationID: rec.LocationID,
DisplayName: rec.DisplayName,
Active: rec.Active,
CreatedAt: rec.CreatedAt,
}
}
func validateStaffName(name string) error {
if trimmed := len(strings.TrimSpace(name)); trimmed < 1 || trimmed > 120 {
return fmt.Errorf("%w: displayName must be between 1 and 120 characters", ErrInvalidInput)
}
return nil
}
// validateStaffLocation returns the normalized location reference: nil when the
// input is empty, the id when it belongs to the tenant, or an error otherwise.
func (s *Service) validateStaffLocation(ctx context.Context, tenantID string, locationID *string) (*string, error) {
if locationID == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*locationID)
if trimmed == "" {
return nil, nil
}
loc, err := s.repo.GetLocationByID(ctx, trimmed)
if err != nil || loc.TenantID != tenantID {
return nil, fmt.Errorf("%w: locationId does not belong to this tenant", ErrInvalidInput)
}
return &trimmed, nil
}
func (s *Service) ListStaff(ctx context.Context, principal domain.Principal) ([]domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return nil, ErrTenantMembership
}
records, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err != nil {
return nil, err
}
staff := make([]domain.StaffMember, len(records))
for i, rec := range records {
staff[i] = staffToDomain(rec)
}
return staff, nil
}
func (s *Service) CreateStaff(ctx context.Context, principal domain.Principal, req domain.CreateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
if s.billingService != nil {
entitlements, err := s.billingService.GetEntitlements(ctx, membership.Tenant.ID)
if err == nil && entitlements.MaxStaff >= 0 {
staff, err := s.repo.ListStaffByTenant(ctx, membership.Tenant.ID)
if err == nil && len(staff) >= entitlements.MaxStaff {
return domain.StaffMember{}, fmt.Errorf("%w: staff limit reached (%d/%d). Upgrade your plan to add more staff.", ErrPlanLimitReached, len(staff), entitlements.MaxStaff)
}
}
}
name := strings.TrimSpace(req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
active := true
if req.Active != nil {
active = *req.Active
}
rec, err := s.repo.CreateStaff(ctx, db.CreateStaffParams{
TenantID: membership.Tenant.ID,
LocationID: locationID,
DisplayName: name,
Active: active,
})
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) UpdateStaff(ctx context.Context, principal domain.Principal, staffID string, req domain.UpdateStaffRequest) (domain.StaffMember, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return domain.StaffMember{}, ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return domain.StaffMember{}, ErrStaffNotFound
}
params := db.UpdateStaffParams{Active: req.Active}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if err := validateStaffName(name); err != nil {
return domain.StaffMember{}, err
}
params.DisplayName = &name
}
if req.LocationID != nil {
locationID, err := s.validateStaffLocation(ctx, membership.Tenant.ID, req.LocationID)
if err != nil {
return domain.StaffMember{}, err
}
// Keep the tri-state: an explicit empty string must clear the value.
if locationID == nil {
empty := ""
params.LocationID = &empty
} else {
params.LocationID = locationID
}
}
rec, err := s.repo.UpdateStaff(ctx, staffID, params)
if err != nil {
return domain.StaffMember{}, err
}
return staffToDomain(rec), nil
}
func (s *Service) DeleteStaff(ctx context.Context, principal domain.Principal, staffID string) error {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return ErrTenantMembership
}
existing, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || existing.TenantID != membership.Tenant.ID {
return ErrStaffNotFound
}
return s.repo.DeleteStaff(ctx, staffID)
}
// ============================================ // ============================================
// BLOCKED DAYS MANAGEMENT // BLOCKED DAYS MANAGEMENT
// ============================================ // ============================================
@@ -0,0 +1,261 @@
package catalog
import (
"context"
"errors"
"testing"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
type stubBilling struct {
entitlements domain.PlanEntitlements
}
func (s stubBilling) GetEntitlements(_ context.Context, _ string) (domain.PlanEntitlements, error) {
return s.entitlements, nil
}
func newCatalogService(repo db.Repository) *Service {
return NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: -1}}, nil)
}
func TestListServicesReturnsSeededService(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
services, err := svc.ListServices(context.Background(), owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 seeded service, got %d", len(services))
}
if services[0].Name != "Signature treatment" {
t.Fatalf("unexpected service name %q", services[0].Name)
}
}
func TestServiceCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
created, err := svc.CreateService(ctx, owner, domain.CreateServiceRequest{
Name: " Haircut ",
DurationMinutes: 45,
BufferBeforeMinutes: 5,
BufferAfterMinutes: 10,
PriceCents: 90000,
})
if err != nil {
t.Fatalf("create service: %v", err)
}
if created.Name != "Haircut" {
t.Fatalf("expected trimmed name, got %q", created.Name)
}
if created.TenantID == "" {
t.Fatal("expected tenant id on created service")
}
services, err := svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services))
}
zero := 0
if _, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{DurationMinutes: &zero}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for zero duration, got %v", err)
}
name := "Deluxe haircut"
price := 110000
updated, err := svc.UpdateService(ctx, owner, created.ID, domain.UpdateServiceRequest{Name: &name, PriceCents: &price})
if err != nil {
t.Fatalf("update service: %v", err)
}
if updated.Name != "Deluxe haircut" || updated.PriceCents != 110000 {
t.Fatalf("unexpected update result %+v", updated)
}
if updated.DurationMinutes != 45 || updated.BufferAfterMinutes != 10 {
t.Fatalf("partial update clobbered untouched fields %+v", updated)
}
if err := svc.DeleteService(ctx, owner, created.ID); err != nil {
t.Fatalf("delete service: %v", err)
}
services, err = svc.ListServices(ctx, owner)
if err != nil {
t.Fatalf("list services: %v", err)
}
if len(services) != 1 {
t.Fatalf("expected 1 service after delete, got %d", len(services))
}
}
func TestServiceValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
cases := []struct {
name string
req domain.CreateServiceRequest
}{
{"empty name", domain.CreateServiceRequest{Name: " ", DurationMinutes: 30}},
{"zero duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 0}},
{"huge duration", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 2000}},
{"negative buffer", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, BufferBeforeMinutes: -1}},
{"negative price", domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30, PriceCents: -5}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := svc.CreateService(ctx, owner, tc.req); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestServiceNotFoundAndMembership(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateService(ctx, owner, missingID, domain.UpdateServiceRequest{}); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
if err := svc.DeleteService(ctx, owner, missingID); !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "owner"}
if _, err := svc.ListServices(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
if _, err := svc.CreateService(ctx, stranger, domain.CreateServiceRequest{Name: "Cut", DurationMinutes: 30}); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestStaffCRUDLifecycle(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
locations, err := svc.ListLocations(ctx, owner)
if err != nil || len(locations) == 0 {
t.Fatalf("expected seeded location, got %v err=%v", locations, err)
}
locationID := locations[0].ID
created, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{
DisplayName: " Anna Novak ",
LocationID: &locationID,
})
if err != nil {
t.Fatalf("create staff: %v", err)
}
if created.DisplayName != "Anna Novak" {
t.Fatalf("expected trimmed name, got %q", created.DisplayName)
}
if !created.Active {
t.Fatal("new staff member should default to active")
}
if created.LocationID == nil || *created.LocationID != locationID {
t.Fatalf("expected location assignment %s, got %+v", locationID, created.LocationID)
}
staff, err := svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 2 {
t.Fatalf("expected seeded member + new member, got %d", len(staff))
}
inactive := false
cleared := ""
updated, err := svc.UpdateStaff(ctx, owner, created.ID, domain.UpdateStaffRequest{
Active: &inactive,
LocationID: &cleared,
})
if err != nil {
t.Fatalf("update staff: %v", err)
}
if updated.Active {
t.Fatal("expected staff member to be deactivated")
}
if updated.LocationID != nil {
t.Fatalf("expected cleared location, got %+v", updated.LocationID)
}
if updated.DisplayName != "Anna Novak" {
t.Fatalf("name should be untouched, got %q", updated.DisplayName)
}
if err := svc.DeleteStaff(ctx, owner, created.ID); err != nil {
t.Fatalf("delete staff: %v", err)
}
staff, err = svc.ListStaff(ctx, owner)
if err != nil {
t.Fatalf("list staff: %v", err)
}
if len(staff) != 1 {
t.Fatalf("expected 1 staff member after delete, got %d", len(staff))
}
}
func TestCreateStaffEnforcesPlanLimit(t *testing.T) {
repo := db.NewMemoryRepository()
// Demo tenant seeds one member; a limit of 1 must reject a second.
svc := NewService(repo, stubBilling{entitlements: domain.PlanEntitlements{MaxStaff: 1}}, nil)
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{DisplayName: "Second"})
if !errors.Is(err, ErrPlanLimitReached) {
t.Fatalf("expected ErrPlanLimitReached, got %v", err)
}
}
func TestCreateStaffRejectsForeignLocation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
foreign := "00000000-0000-0000-0000-000000000099"
_, err := svc.CreateStaff(context.Background(), owner, domain.CreateStaffRequest{
DisplayName: "Anna",
LocationID: &foreign,
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput, got %v", err)
}
}
func TestStaffNotFoundAndValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := newCatalogService(repo)
ctx := context.Background()
if _, err := svc.CreateStaff(ctx, owner, domain.CreateStaffRequest{DisplayName: " "}); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected ErrInvalidInput for blank name, got %v", err)
}
missingID := "00000000-0000-0000-0000-000000000001"
if _, err := svc.UpdateStaff(ctx, owner, missingID, domain.UpdateStaffRequest{}); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
if err := svc.DeleteStaff(ctx, owner, missingID); !errors.Is(err, ErrStaffNotFound) {
t.Fatalf("expected ErrStaffNotFound, got %v", err)
}
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
if _, err := svc.ListStaff(ctx, stranger); !errors.Is(err, ErrTenantMembership) {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
+217
View File
@@ -65,6 +65,19 @@ type Repository interface {
UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error) UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, error)
DeleteLocation(ctx context.Context, locationID string) error DeleteLocation(ctx context.Context, locationID string) error
// Service Management
GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error)
CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error)
UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error)
DeleteService(ctx context.Context, serviceID string) error
// Staff Management
ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error)
GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error)
CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error)
UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error)
DeleteStaff(ctx context.Context, staffID string) error
// Blocked Days / Availability Exceptions // Blocked Days / Availability Exceptions
ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error)
CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error)
@@ -225,6 +238,50 @@ type ServiceRecord struct {
BufferBeforeMinutes int BufferBeforeMinutes int
BufferAfterMinutes int BufferAfterMinutes int
PriceCents int PriceCents int
CreatedAt time.Time
}
type CreateServiceParams struct {
TenantID string
Name string
DurationMinutes int
BufferBeforeMinutes int
BufferAfterMinutes int
PriceCents int
}
// Pointer fields distinguish "not sent" (keep current value) from an explicit
// zero value, which is meaningful for buffers and price.
type UpdateServiceParams struct {
Name *string
DurationMinutes *int
BufferBeforeMinutes *int
BufferAfterMinutes *int
PriceCents *int
}
type StaffRecord struct {
ID string
TenantID string
LocationID *string
DisplayName string
Active bool
CreatedAt time.Time
}
type CreateStaffParams struct {
TenantID string
LocationID *string
DisplayName string
Active bool
}
// LocationID tri-state: nil keeps the current assignment, a pointer to an
// empty string clears it, a pointer to an id reassigns it.
type UpdateStaffParams struct {
DisplayName *string
LocationID *string
Active *bool
} }
type AvailabilityRuleRecord struct { type AvailabilityRuleRecord struct {
@@ -670,6 +727,7 @@ type MemoryRepository struct {
billingSnapshot BillingSnapshotRecord billingSnapshot BillingSnapshotRecord
recordedEvents map[string]struct{} recordedEvents map[string]struct{}
locations []LocationRecord locations []LocationRecord
staff []StaffRecord
blockedDays []BlockedDayRecord blockedDays []BlockedDayRecord
customers []CustomerRecord customers []CustomerRecord
workingHours []WorkingHoursRecord workingHours []WorkingHoursRecord
@@ -732,6 +790,17 @@ func NewMemoryRepository() *MemoryRepository {
BufferBeforeMinutes: 0, BufferBeforeMinutes: 0,
BufferAfterMinutes: 15, BufferAfterMinutes: 15,
PriceCents: 120000, PriceCents: 120000,
CreatedAt: now,
},
},
staff: []StaffRecord{
{
ID: staffID,
TenantID: tenantID,
LocationID: &locationID,
DisplayName: "Demo Stylist",
Active: true,
CreatedAt: now,
}, },
}, },
rules: []AvailabilityRuleRecord{ rules: []AvailabilityRuleRecord{
@@ -857,6 +926,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT
BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0), BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0),
PriceCents: 0, PriceCents: 0,
}} }}
r.staff = nil
r.rules = nil r.rules = nil
blocks := params.AvailabilityBlocks blocks := params.AvailabilityBlocks
if len(blocks) == 0 { if len(blocks) == 0 {
@@ -937,6 +1007,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri
return append([]ServiceRecord(nil), r.services...), nil return append([]ServiceRecord(nil), r.services...), nil
} }
func (r *MemoryRepository) GetServiceByID(_ context.Context, serviceID string) (ServiceRecord, error) {
for _, service := range r.services {
if service.ID == serviceID {
return service, nil
}
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateService(_ context.Context, params CreateServiceParams) (ServiceRecord, error) {
service := ServiceRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
Name: params.Name,
DurationMinutes: params.DurationMinutes,
BufferBeforeMinutes: params.BufferBeforeMinutes,
BufferAfterMinutes: params.BufferAfterMinutes,
PriceCents: params.PriceCents,
CreatedAt: time.Now().UTC(),
}
r.services = append(r.services, service)
return service, nil
}
func (r *MemoryRepository) UpdateService(_ context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
if params.Name != nil {
service.Name = *params.Name
}
if params.DurationMinutes != nil {
service.DurationMinutes = *params.DurationMinutes
}
if params.BufferBeforeMinutes != nil {
service.BufferBeforeMinutes = *params.BufferBeforeMinutes
}
if params.BufferAfterMinutes != nil {
service.BufferAfterMinutes = *params.BufferAfterMinutes
}
if params.PriceCents != nil {
service.PriceCents = *params.PriceCents
}
r.services[i] = service
return service, nil
}
return ServiceRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteService(_ context.Context, serviceID string) error {
for i, service := range r.services {
if service.ID != serviceID {
continue
}
r.services = append(r.services[:i], r.services[i+1:]...)
// Mirror ON DELETE SET NULL for bookings referencing the service.
for j, booking := range r.bookings {
if booking.ServiceID != nil && *booking.ServiceID == serviceID {
booking.ServiceID = nil
r.bookings[j] = booking
}
}
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListStaffByTenant(_ context.Context, tenantID string) ([]StaffRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
return append([]StaffRecord(nil), r.staff...), nil
}
func (r *MemoryRepository) GetStaffByID(_ context.Context, staffID string) (StaffRecord, error) {
for _, member := range r.staff {
if member.ID == staffID {
return member, nil
}
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) CreateStaff(_ context.Context, params CreateStaffParams) (StaffRecord, error) {
member := StaffRecord{
ID: uuid.NewString(),
TenantID: params.TenantID,
LocationID: params.LocationID,
DisplayName: params.DisplayName,
Active: params.Active,
CreatedAt: time.Now().UTC(),
}
r.staff = append(r.staff, member)
return member, nil
}
func (r *MemoryRepository) UpdateStaff(_ context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
if params.DisplayName != nil {
member.DisplayName = *params.DisplayName
}
if params.LocationID != nil {
locationID := strings.TrimSpace(*params.LocationID)
if locationID == "" {
member.LocationID = nil
} else {
member.LocationID = &locationID
}
}
if params.Active != nil {
member.Active = *params.Active
}
r.staff[i] = member
return member, nil
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error {
for i, member := range r.staff {
if member.ID != staffID {
continue
}
r.staff = append(r.staff[:i], r.staff[i+1:]...)
// Mirror the FK behaviour: bookings SET NULL, availability rules CASCADE.
for j, booking := range r.bookings {
if booking.StaffID != nil && *booking.StaffID == staffID {
booking.StaffID = nil
r.bookings[j] = booking
}
}
var rules []AvailabilityRuleRecord
for _, rule := range r.rules {
if rule.StaffID == nil || *rule.StaffID != staffID {
rules = append(rules, rule)
}
}
r.rules = rules
return nil
}
return pgx.ErrNoRows
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID { if tenantID != r.tenant.ID {
return nil, nil return nil, nil
+71 -1
View File
@@ -5,9 +5,11 @@ import (
"time" "time"
) )
const serviceColumns = "id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents, created_at"
func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) { func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string) ([]ServiceRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents SELECT `+serviceColumns+`
FROM services FROM services
WHERE tenant_id = $1 WHERE tenant_id = $1
ORDER BY created_at ASC ORDER BY created_at ASC
@@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
&record.BufferBeforeMinutes, &record.BufferBeforeMinutes,
&record.BufferAfterMinutes, &record.BufferAfterMinutes,
&record.PriceCents, &record.PriceCents,
&record.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string
return records, rows.Err() return records, rows.Err()
} }
func (r *PGRepository) GetServiceByID(ctx context.Context, serviceID string) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
SELECT `+serviceColumns+`
FROM services
WHERE id = $1
`, serviceID).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) CreateService(ctx context.Context, params CreateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO services (tenant_id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, price_cents)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING `+serviceColumns+`
`, params.TenantID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) UpdateService(ctx context.Context, serviceID string, params UpdateServiceParams) (ServiceRecord, error) {
var record ServiceRecord
err := r.pool.QueryRow(ctx, `
UPDATE services
SET name = COALESCE($2, name),
duration_minutes = COALESCE($3, duration_minutes),
buffer_before_minutes = COALESCE($4, buffer_before_minutes),
buffer_after_minutes = COALESCE($5, buffer_after_minutes),
price_cents = COALESCE($6, price_cents)
WHERE id = $1
RETURNING `+serviceColumns+`
`, serviceID, params.Name, params.DurationMinutes, params.BufferBeforeMinutes, params.BufferAfterMinutes, params.PriceCents).Scan(
&record.ID,
&record.TenantID,
&record.Name,
&record.DurationMinutes,
&record.BufferBeforeMinutes,
&record.BufferAfterMinutes,
&record.PriceCents,
&record.CreatedAt,
)
return record, err
}
func (r *PGRepository) DeleteService(ctx context.Context, serviceID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM services WHERE id = $1`, serviceID)
return err
}
func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) { func (r *PGRepository) ListAvailabilityRulesByTenant(ctx context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local
+79
View File
@@ -0,0 +1,79 @@
package db
import (
"context"
"strings"
)
const staffColumns = "id, tenant_id, location_id, display_name, active, created_at"
func (r *PGRepository) ListStaffByTenant(ctx context.Context, tenantID string) ([]StaffRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffRecord
for rows.Next() {
var rec StaffRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) GetStaffByID(ctx context.Context, staffID string) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE id = $1
`, staffID).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) CreateStaff(ctx context.Context, params CreateStaffParams) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
INSERT INTO staff (tenant_id, location_id, display_name, active)
VALUES ($1, $2, $3, $4)
RETURNING `+staffColumns+`
`, params.TenantID, params.LocationID, params.DisplayName, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) UpdateStaff(ctx context.Context, staffID string, params UpdateStaffParams) (StaffRecord, error) {
// Tri-state location update: untouched when the request omits the field,
// NULL when it is explicitly empty, otherwise reassigned.
locationSet := params.LocationID != nil
var locationValue any
if locationSet && strings.TrimSpace(*params.LocationID) != "" {
locationValue = strings.TrimSpace(*params.LocationID)
}
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
UPDATE staff
SET display_name = COALESCE($2, display_name),
location_id = CASE WHEN $3 THEN $4::uuid ELSE location_id END,
active = COALESCE($5, active)
WHERE id = $1
RETURNING `+staffColumns+`
`, staffID, params.DisplayName, locationSet, locationValue, params.Active).Scan(
&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) DeleteStaff(ctx context.Context, staffID string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM staff WHERE id = $1`, staffID)
return err
}
+56
View File
@@ -267,6 +267,62 @@ type UpdateLocationRequest struct {
Capacity int `json:"capacity,omitempty"` Capacity int `json:"capacity,omitempty"`
} }
// ============================================
// SERVICE MODELS
// ============================================
type Service struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
DurationMinutes int `json:"durationMinutes"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes"`
BufferAfterMinutes int `json:"bufferAfterMinutes"`
PriceCents int `json:"priceCents"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateServiceRequest struct {
Name string `json:"name" binding:"required"`
DurationMinutes int `json:"durationMinutes" binding:"required"`
BufferBeforeMinutes int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes int `json:"bufferAfterMinutes,omitempty"`
PriceCents int `json:"priceCents,omitempty"`
}
type UpdateServiceRequest struct {
Name *string `json:"name,omitempty"`
DurationMinutes *int `json:"durationMinutes,omitempty"`
BufferBeforeMinutes *int `json:"bufferBeforeMinutes,omitempty"`
BufferAfterMinutes *int `json:"bufferAfterMinutes,omitempty"`
PriceCents *int `json:"priceCents,omitempty"`
}
// ============================================
// STAFF MODELS
// ============================================
type StaffMember struct {
ID string `json:"id"`
TenantID string `json:"tenantId"`
LocationID *string `json:"locationId,omitempty"`
DisplayName string `json:"displayName"`
Active bool `json:"active"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateStaffRequest struct {
DisplayName string `json:"displayName" binding:"required"`
LocationID *string `json:"locationId,omitempty"`
Active *bool `json:"active,omitempty"` // defaults to true
}
type UpdateStaffRequest struct {
DisplayName *string `json:"displayName,omitempty"`
LocationID *string `json:"locationId,omitempty"` // empty string clears the assignment
Active *bool `json:"active,omitempty"`
}
// ============================================ // ============================================
// BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS // BLOCKED DAYS / AVAILABILITY EXCEPTION MODELS
// ============================================ // ============================================
+239
View File
@@ -589,6 +589,156 @@ paths:
description: Location deleted description: Location deleted
"404": "404":
description: Location not found description: Location not found
/v1/catalog/services:
get:
tags: [Catalog]
operationId: listServices
security:
- bearerAuth: []
responses:
"200":
description: Tenant services
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Service"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createService
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"201":
description: Service created
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
/v1/catalog/services/{serviceID}:
put:
tags: [Catalog]
operationId: updateService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertServiceRequest"
responses:
"200":
description: Service updated
content:
application/json:
schema:
$ref: "#/components/schemas/Service"
"400":
description: Invalid request
"404":
description: Service not found
delete:
tags: [Catalog]
operationId: deleteService
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/serviceID"
responses:
"200":
description: Service deleted
"404":
description: Service not found
/v1/catalog/staff:
get:
tags: [Catalog]
operationId: listStaff
security:
- bearerAuth: []
responses:
"200":
description: Tenant staff
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StaffMember"
"401":
description: Unauthorized
post:
tags: [Catalog]
operationId: createStaff
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"201":
description: Staff member created
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"403":
description: Plan limit reached
/v1/catalog/staff/{staffID}:
put:
tags: [Catalog]
operationId: updateStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpsertStaffRequest"
responses:
"200":
description: Staff member updated
content:
application/json:
schema:
$ref: "#/components/schemas/StaffMember"
"400":
description: Invalid request
"404":
description: Staff member not found
delete:
tags: [Catalog]
operationId: deleteStaff
security:
- bearerAuth: []
parameters:
- $ref: "#/components/parameters/staffID"
responses:
"200":
description: Staff member deleted
"404":
description: Staff member not found
/v1/catalog/bookings: /v1/catalog/bookings:
post: post:
tags: [Catalog] tags: [Catalog]
@@ -1045,6 +1195,20 @@ components:
schema: schema:
type: string type: string
format: uuid format: uuid
serviceID:
in: path
name: serviceID
required: true
schema:
type: string
format: uuid
staffID:
in: path
name: staffID
required: true
schema:
type: string
format: uuid
schemas: schemas:
HealthResponse: HealthResponse:
type: object type: object
@@ -1478,6 +1642,81 @@ components:
type: integer type: integer
address: address:
type: string type: string
Service:
type: object
required: [id, tenantId, name, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, priceCents]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
name:
type: string
durationMinutes:
type: integer
description: Appointment length in minutes (1-1440)
bufferBeforeMinutes:
type: integer
description: Blocked time before the appointment (0-480)
bufferAfterMinutes:
type: integer
description: Blocked time after the appointment (0-480)
priceCents:
type: integer
description: Price in the tenant's currency minor units
createdAt:
type: string
format: date-time
UpsertServiceRequest:
type: object
required: [name, durationMinutes]
properties:
name:
type: string
durationMinutes:
type: integer
bufferBeforeMinutes:
type: integer
bufferAfterMinutes:
type: integer
priceCents:
type: integer
StaffMember:
type: object
required: [id, tenantId, displayName, active]
properties:
id:
type: string
format: uuid
tenantId:
type: string
format: uuid
locationId:
type: string
format: uuid
nullable: true
displayName:
type: string
active:
type: boolean
createdAt:
type: string
format: date-time
UpsertStaffRequest:
type: object
required: [displayName]
properties:
displayName:
type: string
locationId:
type: string
format: uuid
nullable: true
description: Omit to keep the current assignment; empty string clears it
active:
type: boolean
BlockedDay: BlockedDay:
type: object type: object
required: [id, date, type] required: [id, date, type]
@@ -114,6 +114,12 @@ export const PlusIcon = () => (
</svg> </svg>
); );
export const ScissorsIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><path d="M8.12 8.12 12 12"/><path d="M20 4 8.12 15.88"/><circle cx="6" cy="18" r="3"/><path d="M14.8 14.8 20 20"/>
</svg>
);
export const UsersIcon = () => ( export const UsersIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
+4
View File
@@ -16,6 +16,8 @@ const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page"));
const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page")); const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page"));
const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page")); const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-page"));
const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page")); const DashboardZonesRoute = lazy(() => import("./routes/dashboard/zones-page"));
const DashboardServicesRoute = lazy(() => import("./routes/dashboard/services-page"));
const DashboardStaffRoute = lazy(() => import("./routes/dashboard/staff-page"));
const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page")); const DashboardBillingRoute = lazy(() => import("./routes/dashboard/billing-page"));
const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page")); const DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page"));
const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute }))); const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute })));
@@ -35,6 +37,8 @@ render(
<Route path="/dashboard/bookings" component={DashboardBookingsRoute} /> <Route path="/dashboard/bookings" component={DashboardBookingsRoute} />
<Route path="/dashboard/customers" component={DashboardCustomersRoute} /> <Route path="/dashboard/customers" component={DashboardCustomersRoute} />
<Route path="/dashboard/zones" component={DashboardZonesRoute} /> <Route path="/dashboard/zones" component={DashboardZonesRoute} />
<Route path="/dashboard/services" component={DashboardServicesRoute} />
<Route path="/dashboard/staff" component={DashboardStaffRoute} />
<Route path="/dashboard/billing" component={DashboardBillingRoute} /> <Route path="/dashboard/billing" component={DashboardBillingRoute} />
<Route path="/dashboard/settings" component={DashboardSettingsRoute} /> <Route path="/dashboard/settings" component={DashboardSettingsRoute} />
<Route path="/book/:tenantSlug" component={PublicBookingRoute} /> <Route path="/book/:tenantSlug" component={PublicBookingRoute} />
@@ -398,6 +398,41 @@ const dictionaries = {
"dashboard.zone.addBlocked": "Přidat blokovaný den", "dashboard.zone.addBlocked": "Přidat blokovaný den",
"dashboard.zone.reason": "Důvod", "dashboard.zone.reason": "Důvod",
"dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.", "dashboard.zone.noZones": "Zatím nemáte žádné zóny. Přidejte první.",
"dashboard.zone.staff": "Zaměstnanec",
"dashboard.zone.allStaff": "Všichni zaměstnanci",
"dashboard.services": "Služby",
"dashboard.services.subtitle": "služeb v nabídce",
"dashboard.service.add": "Přidat službu",
"dashboard.service.edit": "Upravit službu",
"dashboard.service.name": "Název služby",
"dashboard.service.namePlaceholder": "např. Dámský střih",
"dashboard.service.duration": "Délka (min)",
"dashboard.service.bufferBefore": "Přestávka před",
"dashboard.service.bufferAfter": "Přestávka po",
"dashboard.service.price": "Cena (Kč)",
"dashboard.service.free": "Zdarma",
"dashboard.service.min": "min",
"dashboard.service.noServices": "Zatím žádné služby.",
"dashboard.service.noServicesDesc": "Přidejte první službu, kterou si klienti mohou rezervovat.",
"dashboard.service.saveFailed": "Uložení služby selhalo.",
"dashboard.service.deleteFailed": "Smazání služby selhalo.",
"dashboard.staff": "Zaměstnanci",
"dashboard.staff.subtitle": "členů týmu",
"dashboard.staff.add": "Přidat zaměstnance",
"dashboard.staff.edit": "Upravit zaměstnance",
"dashboard.staff.name": "Jméno",
"dashboard.staff.namePlaceholder": "např. Anna Nováková",
"dashboard.staff.location": "Lokace",
"dashboard.staff.noLocation": "Bez lokace",
"dashboard.staff.active": "Aktivní",
"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.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.",
"dashboard.staff.deleteFailed": "Smazání zaměstnance selhalo.",
"dashboard.bookingModal.selectService": "Vyberte službu",
"dashboard.billing.planUsage": "Využití plánu", "dashboard.billing.planUsage": "Využití plánu",
"dashboard.billing.locations": "Lokace", "dashboard.billing.locations": "Lokace",
"dashboard.billing.bookings": "Rezervace", "dashboard.billing.bookings": "Rezervace",
@@ -907,6 +942,41 @@ const dictionaries = {
"dashboard.zone.addBlocked": "Add blocked day", "dashboard.zone.addBlocked": "Add blocked day",
"dashboard.zone.reason": "Reason", "dashboard.zone.reason": "Reason",
"dashboard.zone.noZones": "No zones yet. Add your first one.", "dashboard.zone.noZones": "No zones yet. Add your first one.",
"dashboard.zone.staff": "Staff member",
"dashboard.zone.allStaff": "All staff",
"dashboard.services": "Services",
"dashboard.services.subtitle": "services offered",
"dashboard.service.add": "Add service",
"dashboard.service.edit": "Edit service",
"dashboard.service.name": "Service name",
"dashboard.service.namePlaceholder": "e.g. Women's haircut",
"dashboard.service.duration": "Duration (min)",
"dashboard.service.bufferBefore": "Buffer before",
"dashboard.service.bufferAfter": "Buffer after",
"dashboard.service.price": "Price (CZK)",
"dashboard.service.free": "Free",
"dashboard.service.min": "min",
"dashboard.service.noServices": "No services yet.",
"dashboard.service.noServicesDesc": "Add the first service clients can book.",
"dashboard.service.saveFailed": "Failed to save service.",
"dashboard.service.deleteFailed": "Failed to delete service.",
"dashboard.staff": "Staff",
"dashboard.staff.subtitle": "team members",
"dashboard.staff.add": "Add staff member",
"dashboard.staff.edit": "Edit staff member",
"dashboard.staff.name": "Name",
"dashboard.staff.namePlaceholder": "e.g. Anna Novak",
"dashboard.staff.location": "Location",
"dashboard.staff.noLocation": "No location",
"dashboard.staff.active": "Active",
"dashboard.staff.inactive": "Inactive",
"dashboard.staff.noStaff": "No staff yet.",
"dashboard.staff.noStaffDesc": "Add team members who provide services.",
"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.",
"dashboard.staff.deleteFailed": "Failed to delete staff member.",
"dashboard.bookingModal.selectService": "Select service",
"dashboard.billing.planUsage": "Plan usage", "dashboard.billing.planUsage": "Plan usage",
"dashboard.billing.locations": "Locations", "dashboard.billing.locations": "Locations",
"dashboard.billing.bookings": "Bookings", "dashboard.billing.bookings": "Bookings",
@@ -1,4 +1,4 @@
import { Show, createSignal, createMemo } from "solid-js"; import { Show, createSignal, createMemo, createResource } from "solid-js";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select"; import { Select } from "../../components/ui/select";
import { Textarea } from "../../components/ui/textarea"; import { Textarea } from "../../components/ui/textarea";
@@ -25,6 +25,28 @@ function BookingsPage() {
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? []; const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
const [serviceOptions] = createResource(
() => [data.token(), showNewBooking()] as const,
async ([bearer, open]) => {
if (!bearer || !open) return [];
if (bearer.startsWith("demo.")) {
const names = data.i18n.locale() === "cs"
? ["Masáž", "Kosmetika", "Fyzioterapie", "Manikúra"]
: ["Massage", "Cosmetics", "Physiotherapy", "Manicure"];
return names.map((name) => ({ value: name, label: name }));
}
try {
const response = await apiClient.GET("/v1/catalog/services", { headers: { Authorization: `Bearer ${bearer}` } });
return ((response.data as any[]) ?? []).map((s) => ({
value: s.name,
label: `${s.name} · ${s.durationMinutes} min`,
}));
} catch {
return [];
}
},
);
const filteredBookings = createMemo(() => { const filteredBookings = createMemo(() => {
let bookings = resolvedAllBookings(); let bookings = resolvedAllBookings();
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus()); if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
@@ -224,7 +246,16 @@ function BookingsPage() {
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} /> <Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} /> <Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
<Show when={(serviceOptions() ?? []).length > 0} fallback={
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} /> <Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
}>
<Select
label={data.i18n.t("dashboard.bookingModal.service")}
value={newBookingService()}
onChange={(v) => setNewBookingService(v)}
options={[{ value: "", label: data.i18n.t("dashboard.bookingModal.selectService") }, ...(serviceOptions() ?? [])]}
/>
</Show>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} /> <Input type="date" label={data.i18n.t("dashboard.bookingModal.date")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
<Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} /> <Input type="time" label={data.i18n.t("dashboard.bookingModal.time")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
@@ -4,7 +4,7 @@ import { useAuth } from "../../providers/auth-provider";
import { useI18n } from "../../providers/i18n-provider"; import { useI18n } from "../../providers/i18n-provider";
import { getInitials, getBookingDuration } from "../../components/dashboard/types"; import { getInitials, getBookingDuration } from "../../components/dashboard/types";
export type Section = "overview" | "bookings" | "customers" | "zones" | "billing" | "settings"; export type Section = "overview" | "bookings" | "customers" | "services" | "staff" | "zones" | "billing" | "settings";
function createDemoData(i18n: ReturnType<typeof useI18n>) { function createDemoData(i18n: ReturnType<typeof useI18n>) {
const cs = i18n.locale() === "cs"; const cs = i18n.locale() === "cs";
@@ -8,7 +8,7 @@ import { getInitials, getBookingDuration } from "../../components/dashboard/type
import { import {
LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon, LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon,
LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon, LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon,
UserCircleIcon, MapPinIcon UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
} from "../../components/dashboard/icons"; } from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
@@ -18,6 +18,8 @@ const navItems = [
{ id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" }, { id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" },
{ id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" }, { id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" },
{ id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" }, { id: "customers", label: "dashboard.customers", icon: UserCircleIcon, path: "/dashboard/customers" },
{ id: "services", label: "dashboard.services", icon: ScissorsIcon, path: "/dashboard/services" },
{ id: "staff", label: "dashboard.staff", icon: UsersIcon, path: "/dashboard/staff" },
{ id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" }, { id: "zones", label: "dashboard.zones", icon: MapPinIcon, path: "/dashboard/zones" },
{ id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" }, { id: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" },
{ id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" }, { id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" },
@@ -0,0 +1,204 @@
import { For, Show, createResource, createSignal } from "solid-js";
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";
interface ServiceForm {
name: string;
durationMinutes: string;
bufferBeforeMinutes: string;
bufferAfterMinutes: string;
price: string;
}
const emptyForm: ServiceForm = { name: "", durationMinutes: "60", bufferBeforeMinutes: "0", bufferAfterMinutes: "0", price: "0" };
function ServicesPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [showForm, setShowForm] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [form, setForm] = createSignal<ServiceForm>({ ...emptyForm });
const [saving, setSaving] = createSignal(false);
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
const setField = (patch: Partial<ServiceForm>) => setForm({ ...form(), ...patch });
const [services, { refetch }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "sv1", name: cs() ? "Dámský střih" : "Women's haircut", durationMinutes: 60, bufferBeforeMinutes: 0, bufferAfterMinutes: 15, priceCents: 89000 },
{ id: "sv2", name: cs() ? "Pánský střih" : "Men's haircut", durationMinutes: 45, bufferBeforeMinutes: 0, bufferAfterMinutes: 10, priceCents: 59000 },
{ id: "sv3", name: cs() ? "Barvení" : "Coloring", durationMinutes: 120, bufferBeforeMinutes: 10, bufferAfterMinutes: 15, priceCents: 199000 },
{ id: "sv4", name: cs() ? "Konzultace" : "Consultation", durationMinutes: 30, bufferBeforeMinutes: 0, bufferAfterMinutes: 0, priceCents: 0 },
];
}
const response = await apiClient.GET("/v1/catalog/services", authHeaders());
return (response.data as any[]) ?? [];
},
);
const resolvedServices = () => services() ?? [];
const formatPrice = (priceCents: number) => {
if (!priceCents) return data.i18n.t("dashboard.service.free");
return new Intl.NumberFormat(cs() ? "cs-CZ" : "en-US", { style: "currency", currency: "CZK", maximumFractionDigits: 0 }).format(priceCents / 100);
};
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (service: any) => {
setEditingId(service.id);
setForm({
name: service.name,
durationMinutes: String(service.durationMinutes ?? 60),
bufferBeforeMinutes: String(service.bufferBeforeMinutes ?? 0),
bufferAfterMinutes: String(service.bufferAfterMinutes ?? 0),
price: String((service.priceCents ?? 0) / 100),
});
setShowForm(true);
};
const handleSave = async () => {
const bearer = data.token();
const name = form().name.trim();
const duration = parseInt(form().durationMinutes, 10);
if (!name || !duration || duration <= 0) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
setSaving(true);
try {
const body = {
name,
durationMinutes: duration,
bufferBeforeMinutes: Math.max(0, parseInt(form().bufferBeforeMinutes, 10) || 0),
bufferAfterMinutes: Math.max(0, parseInt(form().bufferAfterMinutes, 10) || 0),
priceCents: Math.max(0, Math.round((parseFloat(form().price) || 0) * 100)),
};
const id = editingId();
const response = id
? await apiClient.PUT("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } }, body })
: await apiClient.POST("/v1/catalog/services", { ...authHeaders(), body });
if (response.error) throw new Error("save failed");
setShowForm(false);
setEditingId(null);
setForm({ ...emptyForm });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.service.saveFailed"));
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode.");
return;
}
try {
await apiClient.DELETE("/v1/catalog/services/{serviceID}", { ...authHeaders(), params: { path: { serviceID: id } } });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.service.deleteFailed"));
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<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>
</div>
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.service.add")}</button>
</div>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
<div class="sm:col-span-2">
<Input type="text" label={data.i18n.t("dashboard.service.name")} value={form().name} onInput={(e) => setField({ name: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.namePlaceholder")} />
</div>
<Input type="number" min="1" label={data.i18n.t("dashboard.service.duration")} value={form().durationMinutes} onInput={(e) => setField({ durationMinutes: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.price")} value={form().price} onInput={(e) => setField({ price: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferBefore")} value={form().bufferBeforeMinutes} onInput={(e) => setField({ bufferBeforeMinutes: e.currentTarget.value })} />
<Input type="number" min="0" label={data.i18n.t("dashboard.service.bufferAfter")} value={form().bufferAfterMinutes} onInput={(e) => setField({ bufferAfterMinutes: e.currentTarget.value })} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleSave()} disabled={saving() || !form().name.trim()} class="btn-primary text-sm disabled:opacity-60">
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
<button onClick={() => { setShowForm(false); setEditingId(null); }} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={!services.loading} fallback={
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
}>
<Show when={resolvedServices().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4 text-ink-subtle"><ScissorsIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.service.noServices")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.service.noServicesDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={resolvedServices()}>
{(service: any) => (
<div class="flex items-center justify-between p-5 px-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<div class="w-12 h-12 rounded-xl bg-accent-subtle/50 flex items-center justify-center text-accent shrink-0"><ScissorsIcon /></div>
<div class="min-w-0">
<h4 class="font-medium text-ink truncate">{service.name}</h4>
<p class="text-sm text-ink-muted flex items-center gap-1.5">
<ClockIcon /> {service.durationMinutes} {data.i18n.t("dashboard.service.min")}
<Show when={service.bufferBeforeMinutes > 0 || service.bufferAfterMinutes > 0}>
<span class="text-ink-subtle">
&bull; +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"}
</span>
</Show>
</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="hidden sm:inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-accent/10 text-accent">{formatPrice(service.priceCents)}</span>
<button onClick={() => openEdit(service)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.edit")}>
<EditIcon />
</button>
<button onClick={() => void handleDelete(service.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
<Trash2Icon />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
</div>
);
}
export default function ServicesRoute() {
return (
<DashboardLayout>
<ServicesPage />
</DashboardLayout>
);
}
@@ -0,0 +1,264 @@
import { For, Show, createResource, createSignal } from "solid-js";
import { apiClient } from "../../lib/api-client";
import { Input } from "../../components/ui/input";
import { Select } from "../../components/ui/select";
import { PlusIcon, UsersIcon, Trash2Icon, EditIcon, SparklesIcon, MapPinIcon } from "../../components/dashboard/icons";
import { DashboardLayout, useDashboardData } from "./layout";
interface StaffForm {
displayName: string;
locationId: string;
}
const emptyForm: StaffForm = { displayName: "", locationId: "" };
function StaffPage() {
const data = useDashboardData();
const cs = () => data.i18n.locale() === "cs";
const [showForm, setShowForm] = createSignal(false);
const [editingId, setEditingId] = createSignal<string | null>(null);
const [form, setForm] = createSignal<StaffForm>({ ...emptyForm });
const [saving, setSaving] = createSignal(false);
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
const setField = (patch: Partial<StaffForm>) => setForm({ ...form(), ...patch });
const [staff, { refetch }] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "st1", displayName: "Petra M.", active: true, locationId: "z1" },
{ id: "st2", displayName: "Lucie K.", active: true, locationId: "z2" },
{ id: "st3", displayName: cs() ? "Martin Hájek" : "Martin Hajek", active: false, locationId: null },
];
}
const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
return (response.data as any[]) ?? [];
},
);
const [locations] = createResource(
() => data.token(),
async (bearer) => {
if (!bearer) return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "z1", name: cs() ? "Hlavní sál" : "Main Hall" },
{ id: "z2", name: cs() ? "Soukromá místnost A" : "Private Room A" },
];
}
const response = await apiClient.GET("/v1/catalog/locations", authHeaders());
return (response.data as any[]) ?? [];
},
);
const resolvedStaff = () => staff() ?? [];
const resolvedLocations = () => locations() ?? [];
const staffLimit = () => data.resolvedBilling()?.entitlements?.maxStaff ?? -1;
const canAddStaff = () => staffLimit() < 0 || resolvedStaff().length < staffLimit();
const locationName = (id: string | null | undefined) =>
resolvedLocations().find((l: any) => l.id === id)?.name ?? data.i18n.t("dashboard.staff.noLocation");
const locationOptions = () => [
{ value: "", label: data.i18n.t("dashboard.staff.noLocation") },
...resolvedLocations().map((l: any) => ({ value: l.id, label: l.name })),
];
const openCreate = () => {
setEditingId(null);
setForm({ ...emptyForm });
setShowForm(true);
};
const openEdit = (member: any) => {
setEditingId(member.id);
setForm({ displayName: member.displayName, locationId: member.locationId ?? "" });
setShowForm(true);
};
const handleSave = async () => {
const bearer = data.token();
const name = form().displayName.trim();
if (!name) return;
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
setSaving(true);
try {
const id = editingId();
const locationId = form().locationId;
const response = id
? await apiClient.PUT("/v1/catalog/staff/{staffID}", {
...authHeaders(),
params: { path: { staffID: id } },
body: { displayName: name, locationId: locationId || "" },
})
: await apiClient.POST("/v1/catalog/staff", {
...authHeaders(),
body: { displayName: name, ...(locationId ? { locationId } : {}) },
});
if (response.error) throw new Error("save failed");
setShowForm(false);
setEditingId(null);
setForm({ ...emptyForm });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
} finally {
setSaving(false);
}
};
const handleToggleActive = async (member: any) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze ukládat." : "Cannot save in demo mode.");
return;
}
try {
await apiClient.PUT("/v1/catalog/staff/{staffID}", {
...authHeaders(),
params: { path: { staffID: member.id } },
body: { displayName: member.displayName, active: !member.active },
});
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.saveFailed"));
}
};
const handleDelete = async (id: string) => {
const bearer = data.token();
if (!bearer || bearer.startsWith("demo.")) {
data.setDemoNotice(cs() ? "V demo režimu nelze mazat." : "Cannot delete in demo mode.");
return;
}
try {
await apiClient.DELETE("/v1/catalog/staff/{staffID}", { ...authHeaders(), params: { path: { staffID: id } } });
void refetch();
} catch {
data.setDemoNotice(data.i18n.t("dashboard.staff.deleteFailed"));
}
};
return (
<div class="space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink font-display">{data.i18n.t("dashboard.staff")}</h1>
<p class="text-ink-muted mt-1">{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}</p>
</div>
<Show when={canAddStaff()} fallback={
<button onClick={() => setShowUpgradePrompt(true)} class="flex items-center gap-2 px-4 py-2 bg-canvas-subtle text-ink-muted border border-border rounded-lg text-sm opacity-80">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="18" height="11" x="3" y="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{data.i18n.t("dashboard.zone.limitReached")}
</button>
}>
<button onClick={openCreate} class="btn-primary text-sm shrink-0"><PlusIcon /> {data.i18n.t("dashboard.staff.add")}</button>
</Show>
</div>
<div class="surface-card overflow-hidden">
<Show when={showForm()}>
<div class="p-6 border-b border-border bg-canvas-subtle/50">
<h4 class="font-medium text-ink mb-4">{editingId() ? data.i18n.t("dashboard.staff.edit") : data.i18n.t("dashboard.staff.add")}</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input type="text" label={data.i18n.t("dashboard.staff.name")} value={form().displayName} onInput={(e) => setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} />
<Select label={data.i18n.t("dashboard.staff.location")} value={form().locationId} onChange={(v) => setField({ locationId: v })} options={locationOptions()} />
</div>
<div class="flex gap-3 mt-4">
<button onClick={() => void handleSave()} disabled={saving() || !form().displayName.trim()} class="btn-primary text-sm disabled:opacity-60">
{saving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
</button>
<button onClick={() => { setShowForm(false); setEditingId(null); }} class="btn-secondary text-sm">{data.i18n.t("common.cancel")}</button>
</div>
</div>
</Show>
<Show when={!staff.loading} fallback={
<div class="p-12 text-center text-ink-muted text-sm animate-pulse">{cs() ? "Načítání…" : "Loading…"}</div>
}>
<Show when={resolvedStaff().length > 0} fallback={
<div class="p-12 text-center">
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4 text-ink-subtle"><UsersIcon /></div>
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.staff.noStaff")}</p>
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.staff.noStaffDesc")}</p>
</div>
}>
<div class="divide-y divide-border/60">
<For each={resolvedStaff()}>
{(member: any) => (
<div class="flex items-center justify-between p-5 px-6 hover:bg-canvas-subtle/30 transition-colors">
<div class="flex items-center gap-4 min-w-0">
<div class={`w-12 h-12 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${member.active ? "bg-accent-subtle text-accent" : "bg-canvas-muted text-ink-subtle"}`}>
{member.displayName?.split(" ").map((n: string) => n[0]).join("").slice(0, 2).toUpperCase()}
</div>
<div class="min-w-0">
<h4 class="font-medium text-ink truncate">{member.displayName}</h4>
<p class="text-sm text-ink-muted flex items-center gap-1.5">
<MapPinIcon /> {locationName(member.locationId)}
</p>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
onClick={() => void handleToggleActive(member)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${member.active ? "bg-accent" : "bg-canvas-muted"}`}
role="switch"
aria-checked={member.active}
aria-label={member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
>
<span class={`inline-block h-4 w-4 transform rounded-full bg-canvas transition-transform ${member.active ? "translate-x-6" : "translate-x-1"}`} />
</button>
<span class={`hidden sm:inline text-xs font-medium ${member.active ? "text-accent" : "text-ink-subtle"}`}>
{member.active ? data.i18n.t("dashboard.staff.active") : data.i18n.t("dashboard.staff.inactive")}
</span>
<button onClick={() => openEdit(member)} class="p-2 text-ink-subtle hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" aria-label={data.i18n.t("dashboard.edit")}>
<EditIcon />
</button>
<button onClick={() => void handleDelete(member.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}>
<Trash2Icon />
</button>
</div>
</div>
)}
</For>
</div>
</Show>
</Show>
</div>
<Show when={showUpgradePrompt()}>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowUpgradePrompt(false)} />
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-md p-6 animate-scale-in">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center shrink-0 text-accent"><SparklesIcon /></div>
<div class="flex-1">
<h4 class="font-medium text-ink mb-1">{data.i18n.t("dashboard.staff.limitReached")}</h4>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.staff.limitDesc")}</p>
<div class="flex gap-3 mt-4">
<button onClick={() => { setShowUpgradePrompt(false); void data.openCheckout(); }} class="px-4 py-2 bg-accent text-canvas text-sm font-medium rounded-lg hover:bg-accent-hover transition-colors">{data.i18n.t("dashboard.upgrade")}</button>
<button onClick={() => setShowUpgradePrompt(false)} class="px-4 py-2 bg-canvas-subtle text-ink-muted text-sm font-medium rounded-lg hover:bg-canvas transition-colors">{data.i18n.t("dashboard.close")}</button>
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
);
}
export default function StaffRoute() {
return (
<DashboardLayout>
<StaffPage />
</DashboardLayout>
);
}
@@ -16,6 +16,7 @@ function ZonesPage() {
const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false); const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false);
const [newBlockedDate, setNewBlockedDate] = createSignal(""); const [newBlockedDate, setNewBlockedDate] = createSignal("");
const [newBlockedReason, setNewBlockedReason] = createSignal(""); const [newBlockedReason, setNewBlockedReason] = createSignal("");
const [newBlockedStaffId, setNewBlockedStaffId] = createSignal("");
const [blockedSaving, setBlockedSaving] = createSignal(false); const [blockedSaving, setBlockedSaving] = createSignal(false);
const [hoursSaving, setHoursSaving] = createSignal<number | null>(null); const [hoursSaving, setHoursSaving] = createSignal<number | null>(null);
@@ -47,6 +48,28 @@ function ZonesPage() {
}, },
); );
const [staffMembers] = createResource(
() => [data.token(), activeTab()] as const,
async ([bearer, tab]) => {
if (!bearer || tab !== "blocked") return [];
if (bearer.startsWith("demo.")) {
return [
{ id: "st1", displayName: "Petra M.", active: true },
{ id: "st2", displayName: "Lucie K.", active: true },
];
}
try {
const response = await apiClient.GET("/v1/catalog/staff", authHeaders());
return ((response.data as any[]) ?? []).filter((m) => m.active);
} catch {
return [];
}
},
);
const staffName = (id: string | null | undefined) =>
(staffMembers() ?? []).find((m: any) => m.id === id)?.displayName ?? "";
const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource( const [workingHours, { refetch: refetchHours, mutate: mutateHours }] = createResource(
() => [data.token(), activeTab()] as const, () => [data.token(), activeTab()] as const,
async ([bearer, tab]) => { async ([bearer, tab]) => {
@@ -106,9 +129,14 @@ function ZonesPage() {
try { try {
await (apiClient as any).POST("/v1/catalog/blocked-days", { await (apiClient as any).POST("/v1/catalog/blocked-days", {
...authHeaders(), ...authHeaders(),
body: { date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(), type: "closure", reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed") }, body: {
date: new Date(`${newBlockedDate()}T00:00:00`).toISOString(),
type: "closure",
reason: newBlockedReason().trim() || (cs() ? "Zavřeno" : "Closed"),
...(newBlockedStaffId() ? { staffId: newBlockedStaffId() } : {}),
},
}); });
setNewBlockedDate(""); setNewBlockedReason(""); setNewBlockedDate(""); setNewBlockedReason(""); setNewBlockedStaffId("");
void refetchBlocked(); void refetchBlocked();
} catch { } catch {
data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed."); data.setDemoNotice(cs() ? "Uložení selhalo." : "Save failed.");
@@ -236,9 +264,18 @@ function ZonesPage() {
<p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p> <p class="text-sm text-ink-muted mt-1">{cs() ? "Dny, kdy nejsou dostupné rezervace." : "Days when bookings are unavailable."}</p>
</div> </div>
<div class="p-6 border-b border-border bg-canvas-subtle/50"> <div class="p-6 border-b border-border bg-canvas-subtle/50">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} /> <Input type="date" label={cs() ? "Datum" : "Date"} value={newBlockedDate()} onInput={(e) => setNewBlockedDate(e.currentTarget.value)} />
<Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} /> <Input type="text" label={cs() ? "Důvod" : "Reason"} value={newBlockedReason()} onInput={(e) => setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} />
<Select
label={data.i18n.t("dashboard.zone.staff")}
value={newBlockedStaffId()}
onChange={(v) => setNewBlockedStaffId(v)}
options={[
{ value: "", label: data.i18n.t("dashboard.zone.allStaff") },
...(staffMembers() ?? []).map((m: any) => ({ value: m.id, label: m.displayName })),
]}
/>
<div class="flex items-end"> <div class="flex items-end">
<button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60"> <button onClick={() => void handleAddBlockedDay()} disabled={blockedSaving() || !newBlockedDate()} class="btn-primary text-sm w-full sm:w-auto disabled:opacity-60">
{blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")} {blockedSaving() ? data.i18n.t("dashboard.saving") : data.i18n.t("dashboard.settings.save")}
@@ -259,7 +296,12 @@ function ZonesPage() {
<div class="flex items-center justify-between p-4 px-6"> <div class="flex items-center justify-between p-4 px-6">
<div> <div>
<p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p> <p class="font-medium text-ink">{new Date(day.date).toLocaleDateString(cs() ? "cs-CZ" : "en-US", { weekday: "long", day: "numeric", month: "long", year: "numeric" })}</p>
<p class="text-sm text-ink-muted">{day.reason || "—"}</p> <p class="text-sm text-ink-muted">
{day.reason || "—"}
<Show when={staffName(day.staffId)}>
<span class="text-ink-subtle"> &bull; {staffName(day.staffId)}</span>
</Show>
</p>
</div> </div>
<button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button> <button onClick={() => void handleDeleteBlockedDay(day.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
</div> </div>
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/animated-list.tsx","./src/components/bookra-character.tsx","./src/components/dashboard-mockup.tsx","./src/components/floating-dock.tsx","./src/components/hover-feature-cards.tsx","./src/components/index.ts","./src/components/integration-modal.tsx","./src/components/location-map.tsx","./src/components/pinned-list.tsx","./src/components/shell.tsx","./src/components/sms-settings.tsx","./src/components/video-player.tsx","./src/components/widget-builder.tsx","./src/components/dashboard/activity-timeline.tsx","./src/components/dashboard/calendar-view.tsx","./src/components/dashboard/icons.tsx","./src/components/dashboard/kpi-card.tsx","./src/components/dashboard/notification-dropdown.tsx","./src/components/dashboard/revenue-chart.tsx","./src/components/dashboard/types.ts","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/index.ts","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/tooltip.tsx","./src/lib/api-client.ts","./src/lib/map.ts","./src/lib/sentry.ts","./src/lib/types.ts","./src/providers/auth-provider.tsx","./src/providers/i18n-provider.tsx","./src/providers/theme-provider.tsx","./src/routes/about-route.tsx","./src/routes/auth-callback-route.tsx","./src/routes/booking-manage-route.tsx","./src/routes/contact-route.tsx","./src/routes/home-route.tsx","./src/routes/legal-route.tsx","./src/routes/not-found-route.tsx","./src/routes/pricing-route.tsx","./src/routes/public-booking-route.tsx","./src/routes/dashboard/billing-page.tsx","./src/routes/dashboard/bookings-page.tsx","./src/routes/dashboard/customers-page.tsx","./src/routes/dashboard/dashboard-data.ts","./src/routes/dashboard/layout.tsx","./src/routes/dashboard/overview-page.tsx","./src/routes/dashboard/services-page.tsx","./src/routes/dashboard/settings-page.tsx","./src/routes/dashboard/staff-page.tsx","./src/routes/dashboard/zones-page.tsx","./vite.config.ts"],"version":"5.9.3"}
+368
View File
@@ -351,6 +351,70 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/catalog/services": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listServices"];
put?: never;
post: operations["createService"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/services/{serviceID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateService"];
post?: never;
delete: operations["deleteService"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listStaff"];
put?: never;
post: operations["createStaff"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/staff/{staffID}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["updateStaff"];
post?: never;
delete: operations["deleteStaff"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/working-hours": { "/v1/catalog/working-hours": {
parameters: { parameters: {
query?: never; query?: never;
@@ -976,6 +1040,35 @@ export interface components {
environment: string; environment: string;
neonAuthEnabled: boolean; neonAuthEnabled: boolean;
}; };
Service: {
/** @description Blocked time after the appointment (0-480) */
bufferAfterMinutes: number;
/** @description Blocked time before the appointment (0-480) */
bufferBeforeMinutes: number;
/** Format: date-time */
createdAt?: string;
/** @description Appointment length in minutes (1-1440) */
durationMinutes: number;
/** Format: uuid */
id: string;
name: string;
/** @description Price in the tenant's currency minor units */
priceCents: number;
/** Format: uuid */
tenantId: string;
};
StaffMember: {
active: boolean;
/** Format: date-time */
createdAt?: string;
displayName: string;
/** Format: uuid */
id: string;
/** Format: uuid */
locationId?: string | null;
/** Format: uuid */
tenantId: string;
};
SubscriptionSnapshot: { SubscriptionSnapshot: {
cancelAtPeriodEnd: boolean; cancelAtPeriodEnd: boolean;
checkoutUrlAvailable: boolean; checkoutUrlAvailable: boolean;
@@ -1109,6 +1202,22 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
type?: "room" | "private" | "hall"; type?: "room" | "private" | "hall";
}; };
UpsertServiceRequest: {
bufferAfterMinutes?: number;
bufferBeforeMinutes?: number;
durationMinutes: number;
name: string;
priceCents?: number;
};
UpsertStaffRequest: {
active?: boolean;
displayName: string;
/**
* Format: uuid
* @description Omit to keep the current assignment; empty string clears it
*/
locationId?: string | null;
};
WidgetSnippet: { WidgetSnippet: {
code?: string; code?: string;
kind?: string; kind?: string;
@@ -1127,6 +1236,8 @@ export interface components {
blockedDayID: string; blockedDayID: string;
customerID: string; customerID: string;
locationID: string; locationID: string;
serviceID: string;
staffID: string;
}; };
requestBodies: never; requestBodies: never;
headers: never; headers: never;
@@ -1978,6 +2089,263 @@ export interface operations {
}; };
}; };
}; };
listServices: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Tenant services */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createService: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertServiceRequest"];
};
};
responses: {
/** @description Service created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateService: {
parameters: {
query?: never;
header?: never;
path: {
serviceID: components["parameters"]["serviceID"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertServiceRequest"];
};
};
responses: {
/** @description Service updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Service"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteService: {
parameters: {
query?: never;
header?: never;
path: {
serviceID: components["parameters"]["serviceID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Service deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listStaff: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Tenant staff */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"][];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
createStaff: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertStaffRequest"];
};
};
responses: {
/** @description Staff member created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Plan limit reached */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
updateStaff: {
parameters: {
query?: never;
header?: never;
path: {
staffID: components["parameters"]["staffID"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpsertStaffRequest"];
};
};
responses: {
/** @description Staff member updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StaffMember"];
};
};
/** @description Invalid request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
deleteStaff: {
parameters: {
query?: never;
header?: never;
path: {
staffID: components["parameters"]["staffID"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Staff member deleted */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Staff member not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listWorkingHours: { listWorkingHours: {
parameters: { parameters: {
query?: never; query?: never;
+13 -4
View File
@@ -51,8 +51,14 @@
- protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone - protected tenant onboarding endpoint added with backend validation for slug, preset, locale, and timezone
- onboarding now creates the tenant, owner membership, and first default location - onboarding now creates the tenant, owner membership, and first default location
- dashboard can transition from empty bootstrap shell to a provisioned tenant state - dashboard can transition from empty bootstrap shell to a provisioned tenant state
- real post-onboarding entity management screens are still missing - post-onboarding entity screens for locations, services, staff, blocked days, and working hours now exist (Milestone 8); class/session template management is still missing
- `[next]` Milestone 8: Catalog management, live staging validation, and operational hardening - `[done/partial]` Milestone 8: Catalog management shipped; live staging validation and operational hardening remain
- service CRUD end-to-end: repository (pgx + in-memory), catalog service with validation and tenant isolation, protected `/v1/catalog/services` routes, OpenAPI schemas, dashboard services page
- staff CRUD end-to-end with active toggle, optional location assignment, and `MaxStaff` entitlement enforcement; dashboard staff page with upgrade prompt
- dashboard summary now resolves `staffName` on bookings and computes `setupCompletion` from real state (brand, location, service, staff, schedule)
- booking modal service field is a select bound to the tenant's real services; blocked-day creation supports optional per-staff scoping
- deleting a service or staff member preserves bookings (FK `SET NULL`) and removes the member's availability rules (`CASCADE`), mirrored in the in-memory repository
- live staging validation, real Neon Auth/Stripe/SMS provider checks, and operational hardening are still open
## 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.
@@ -279,11 +285,13 @@ Key entities:
- goose migration execution is runnable through workspace scripts. - goose migration execution is runnable through workspace scripts.
- Protected backend requests now perform automatic app-side identity sync before tenant membership resolution. - Protected backend requests now perform automatic app-side identity sync before tenant membership resolution.
- `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users. - `/dashboard` now includes first-run onboarding instead of only a passive empty state for unassigned authenticated users.
- Catalog management now covers services and staff end-to-end; staff creation enforces `MaxStaff` entitlements and staff location assignment validates tenant ownership.
- Dashboard summary computes `setupCompletion` from real configured state (brand, location, service, staff, availability rules) rather than a static value.
## Current Gaps ## Current Gaps
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment. - Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants. - Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
- First-time onboarding exists, but there are still no CRUD screens for locations, staff, services, or schedules after workspace creation. - Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI.
- Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures. - Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops. - Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup. - The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
@@ -293,7 +301,8 @@ Key entities:
- sync Neon Auth identities into tenant membership records and onboarding bootstrap - sync Neon Auth identities into tenant membership records and onboarding bootstrap
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure - validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
- replace noop notification providers with the first production email transport and a gated SMS transport - replace noop notification providers with the first production email transport and a gated SMS transport
- add real app management screens for services, staff, locations, and availability after onboarding - add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding
- surface service selection and staff assignment on the public booking flow (services currently auto-resolve to the first match)
## Product Principles ## Product Principles
- calm, premium, structured UX - calm, premium, structured UX