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:
Tomas DvorakandDevin committed 2026-09-18 11:21:46 +02:00
1 parent 0405106587
commit 668a54ae1d
15 files changed
+563 -5

No files matched your search

+15
View File
@@ -721,6 +721,21 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
}
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
@@ -26,6 +26,7 @@ type CustomerService struct {
}
type CustomerNotifier interface {
SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error
SendBookingReschedule(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{}
func (n *customerNoopNotifier) SendBookingConfirmation(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
func (n *customerNoopNotifier) SendBookingReschedule(ctx context.Context, data notifications.BookingEmailData) error {
return nil
}
@@ -194,6 +199,15 @@ func (s *CustomerService) CancelBooking(ctx context.Context, reference string, t
// Send cancellation email (async)
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
}
@@ -270,3 +284,37 @@ func (s *CustomerService) sendCancellationEmail(booking db.BookingRecord) {
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 (
"context"
"errors"
"fmt"
"net/url"
"testing"
"time"
@@ -399,3 +401,52 @@ func TestCreateAppointmentRejectsForeignStaff(t *testing.T) {
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)
}
}
+108
View File
@@ -11,6 +11,7 @@ import (
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
"bookra/apps/backend/internal/notifications"
"github.com/jackc/pgx/v5"
)
@@ -39,6 +40,8 @@ type Service struct {
}
notificationService interface {
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)
}, notificationService interface {
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 {
return &Service{repo: repo, billingService: billingService, notificationService: notificationService}
}
@@ -1263,3 +1268,106 @@ func hhmm(value string) string {
}
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 (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -547,3 +548,95 @@ func TestDeleteClassSessionPreservesBooking(t *testing.T) {
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)
}
}
+13 -1
View File
@@ -9,7 +9,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
rows, err := r.pool.Query(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, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE tenant_id = $1 AND starts_at < $3 AND ends_at > $2
ORDER BY starts_at ASC
@@ -38,6 +38,7 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
&record.Reference,
&record.ManageToken,
&record.Notes,
&record.CreatedAt,
); err != nil {
return nil, err
}
@@ -84,6 +85,17 @@ func (r *PGRepository) AppendWaitlistEntry(ctx context.Context, params WaitlistE
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 {
_, err := r.pool.Exec(ctx, `
INSERT INTO reminder_jobs (tenant_id, booking_id, channel, scheduled_for)
+35 -2
View File
@@ -36,6 +36,7 @@ type Repository interface {
ListBookingsByTenantBetween(ctx context.Context, tenantID string, from time.Time, to time.Time) ([]BookingRecord, error)
CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error)
AppendWaitlistEntry(ctx context.Context, params WaitlistEntryParams) error
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
CreateReminderJob(ctx context.Context, params ReminderJobParams) error
ListDueReminderJobs(ctx context.Context, dueBefore time.Time, limit int) ([]ReminderJobRecord, error)
MarkReminderJobDispatched(ctx context.Context, reminderJobID string, status string, dispatchedAt time.Time) error
@@ -103,6 +104,7 @@ type Repository interface {
// Customer Booking Management
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
RescheduleBooking(ctx context.Context, bookingID string, startsAt time.Time, endsAt time.Time) error
@@ -352,6 +354,7 @@ type BookingRecord struct {
Reference string
ManageToken string
Notes string
CreatedAt time.Time
}
type CreateBookingParams struct {
@@ -719,11 +722,24 @@ func (r *PGRepository) GetBookingByReference(ctx context.Context, reference stri
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, '')
COALESCE(manage_token, ''), COALESCE(notes, ''), created_at
FROM bookings
WHERE reference = $1
`, 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
}
@@ -1343,6 +1359,7 @@ func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBooking
Reference: params.Reference,
ManageToken: params.ManageToken,
Notes: params.Notes,
CreatedAt: time.Now().UTC(),
})
return created, nil
}
@@ -1352,6 +1369,13 @@ func (r *MemoryRepository) AppendWaitlistEntry(_ context.Context, _ WaitlistEntr
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 {
reference := params.BookingID
startsAt := time.Now().UTC()
@@ -1756,6 +1780,15 @@ func (r *MemoryRepository) GetBookingByReference(_ context.Context, reference st
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 {
for i, b := range r.bookings {
if b.ID == bookingID {
+20
View File
@@ -800,6 +800,26 @@ paths:
description: Invalid request
"409":
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:
get:
tags: [Catalog]
@@ -309,6 +309,8 @@ const dictionaries = {
"dashboard.totalBookings": "celkem rezervací",
"dashboard.newBooking": "Nová rezervace",
"dashboard.bookingDetails": "Detail rezervace",
"dashboard.booking.cancelBooking": "Zrušit rezervaci",
"dashboard.booking.confirmCancel": "Potvrdit zrušení?",
"dashboard.customerDetails": "Detail zákazníka",
"dashboard.close": "Zavřít",
"dashboard.edit": "Upravit",
@@ -324,6 +326,7 @@ const dictionaries = {
"dashboard.nextMonth": "Další měsíc",
"dashboard.confirmed": "Potvrzeno",
"dashboard.pending": "Čeká",
"dashboard.waitlisted": "Čekací listina",
"dashboard.cancelled": "Zrušeno",
"dashboard.completed": "Dokončeno",
"dashboard.welcome.title": "Vítejte v Bookra",
@@ -878,6 +881,8 @@ const dictionaries = {
"dashboard.totalBookings": "total bookings",
"dashboard.newBooking": "New Booking",
"dashboard.bookingDetails": "Booking Details",
"dashboard.booking.cancelBooking": "Cancel booking",
"dashboard.booking.confirmCancel": "Confirm cancellation?",
"dashboard.customerDetails": "Customer Details",
"dashboard.close": "Close",
"dashboard.edit": "Edit",
@@ -893,6 +898,7 @@ const dictionaries = {
"dashboard.nextMonth": "Next month",
"dashboard.confirmed": "Confirmed",
"dashboard.pending": "Pending",
"dashboard.waitlisted": "Waitlisted",
"dashboard.cancelled": "Cancelled",
"dashboard.completed": "Completed",
"dashboard.welcome.title": "Welcome to Bookra",
@@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout";
function BookingsPage() {
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 [searchQuery, setSearchQuery] = createSignal("");
const [showNewBooking, setShowNewBooking] = createSignal(false);
@@ -81,6 +81,7 @@ function BookingsPage() {
total: bookings.length,
confirmed: bookings.filter((b: any) => b.status === "confirmed").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,
completed: bookings.filter((b: any) => b.status === "completed").length,
};
@@ -111,6 +112,7 @@ function BookingsPage() {
const statusLabels: Record<string, string> = {
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")
};
@@ -130,7 +132,7 @@ function BookingsPage() {
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ 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: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
].map((s) => (
@@ -151,6 +153,7 @@ function BookingsPage() {
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
{ 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: "completed", label: data.i18n.t("dashboard.completed") },
]} />
@@ -218,6 +221,7 @@ function BookingsPage() {
</button>
<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 === "waitlisted" ? "bg-warning-subtle text-warning"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "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: "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: "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 {
@@ -11,6 +11,7 @@ import {
UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
} from "../../components/dashboard/icons";
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
import { apiClient } from "../../lib/api-client";
export { useDashboardData };
@@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) {
const theme = useTheme();
const location = useLocation();
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 p = location.pathname;
@@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
</div>
<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 === "waitlisted" ? "bg-warning-subtle text-warning"
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
</div>
</Show>
</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>
</Show>