mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(bookings): dashboard cancellation and waitlist promotion
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
when a class seat frees, on both dashboard and customer cancel paths;
promoted customers get a confirmation email, waitlist ledger marked
resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
ResolveWaitlistEntry added to the repository surface
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0405106587
commit
668a54ae1d
15 files changed
+563
-5
No files matched your search
@@ -721,6 +721,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, response)
|
c.JSON(http.StatusCreated, response)
|
||||||
})
|
})
|
||||||
|
protected.POST("/catalog/bookings/:bookingID/cancel", func(c *gin.Context) {
|
||||||
|
err := catalogService.CancelBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"))
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrBookingNotFound):
|
||||||
|
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, gin.H{"status": "cancelled"})
|
||||||
|
})
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// CATALOG API - Blocked Days
|
// CATALOG API - Blocked Days
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type CustomerService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CustomerNotifier interface {
|
type CustomerNotifier interface {
|
||||||
|
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
|
SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,10 @@ func NewCustomerService(repo db.Repository, notifier CustomerNotifier, frontendU
|
|||||||
|
|
||||||
type customerNoopNotifier struct{}
|
type customerNoopNotifier struct{}
|
||||||
|
|
||||||
|
func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
|
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -194,6 +199,15 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
|
|||||||
// Send cancellation email (async)
|
// Send cancellation email (async)
|
||||||
go s.sendCancellationEmail(booking)
|
go s.sendCancellationEmail(booking)
|
||||||
|
|
||||||
|
// A freed class seat promotes the earliest waitlisted booking.
|
||||||
|
if booking.ClassSessionID != nil {
|
||||||
|
if promoted, err := PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID); err == nil {
|
||||||
|
for _, promotedBooking := range promoted {
|
||||||
|
go s.sendPromotionEmail(promotedBooking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,3 +284,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
|
|||||||
|
|
||||||
s.notifier.SendBookingCancellation(ctx, emailData)
|
s.notifier.SendBookingCancellation(ctx, emailData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendPromotionEmail confirms a booking that moved off the class waitlist.
|
||||||
|
func (s *CustomerService) sendPromotionEmail(booking db.BookingRecord) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||||
|
brandColor := brand.PrimaryColor
|
||||||
|
if brandColor == "" {
|
||||||
|
brandColor = "#a65c3e"
|
||||||
|
}
|
||||||
|
serviceName, locationName := s.descriptors(ctx, booking)
|
||||||
|
|
||||||
|
s.notifier.SendBookingConfirmation(ctx, notifications.BookingEmailData{
|
||||||
|
TenantID: tenant.ID,
|
||||||
|
TenantName: tenant.Name,
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
BrandColor: brandColor,
|
||||||
|
CustomerName: booking.CustomerName,
|
||||||
|
CustomerEmail: booking.CustomerEmail,
|
||||||
|
Service: serviceName,
|
||||||
|
Location: locationName,
|
||||||
|
Reference: booking.Reference,
|
||||||
|
StartsAt: booking.StartsAt,
|
||||||
|
EndsAt: booking.EndsAt,
|
||||||
|
Timezone: tenant.Timezone,
|
||||||
|
Locale: tenant.Locale,
|
||||||
|
ManagementURL: s.managementURL(booking),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package bookings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"bookra/apps/backend/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromoteWaitlist confirms the earliest waitlisted bookings on a class session
|
||||||
|
// while free capacity remains. It returns the promoted records so callers can
|
||||||
|
// notify customers. A missing session is treated as nothing to promote.
|
||||||
|
func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionID string) ([]db.BookingRecord, error) {
|
||||||
|
session, err := repo.GetClassSessionByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmed := 0
|
||||||
|
var waitlisted []db.BookingRecord
|
||||||
|
for _, booking := range bookings {
|
||||||
|
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch booking.Status {
|
||||||
|
case "confirmed":
|
||||||
|
confirmed++
|
||||||
|
case "waitlisted":
|
||||||
|
waitlisted = append(waitlisted, booking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(waitlisted) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
sort.SliceStable(waitlisted, func(i, j int) bool {
|
||||||
|
return waitlisted[i].CreatedAt.Before(waitlisted[j].CreatedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
var promoted []db.BookingRecord
|
||||||
|
for _, booking := range waitlisted {
|
||||||
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||||
|
return promoted, err
|
||||||
|
}
|
||||||
|
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
||||||
|
confirmed++
|
||||||
|
promoted = append(promoted, booking)
|
||||||
|
}
|
||||||
|
return promoted, nil
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package bookings
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -399,3 +401,52 @@ func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
t.Fatalf("expected ErrInvalidBooking, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCustomerCancelPromotesWaitlist(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
service := NewService(repo, nil, "http://localhost:3000")
|
||||||
|
customerService := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
session, err := repo.GetClassSessionByID(ctx, "4bf74c12-44dd-45ca-86bb-b104f16f2435")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get session: %v", err)
|
||||||
|
}
|
||||||
|
sessionID := session.ID
|
||||||
|
book := func(email string) domain.CreateBookingResponse {
|
||||||
|
resp, err := service.Create(ctx, domain.CreateBookingRequest{
|
||||||
|
TenantSlug: "studio-atelier",
|
||||||
|
BookingMode: "class",
|
||||||
|
ClassSessionID: &sessionID,
|
||||||
|
CustomerName: "Test User",
|
||||||
|
CustomerEmail: email,
|
||||||
|
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
|
||||||
|
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("book %s: %v", email, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmed []domain.CreateBookingResponse
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
confirmed = append(confirmed, book(fmt.Sprintf("seat%[email protected]", i)))
|
||||||
|
}
|
||||||
|
waiter := book("[email protected]")
|
||||||
|
if waiter.Status != "waitlisted" {
|
||||||
|
t.Fatalf("expected waitlisted, got %s", waiter.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer cancels a confirmed seat via the management token.
|
||||||
|
manageURL, _ := url.Parse(confirmed[0].ManageURL)
|
||||||
|
token := manageURL.Query().Get("token")
|
||||||
|
if err := customerService.CancelBooking(ctx, confirmed[0].Reference, token); err != nil {
|
||||||
|
t.Fatalf("customer cancel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
booking, _ := repo.GetBookingByID(ctx, waiter.BookingID)
|
||||||
|
if booking.Status != "confirmed" {
|
||||||
|
t.Fatalf("expected waiter promoted to confirmed, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"bookra/apps/backend/internal/bookings"
|
"bookra/apps/backend/internal/bookings"
|
||||||
"bookra/apps/backend/internal/db"
|
"bookra/apps/backend/internal/db"
|
||||||
"bookra/apps/backend/internal/domain"
|
"bookra/apps/backend/internal/domain"
|
||||||
|
"bookra/apps/backend/internal/notifications"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,8 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
notificationService interface {
|
notificationService interface {
|
||||||
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||||
|
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
|
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,8 @@ func NewService(repo db.Repository, billingService interface {
|
|||||||
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
GetEntitlements(ctx context.Context, tenantID string) (domain.PlanEntitlements, error)
|
||||||
}, notificationService interface {
|
}, notificationService interface {
|
||||||
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
SendUsageWarning(ctx context.Context, tenantID string, locationCount, locationLimit, usagePercent int) error
|
||||||
|
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
|
SendBookingCancellation(ctx context.Context, data notifications.BookingEmailData) error
|
||||||
}) *Service {
|
}) *Service {
|
||||||
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
|
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
|
||||||
}
|
}
|
||||||
@@ -1263,3 +1268,106 @@ func hhmm(value string) string {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BOOKING LIFECYCLE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// CancelBooking cancels a tenant-owned booking from the dashboard. Cancelling a
|
||||||
|
// class booking frees the seat and promotes the earliest waitlisted bookings.
|
||||||
|
func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal, bookingID string) error {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return ErrTenantMembership
|
||||||
|
}
|
||||||
|
|
||||||
|
booking, err := s.repo.GetBookingByID(ctx, bookingID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrBookingNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if booking.TenantID != membership.Tenant.ID {
|
||||||
|
return ErrBookingNotFound
|
||||||
|
}
|
||||||
|
if booking.Status == "cancelled" {
|
||||||
|
return fmt.Errorf("%w: booking already cancelled", ErrInvalidInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateBookingStatus(ctx, booking.ID, "cancelled"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.sendBookingEmail(booking, false)
|
||||||
|
|
||||||
|
if booking.ClassSessionID != nil {
|
||||||
|
promoted, err := bookings.PromoteWaitlist(ctx, s.repo, booking.TenantID, *booking.ClassSessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, promotedBooking := range promoted {
|
||||||
|
go s.sendBookingEmail(promotedBooking, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendBookingEmail builds the shared payload and fires a confirmation
|
||||||
|
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
||||||
|
// email delivery must not fail the cancellation itself.
|
||||||
|
func (s *Service) sendBookingEmail(booking db.BookingRecord, promoted bool) {
|
||||||
|
if s.notificationService == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
brand, _ := s.repo.GetBrandProfile(ctx, tenant.ID)
|
||||||
|
brandColor := brand.PrimaryColor
|
||||||
|
if brandColor == "" {
|
||||||
|
brandColor = "#a65c3e"
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceName, locationName string
|
||||||
|
if booking.ServiceID != nil {
|
||||||
|
if service, err := s.repo.GetServiceByID(ctx, *booking.ServiceID); err == nil {
|
||||||
|
serviceName = service.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if booking.ClassSessionID != nil {
|
||||||
|
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
|
||||||
|
serviceName = session.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if booking.LocationID != nil {
|
||||||
|
if location, err := s.repo.GetLocationByID(ctx, *booking.LocationID); err == nil {
|
||||||
|
locationName = location.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := notifications.BookingEmailData{
|
||||||
|
TenantID: tenant.ID,
|
||||||
|
TenantName: tenant.Name,
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
BrandColor: brandColor,
|
||||||
|
CustomerName: booking.CustomerName,
|
||||||
|
CustomerEmail: booking.CustomerEmail,
|
||||||
|
Service: serviceName,
|
||||||
|
Location: locationName,
|
||||||
|
Reference: booking.Reference,
|
||||||
|
StartsAt: booking.StartsAt,
|
||||||
|
EndsAt: booking.EndsAt,
|
||||||
|
Timezone: tenant.Timezone,
|
||||||
|
Locale: tenant.Locale,
|
||||||
|
}
|
||||||
|
if promoted {
|
||||||
|
s.notificationService.SendBookingConfirmation(ctx, data)
|
||||||
|
} else {
|
||||||
|
s.notificationService.SendBookingCancellation(ctx, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package catalog
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -547,3 +548,95 @@ func TestDeleteClassSessionPreservesBooking(t *testing.T) {
|
|||||||
t.Fatal("expected booking class_session_id to be nulled")
|
t.Fatal("expected booking class_session_id to be nulled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||||
|
|
||||||
|
func bookClass(t *testing.T, repo db.Repository, email string) domain.CreateBookingResponse {
|
||||||
|
t.Helper()
|
||||||
|
session, err := repo.GetClassSessionByID(context.Background(), demoSessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get session: %v", err)
|
||||||
|
}
|
||||||
|
bookingSvc := bookings.NewService(repo, nil, "")
|
||||||
|
resp, err := bookingSvc.Create(context.Background(), domain.CreateBookingRequest{
|
||||||
|
TenantSlug: "studio-atelier",
|
||||||
|
CustomerName: "Test User",
|
||||||
|
CustomerEmail: email,
|
||||||
|
BookingMode: "class",
|
||||||
|
ClassSessionID: &[]string{demoSessionID}[0],
|
||||||
|
StartsAt: session.StartsAt.UTC().Format(time.RFC3339),
|
||||||
|
EndsAt: session.EndsAt.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("book class for %s: %v", email, err)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelBookingLifecycle(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
resp := bookClass(t, repo, "[email protected]")
|
||||||
|
|
||||||
|
if err := svc.CancelBooking(ctx, owner, resp.BookingID); err != nil {
|
||||||
|
t.Fatalf("cancel booking: %v", err)
|
||||||
|
}
|
||||||
|
booking, _ := repo.GetBookingByID(ctx, resp.BookingID)
|
||||||
|
if booking.Status != "cancelled" {
|
||||||
|
t.Fatalf("expected cancelled, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.CancelBooking(ctx, owner, resp.BookingID); !errors.Is(err, ErrInvalidInput) {
|
||||||
|
t.Fatalf("expected ErrInvalidInput on second cancel, got %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.CancelBooking(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrBookingNotFound) {
|
||||||
|
t.Fatalf("expected ErrBookingNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||||
|
if err := svc.CancelBooking(ctx, stranger, resp.BookingID); !errors.Is(err, ErrTenantMembership) {
|
||||||
|
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Seeded session capacity is 4 — fill it, then overflow onto the waitlist.
|
||||||
|
var confirmed []domain.CreateBookingResponse
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
confirmed = append(confirmed, bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i)))
|
||||||
|
}
|
||||||
|
first := bookClass(t, repo, "[email protected]")
|
||||||
|
second := bookClass(t, repo, "[email protected]")
|
||||||
|
|
||||||
|
booking, _ := repo.GetBookingByID(ctx, first.BookingID)
|
||||||
|
if booking.Status != "waitlisted" {
|
||||||
|
t.Fatalf("expected waitlisted, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel a confirmed seat -> earliest waiter is promoted, second stays queued.
|
||||||
|
if err := svc.CancelBooking(ctx, owner, confirmed[0].BookingID); err != nil {
|
||||||
|
t.Fatalf("cancel: %v", err)
|
||||||
|
}
|
||||||
|
booking, _ = repo.GetBookingByID(ctx, first.BookingID)
|
||||||
|
if booking.Status != "confirmed" {
|
||||||
|
t.Fatalf("expected first waiter promoted, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
|
||||||
|
if booking.Status != "waitlisted" {
|
||||||
|
t.Fatalf("expected second waiter still queued, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free another seat -> second waiter promoted.
|
||||||
|
if err := svc.CancelBooking(ctx, owner, confirmed[1].BookingID); err != nil {
|
||||||
|
t.Fatalf("cancel second: %v", err)
|
||||||
|
}
|
||||||
|
booking, _ = repo.GetBookingByID(ctx, second.BookingID)
|
||||||
|
if booking.Status != "confirmed" {
|
||||||
|
t.Fatalf("expected second waiter promoted, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||||
COALESCE(manage_token, ''), COALESCE(notes, '')
|
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
|
||||||
ORDER BY starts_at ASC
|
ORDER BY starts_at ASC
|
||||||
@@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
&record.Reference,
|
&record.Reference,
|
||||||
&record.ManageToken,
|
&record.ManageToken,
|
||||||
&record.Notes,
|
&record.Notes,
|
||||||
|
&record.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveWaitlistEntry marks the pending ledger row for a promoted booking.
|
||||||
|
// Entries carry no booking_id, so the session + customer email pair is the
|
||||||
|
// practical key.
|
||||||
|
func (r *PGRepository) ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
UPDATE waitlist_entries SET status = 'resolved'
|
||||||
|
WHERE tenant_id = $1 AND class_session_id = $2 AND customer_email = $3 AND status = 'pending'
|
||||||
|
`, tenantID, sessionID, customerEmail)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
func (r *PGRepository) CreateReminderJob(ctx context.Context, params ReminderJobParams) error {
|
||||||
_, err := r.pool.Exec(ctx, `
|
_, err := r.pool.Exec(ctx, `
|
||||||
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Repository interface {
|
|||||||
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
|
||||||
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
|
||||||
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
|
||||||
|
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
|
||||||
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
|
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
|
||||||
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
|
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
|
||||||
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
|
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
|
||||||
@@ -103,6 +104,7 @@ type Repository interface {
|
|||||||
|
|
||||||
// Customer Booking Management
|
// Customer Booking Management
|
||||||
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
|
GetBookingByReference(ctx context.Context, reference string) (BookingRecord, error)
|
||||||
|
GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error)
|
||||||
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
|
UpdateBookingStatus(ctx context.Context, bookingID string, status string) error
|
||||||
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
|
||||||
|
|
||||||
@@ -352,6 +354,7 @@ type BookingRecord struct {
|
|||||||
Reference string
|
Reference string
|
||||||
ManageToken string
|
ManageToken string
|
||||||
Notes string
|
Notes string
|
||||||
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateBookingParams struct {
|
type CreateBookingParams struct {
|
||||||
@@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
|
|||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||||
COALESCE(manage_token, ''), COALESCE(notes, '')
|
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE reference = $1
|
WHERE reference = $1
|
||||||
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
`, reference).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||||
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes)
|
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
||||||
|
return rec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PGRepository) GetBookingByID(ctx context.Context, bookingID string) (BookingRecord, error) {
|
||||||
|
var rec BookingRecord
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_id, service_id, class_session_id, staff_id, location_id,
|
||||||
|
customer_name, customer_email, customer_phone, starts_at, ends_at, status, reference,
|
||||||
|
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
|
||||||
|
FROM bookings
|
||||||
|
WHERE id = $1
|
||||||
|
`, bookingID).Scan(&rec.ID, &rec.TenantID, &rec.ServiceID, &rec.ClassSessionID, &rec.StaffID, &rec.LocationID,
|
||||||
|
&rec.CustomerName, &rec.CustomerEmail, &rec.CustomerPhone, &rec.StartsAt, &rec.EndsAt, &rec.Status, &rec.Reference, &rec.ManageToken, &rec.Notes, &rec.CreatedAt)
|
||||||
return rec, err
|
return rec, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
|
|||||||
Reference: params.Reference,
|
Reference: params.Reference,
|
||||||
ManageToken: params.ManageToken,
|
ManageToken: params.ManageToken,
|
||||||
Notes: params.Notes,
|
Notes: params.Notes,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
@@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) ResolveWaitlistEntry(_ context.Context, _, _, _ string) error {
|
||||||
|
if r.waitlistSize > 0 {
|
||||||
|
r.waitlistSize--
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
|
func (r *MemoryRepository) CreateReminderJob(_ context.Context, params ReminderJobParams) error {
|
||||||
reference := params.BookingID
|
reference := params.BookingID
|
||||||
startsAt := time.Now().UTC()
|
startsAt := time.Now().UTC()
|
||||||
@@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st
|
|||||||
return BookingRecord{}, pgx.ErrNoRows
|
return BookingRecord{}, pgx.ErrNoRows
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) GetBookingByID(_ context.Context, bookingID string) (BookingRecord, error) {
|
||||||
|
for _, b := range r.bookings {
|
||||||
|
if b.ID == bookingID {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BookingRecord{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
|
func (r *MemoryRepository) UpdateBookingStatus(_ context.Context, bookingID string, status string) error {
|
||||||
for i, b := range r.bookings {
|
for i, b := range r.bookings {
|
||||||
if b.ID == bookingID {
|
if b.ID == bookingID {
|
||||||
|
|||||||
@@ -800,6 +800,26 @@ paths:
|
|||||||
description: Invalid request
|
description: Invalid request
|
||||||
"409":
|
"409":
|
||||||
description: Conflict with existing booking
|
description: Conflict with existing booking
|
||||||
|
/v1/catalog/bookings/{bookingID}/cancel:
|
||||||
|
post:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: cancelDashboardBooking
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: bookingID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Booking cancelled; freed class seats promote the waitlist
|
||||||
|
"400":
|
||||||
|
description: Booking already cancelled
|
||||||
|
"404":
|
||||||
|
description: Booking not found
|
||||||
/v1/catalog/blocked-days:
|
/v1/catalog/blocked-days:
|
||||||
get:
|
get:
|
||||||
tags: [Catalog]
|
tags: [Catalog]
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ const dictionaries = {
|
|||||||
"dashboard.totalBookings": "celkem rezervací",
|
"dashboard.totalBookings": "celkem rezervací",
|
||||||
"dashboard.newBooking": "Nová rezervace",
|
"dashboard.newBooking": "Nová rezervace",
|
||||||
"dashboard.bookingDetails": "Detail rezervace",
|
"dashboard.bookingDetails": "Detail rezervace",
|
||||||
|
"dashboard.booking.cancelBooking": "Zrušit rezervaci",
|
||||||
|
"dashboard.booking.confirmCancel": "Potvrdit zrušení?",
|
||||||
"dashboard.customerDetails": "Detail zákazníka",
|
"dashboard.customerDetails": "Detail zákazníka",
|
||||||
"dashboard.close": "Zavřít",
|
"dashboard.close": "Zavřít",
|
||||||
"dashboard.edit": "Upravit",
|
"dashboard.edit": "Upravit",
|
||||||
@@ -324,6 +326,7 @@ const dictionaries = {
|
|||||||
"dashboard.nextMonth": "Další měsíc",
|
"dashboard.nextMonth": "Další měsíc",
|
||||||
"dashboard.confirmed": "Potvrzeno",
|
"dashboard.confirmed": "Potvrzeno",
|
||||||
"dashboard.pending": "Čeká",
|
"dashboard.pending": "Čeká",
|
||||||
|
"dashboard.waitlisted": "Čekací listina",
|
||||||
"dashboard.cancelled": "Zrušeno",
|
"dashboard.cancelled": "Zrušeno",
|
||||||
"dashboard.completed": "Dokončeno",
|
"dashboard.completed": "Dokončeno",
|
||||||
"dashboard.welcome.title": "Vítejte v Bookra",
|
"dashboard.welcome.title": "Vítejte v Bookra",
|
||||||
@@ -878,6 +881,8 @@ const dictionaries = {
|
|||||||
"dashboard.totalBookings": "total bookings",
|
"dashboard.totalBookings": "total bookings",
|
||||||
"dashboard.newBooking": "New Booking",
|
"dashboard.newBooking": "New Booking",
|
||||||
"dashboard.bookingDetails": "Booking Details",
|
"dashboard.bookingDetails": "Booking Details",
|
||||||
|
"dashboard.booking.cancelBooking": "Cancel booking",
|
||||||
|
"dashboard.booking.confirmCancel": "Confirm cancellation?",
|
||||||
"dashboard.customerDetails": "Customer Details",
|
"dashboard.customerDetails": "Customer Details",
|
||||||
"dashboard.close": "Close",
|
"dashboard.close": "Close",
|
||||||
"dashboard.edit": "Edit",
|
"dashboard.edit": "Edit",
|
||||||
@@ -893,6 +898,7 @@ const dictionaries = {
|
|||||||
"dashboard.nextMonth": "Next month",
|
"dashboard.nextMonth": "Next month",
|
||||||
"dashboard.confirmed": "Confirmed",
|
"dashboard.confirmed": "Confirmed",
|
||||||
"dashboard.pending": "Pending",
|
"dashboard.pending": "Pending",
|
||||||
|
"dashboard.waitlisted": "Waitlisted",
|
||||||
"dashboard.cancelled": "Cancelled",
|
"dashboard.cancelled": "Cancelled",
|
||||||
"dashboard.completed": "Completed",
|
"dashboard.completed": "Completed",
|
||||||
"dashboard.welcome.title": "Welcome to Bookra",
|
"dashboard.welcome.title": "Welcome to Bookra",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout";
|
|||||||
|
|
||||||
function BookingsPage() {
|
function BookingsPage() {
|
||||||
const data = useDashboardData();
|
const data = useDashboardData();
|
||||||
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
|
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "waitlisted" | "cancelled" | "completed">("all");
|
||||||
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
|
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
|
||||||
const [searchQuery, setSearchQuery] = createSignal("");
|
const [searchQuery, setSearchQuery] = createSignal("");
|
||||||
const [showNewBooking, setShowNewBooking] = createSignal(false);
|
const [showNewBooking, setShowNewBooking] = createSignal(false);
|
||||||
@@ -81,6 +81,7 @@ function BookingsPage() {
|
|||||||
total: bookings.length,
|
total: bookings.length,
|
||||||
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
|
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
|
||||||
pending: bookings.filter((b: any) => b.status === "pending").length,
|
pending: bookings.filter((b: any) => b.status === "pending").length,
|
||||||
|
waitlisted: bookings.filter((b: any) => b.status === "waitlisted").length,
|
||||||
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
|
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
|
||||||
completed: bookings.filter((b: any) => b.status === "completed").length,
|
completed: bookings.filter((b: any) => b.status === "completed").length,
|
||||||
};
|
};
|
||||||
@@ -111,6 +112,7 @@ function BookingsPage() {
|
|||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
|
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
|
||||||
|
waitlisted: data.i18n.t("dashboard.waitlisted"),
|
||||||
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
|
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ function BookingsPage() {
|
|||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
|
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
|
||||||
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
|
{ key: "waitlisted", label: data.i18n.t("dashboard.waitlisted"), value: bookingStats().waitlisted },
|
||||||
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
|
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
|
||||||
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
|
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
@@ -151,6 +153,7 @@ function BookingsPage() {
|
|||||||
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
||||||
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
|
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
|
||||||
{ value: "pending", label: data.i18n.t("dashboard.pending") },
|
{ value: "pending", label: data.i18n.t("dashboard.pending") },
|
||||||
|
{ value: "waitlisted", label: data.i18n.t("dashboard.waitlisted") },
|
||||||
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
|
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
|
||||||
{ value: "completed", label: data.i18n.t("dashboard.completed") },
|
{ value: "completed", label: data.i18n.t("dashboard.completed") },
|
||||||
]} />
|
]} />
|
||||||
@@ -218,6 +221,7 @@ function BookingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
|
<span class={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||||
booking.status === "confirmed" ? "bg-accent/10 text-accent"
|
booking.status === "confirmed" ? "bg-accent/10 text-accent"
|
||||||
|
: booking.status === "waitlisted" ? "bg-warning-subtle text-warning"
|
||||||
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
||||||
: "bg-canvas-subtle text-ink-subtle"
|
: "bg-canvas-subtle text-ink-subtle"
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
|||||||
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
|
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
|
||||||
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
|
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
|
||||||
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
|
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
|
||||||
|
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
|
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";
|
||||||
|
import { apiClient } from "../../lib/api-client";
|
||||||
|
|
||||||
export { useDashboardData };
|
export { useDashboardData };
|
||||||
|
|
||||||
@@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
|
||||||
|
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||||
|
|
||||||
|
const handleCancelBooking = async () => {
|
||||||
|
if (cancelConfirm() === "busy") return;
|
||||||
|
if (cancelConfirm() === "idle") { setCancelConfirm("armed"); return; }
|
||||||
|
const booking = data.selectedBooking();
|
||||||
|
const bearer = data.token();
|
||||||
|
if (!booking) return;
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) {
|
||||||
|
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze rušit rezervace." : "Cannot cancel bookings in demo mode.");
|
||||||
|
setCancelConfirm("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCancelConfirm("busy");
|
||||||
|
try {
|
||||||
|
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/cancel`, { headers: { Authorization: `Bearer ${bearer}` } });
|
||||||
|
if (response.error) throw new Error("cancel failed");
|
||||||
|
data.setShowBookingDetail(false);
|
||||||
|
void data.refetchSummary();
|
||||||
|
} catch {
|
||||||
|
data.setDemoNotice(data.i18n.locale() === "cs" ? "Zrušení rezervace selhalo." : "Failed to cancel booking.");
|
||||||
|
} finally {
|
||||||
|
setCancelConfirm("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const activeSection = () => {
|
const activeSection = () => {
|
||||||
const p = location.pathname;
|
const p = location.pathname;
|
||||||
@@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
</div>
|
</div>
|
||||||
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
|
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
|
||||||
|
: data.selectedBooking()?.status === "waitlisted" ? "bg-warning-subtle text-warning"
|
||||||
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
||||||
: "bg-canvas-subtle text-ink-subtle"
|
: "bg-canvas-subtle text-ink-subtle"
|
||||||
}`}>
|
}`}>
|
||||||
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||||
|
<div class="p-6 border-t border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => void handleCancelBooking()}
|
||||||
|
disabled={cancelConfirm() === "busy"}
|
||||||
|
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
|
||||||
|
cancelConfirm() === "armed"
|
||||||
|
? "bg-red-600 text-canvas hover:bg-red-700"
|
||||||
|
: "border border-border text-red-600 hover:bg-red-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cancelConfirm() === "busy"
|
||||||
|
? "…"
|
||||||
|
: cancelConfirm() === "armed"
|
||||||
|
? data.i18n.t("dashboard.booking.confirmCancel")
|
||||||
|
: data.i18n.t("dashboard.booking.cancelBooking")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -287,6 +287,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/catalog/bookings/{bookingID}/cancel": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["cancelDashboardBooking"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/catalog/class-sessions": {
|
"/v1/catalog/class-sessions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2030,6 +2046,40 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
cancelDashboardBooking: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
bookingID: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Booking cancelled; freed class seats promote the waitlist */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Booking already cancelled */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Booking not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listClassSessions: {
|
listClassSessions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -75,6 +75,13 @@
|
|||||||
- dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling
|
- dashboard zones page gained a staff-scope selector on the working-hours tab (business-wide vs individual schedule) and a Classes tab with template create/delete and session scheduling
|
||||||
- normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer
|
- normalized `availability_rules` time columns (`HH:MM:SS`) down to the `HH:MM` API contract in the service layer
|
||||||
- remaining: live staging validation
|
- remaining: live staging validation
|
||||||
|
- `[done/partial]` Milestone 11: Booking lifecycle — dashboard cancellation and waitlist promotion
|
||||||
|
- businesses can cancel bookings from the dashboard (`POST /v1/catalog/bookings/{id}/cancel`), tenant-checked, with a two-step confirm in the booking detail modal
|
||||||
|
- cancelling a class booking (dashboard or customer self-service) now promotes the earliest waitlisted bookings while free capacity remains, ordered by booking `created_at`; promoted customers receive a confirmation email, cancelled customers a cancellation email
|
||||||
|
- shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion
|
||||||
|
- bookings surface the `waitlisted` status: stats card, filter option, warning-tone badge, demo seed entry; `dashboard.{status}` interpolation resolves the new i18n key automatically
|
||||||
|
- `BookingRecord` now carries `created_at` (needed for fair waitlist ordering); `GetBookingByID` added to the repository surface
|
||||||
|
- remaining: live staging validation; waitlist position display and manual promotion controls are not yet exposed
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
Reference in new issue
Block a user