diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 9a4fbc4..c59bec0 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -541,6 +541,144 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) { 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) { var request domain.DashboardBookingRequest if err := c.ShouldBindJSON(&request); err != nil { diff --git a/apps/backend/internal/bookings/service.go b/apps/backend/internal/bookings/service.go index 78b9555..d20b55c 100644 --- a/apps/backend/internal/bookings/service.go +++ b/apps/backend/internal/bookings/service.go @@ -355,6 +355,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip 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 { out := domain.UpcomingBooking{ ID: booking.ID, @@ -372,6 +378,9 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip if booking.LocationID != nil { out.Location = locationNames[*booking.LocationID] } + if booking.StaffID != nil { + out.StaffName = staffNames[*booking.StaffID] + } return out } @@ -408,6 +417,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip 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{ TenantName: membership.Tenant.Name, TenantSlug: membership.Tenant.Slug, @@ -415,7 +433,7 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip Timezone: membership.Tenant.Timezone, PlanCode: shared.NormalizePlanCode(membership.Tenant.PlanCode), PublicBookingURL: "/book/" + membership.Tenant.Slug, - SetupCompletion: 100, + SetupCompletion: setupCompletion(brandName, len(locationNames), len(serviceNames), len(staffNames), scheduleCount), KPIs: []domain.DashboardKPI{ {Code: "bookings_this_week", Label: "Bookings this week", Value: fmt.Sprintf("%d", metrics.BookingsCount)}, {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 } +// 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 { path := "/book/" + tenant.Slug return []domain.WidgetSnippet{ diff --git a/apps/backend/internal/catalog/service.go b/apps/backend/internal/catalog/service.go index 61f363f..a4624c9 100644 --- a/apps/backend/internal/catalog/service.go +++ b/apps/backend/internal/catalog/service.go @@ -18,6 +18,9 @@ var ( ErrBlockedDayNotFound = errors.New("blocked day not found") ErrCustomerNotFound = errors.New("customer 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") ErrBookingConflict = errors.New("booking conflicts with an existing reservation") 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) } +// ============================================ +// 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 // ============================================ diff --git a/apps/backend/internal/catalog/service_test.go b/apps/backend/internal/catalog/service_test.go new file mode 100644 index 0000000..2cc3986 --- /dev/null +++ b/apps/backend/internal/catalog/service_test.go @@ -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: "demo@bookra.dev", 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) + } +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 14c6d0a..fc164f8 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -65,6 +65,19 @@ type Repository interface { UpdateLocation(ctx context.Context, locationID string, params UpdateLocationParams) (LocationRecord, 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 ListBlockedDaysByTenant(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BlockedDayRecord, error) CreateBlockedDay(ctx context.Context, params CreateBlockedDayParams) (BlockedDayRecord, error) @@ -225,6 +238,50 @@ type ServiceRecord struct { BufferBeforeMinutes int BufferAfterMinutes 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 { @@ -670,6 +727,7 @@ type MemoryRepository struct { billingSnapshot BillingSnapshotRecord recordedEvents map[string]struct{} locations []LocationRecord + staff []StaffRecord blockedDays []BlockedDayRecord customers []CustomerRecord workingHours []WorkingHoursRecord @@ -732,6 +790,17 @@ func NewMemoryRepository() *MemoryRepository { BufferBeforeMinutes: 0, BufferAfterMinutes: 15, PriceCents: 120000, + CreatedAt: now, + }, + }, + staff: []StaffRecord{ + { + ID: staffID, + TenantID: tenantID, + LocationID: &locationID, + DisplayName: "Demo Stylist", + Active: true, + CreatedAt: now, }, }, rules: []AvailabilityRuleRecord{ @@ -857,6 +926,7 @@ func (r *MemoryRepository) CreateTenantForUser(_ context.Context, params CreateT BufferAfterMinutes: maxInt(params.BufferAfterMinutes, 0), PriceCents: 0, }} + r.staff = nil r.rules = nil blocks := params.AvailabilityBlocks if len(blocks) == 0 { @@ -937,6 +1007,153 @@ func (r *MemoryRepository) ListServicesByTenant(_ context.Context, tenantID stri 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) { if tenantID != r.tenant.ID { return nil, nil diff --git a/apps/backend/internal/db/services.go b/apps/backend/internal/db/services.go index 2cc7538..f9bc31b 100644 --- a/apps/backend/internal/db/services.go +++ b/apps/backend/internal/db/services.go @@ -5,9 +5,11 @@ import ( "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) { 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 WHERE tenant_id = $1 ORDER BY created_at ASC @@ -28,6 +30,7 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string &record.BufferBeforeMinutes, &record.BufferAfterMinutes, &record.PriceCents, + &record.CreatedAt, ); err != nil { return nil, err } @@ -36,6 +39,73 @@ func (r *PGRepository) ListServicesByTenant(ctx context.Context, tenantID string 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) { rows, err := r.pool.Query(ctx, ` SELECT id, tenant_id, staff_id, day_of_week, starts_local, ends_local diff --git a/apps/backend/internal/db/staff.go b/apps/backend/internal/db/staff.go new file mode 100644 index 0000000..d4d25e9 --- /dev/null +++ b/apps/backend/internal/db/staff.go @@ -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 +} diff --git a/apps/backend/internal/domain/models.go b/apps/backend/internal/domain/models.go index 873d293..b7287f9 100644 --- a/apps/backend/internal/domain/models.go +++ b/apps/backend/internal/domain/models.go @@ -267,6 +267,62 @@ type UpdateLocationRequest struct { 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 // ============================================ diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index b68df44..d594edc 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -589,6 +589,156 @@ paths: description: Location deleted "404": 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: post: tags: [Catalog] @@ -1045,6 +1195,20 @@ components: schema: type: string 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: HealthResponse: type: object @@ -1478,6 +1642,81 @@ components: type: integer address: 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: type: object required: [id, date, type] diff --git a/apps/frontend/src/components/dashboard/icons.tsx b/apps/frontend/src/components/dashboard/icons.tsx index 4247e58..c55580f 100644 --- a/apps/frontend/src/components/dashboard/icons.tsx +++ b/apps/frontend/src/components/dashboard/icons.tsx @@ -114,6 +114,12 @@ export const PlusIcon = () => ( ); +export const ScissorsIcon = () => ( + + + +); + export const UsersIcon = () => ( diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 1757fe3..eb196ae 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -16,6 +16,8 @@ const DashboardRoute = lazy(() => import("./routes/dashboard/overview-page")); const DashboardBookingsRoute = lazy(() => import("./routes/dashboard/bookings-page")); const DashboardCustomersRoute = lazy(() => import("./routes/dashboard/customers-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 DashboardSettingsRoute = lazy(() => import("./routes/dashboard/settings-page")); const PublicBookingRoute = lazy(() => import("./routes/public-booking-route").then((module) => ({ default: module.PublicBookingRoute }))); @@ -35,6 +37,8 @@ render( + + diff --git a/apps/frontend/src/providers/i18n-provider.tsx b/apps/frontend/src/providers/i18n-provider.tsx index 80da44f..91c8b88 100644 --- a/apps/frontend/src/providers/i18n-provider.tsx +++ b/apps/frontend/src/providers/i18n-provider.tsx @@ -398,6 +398,41 @@ const dictionaries = { "dashboard.zone.addBlocked": "Přidat blokovaný den", "dashboard.zone.reason": "Důvod", "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.locations": "Lokace", "dashboard.billing.bookings": "Rezervace", @@ -907,6 +942,41 @@ const dictionaries = { "dashboard.zone.addBlocked": "Add blocked day", "dashboard.zone.reason": "Reason", "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.locations": "Locations", "dashboard.billing.bookings": "Bookings", diff --git a/apps/frontend/src/routes/dashboard/bookings-page.tsx b/apps/frontend/src/routes/dashboard/bookings-page.tsx index 946e729..ff8d3e8 100644 --- a/apps/frontend/src/routes/dashboard/bookings-page.tsx +++ b/apps/frontend/src/routes/dashboard/bookings-page.tsx @@ -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 { Select } from "../../components/ui/select"; import { Textarea } from "../../components/ui/textarea"; @@ -25,6 +25,28 @@ function BookingsPage() { 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(() => { let bookings = resolvedAllBookings(); if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus()); @@ -224,7 +246,16 @@ function BookingsPage() {
setNewBookingCustomer(e.currentTarget.value)} /> setNewBookingEmail(e.currentTarget.value)} /> - setNewBookingService(e.currentTarget.value)} /> + 0} fallback={ + setNewBookingService(e.currentTarget.value)} /> + }> + setNewBookingDate(e.currentTarget.value)} /> setNewBookingTime(e.currentTarget.value)} /> diff --git a/apps/frontend/src/routes/dashboard/dashboard-data.ts b/apps/frontend/src/routes/dashboard/dashboard-data.ts index fd829bc..3a1eca2 100644 --- a/apps/frontend/src/routes/dashboard/dashboard-data.ts +++ b/apps/frontend/src/routes/dashboard/dashboard-data.ts @@ -4,7 +4,7 @@ import { useAuth } from "../../providers/auth-provider"; import { useI18n } from "../../providers/i18n-provider"; 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) { const cs = i18n.locale() === "cs"; diff --git a/apps/frontend/src/routes/dashboard/layout.tsx b/apps/frontend/src/routes/dashboard/layout.tsx index dd053ee..0e8d27e 100644 --- a/apps/frontend/src/routes/dashboard/layout.tsx +++ b/apps/frontend/src/routes/dashboard/layout.tsx @@ -8,7 +8,7 @@ import { getInitials, getBookingDuration } from "../../components/dashboard/type import { LayoutDashboardIcon, CalendarDaysIcon, CreditCardIcon, Settings2Icon, LogOutIcon, MenuIcon, XIcon, SparklesIcon, GlobeIcon, SunIcon, MoonIcon, - UserCircleIcon, MapPinIcon + UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon } from "../../components/dashboard/icons"; import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data"; @@ -18,6 +18,8 @@ const navItems = [ { id: "overview", label: "dashboard.overview", icon: LayoutDashboardIcon, path: "/dashboard" }, { id: "bookings", label: "dashboard.bookings", icon: CalendarDaysIcon, path: "/dashboard/bookings" }, { 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: "billing", label: "dashboard.billing", icon: CreditCardIcon, path: "/dashboard/billing" }, { id: "settings", label: "dashboard.settings", icon: Settings2Icon, path: "/dashboard/settings" }, diff --git a/apps/frontend/src/routes/dashboard/services-page.tsx b/apps/frontend/src/routes/dashboard/services-page.tsx new file mode 100644 index 0000000..273c633 --- /dev/null +++ b/apps/frontend/src/routes/dashboard/services-page.tsx @@ -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(null); + const [form, setForm] = createSignal({ ...emptyForm }); + const [saving, setSaving] = createSignal(false); + + const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } }); + const setField = (patch: Partial) => 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 ( +
+
+
+

{data.i18n.t("dashboard.services")}

+

{resolvedServices().length} {data.i18n.t("dashboard.services.subtitle")}

+
+ +
+ +
+ +
+

{editingId() ? data.i18n.t("dashboard.service.edit") : data.i18n.t("dashboard.service.add")}

+
+
+ setField({ name: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.service.namePlaceholder")} /> +
+ setField({ durationMinutes: e.currentTarget.value })} /> + setField({ price: e.currentTarget.value })} /> + setField({ bufferBeforeMinutes: e.currentTarget.value })} /> + setField({ bufferAfterMinutes: e.currentTarget.value })} /> +
+
+ + +
+
+
+ + {cs() ? "Načítání…" : "Loading…"}
+ }> + 0} fallback={ +
+
+

{data.i18n.t("dashboard.service.noServices")}

+

{data.i18n.t("dashboard.service.noServicesDesc")}

+
+ }> +
+ + {(service: any) => ( +
+
+
+
+

{service.name}

+

+ {service.durationMinutes} {data.i18n.t("dashboard.service.min")} + 0 || service.bufferAfterMinutes > 0}> + + • +{service.bufferBeforeMinutes ?? 0}/{service.bufferAfterMinutes ?? 0} {data.i18n.t("dashboard.service.min")} {cs() ? "rezerva" : "buffer"} + + +

+
+
+
+ + + +
+
+ )} +
+
+
+ +
+
+ ); +} + +export default function ServicesRoute() { + return ( + + + + ); +} diff --git a/apps/frontend/src/routes/dashboard/staff-page.tsx b/apps/frontend/src/routes/dashboard/staff-page.tsx new file mode 100644 index 0000000..9f2dbc6 --- /dev/null +++ b/apps/frontend/src/routes/dashboard/staff-page.tsx @@ -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(null); + const [form, setForm] = createSignal({ ...emptyForm }); + const [saving, setSaving] = createSignal(false); + const [showUpgradePrompt, setShowUpgradePrompt] = createSignal(false); + + const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } }); + const setField = (patch: Partial) => 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 ( +
+
+
+

{data.i18n.t("dashboard.staff")}

+

{resolvedStaff().length} {data.i18n.t("dashboard.staff.subtitle")}

+
+ 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"> + + {data.i18n.t("dashboard.zone.limitReached")} + + }> + + +
+ +
+ +
+

{editingId() ? data.i18n.t("dashboard.staff.edit") : data.i18n.t("dashboard.staff.add")}

+
+ setField({ displayName: e.currentTarget.value })} placeholder={data.i18n.t("dashboard.staff.namePlaceholder")} /> + setNewBlockedDate(e.currentTarget.value)} /> setNewBlockedReason(e.currentTarget.value)} placeholder={cs() ? "např. Státní svátek" : "e.g. Public holiday"} /> +