mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #3 from Dvorinka/feat/waitlist-management
feat(classes): session roster + manual waitlist promotion
This commit is contained in:
18 files changed
+1022
-59
No files matched your search
@@ -39,3 +39,10 @@
|
|||||||
- Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`).
|
- Demo bootstrap slug aligned with backend demo tenant (`studio-atelier`).
|
||||||
- Paddle mentions replaced by Stripe in legal + i18n copy.
|
- Paddle mentions replaced by Stripe in legal + i18n copy.
|
||||||
- `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities.
|
- `@neondatabase/neon-js` bumped to 0.7.0-beta — `npm audit` now reports 0 vulnerabilities.
|
||||||
|
|
||||||
|
## Fixed This Session (class roster + waitlist promotion)
|
||||||
|
|
||||||
|
- Class sessions in the zones classes tab now show a `N waiting` badge and expand into a two-column roster panel — confirmed attendees vs waitlist with `#N` queue positions, names, emails, and booking references.
|
||||||
|
- Waitlisted roster entries have a **Confirm seat** action; when the session is full the backend returns 409 and the button re-arms into an explicit "confirm anyway" over-capacity step rather than silently overriding.
|
||||||
|
- The booking detail modal now shows the waitlist position for waitlisted bookings and offers the same promote action (accent button above cancel); the bookings list badges waitlisted rows with their `#N` position.
|
||||||
|
- Demo mode seeds a class session with a populated roster; mutation attempts surface the demo notice instead of failing. Verified in-browser in both Czech and English with a clean console.
|
||||||
@@ -740,10 +740,28 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
|
||||||
})
|
})
|
||||||
|
protected.POST("/catalog/bookings/:bookingID/promote", func(c *gin.Context) {
|
||||||
// ============================================
|
var request domain.PromoteBookingRequest
|
||||||
// CATALOG API - Blocked Days
|
if err := c.ShouldBindJSON(&request); err != nil && !errors.Is(err, io.EOF) {
|
||||||
// ============================================
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := catalogService.PromoteBooking(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("bookingID"), request)
|
||||||
|
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, bookings.ErrNotWaitlisted):
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
case errors.Is(err, bookings.ErrSessionFull):
|
||||||
|
status = http.StatusConflict
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "confirmed"})
|
||||||
|
})
|
||||||
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
protected.GET("/catalog/blocked-days", func(c *gin.Context) {
|
||||||
from := time.Now()
|
from := time.Now()
|
||||||
to := from.AddDate(0, 3, 0) // 3 months ahead
|
to := from.AddDate(0, 3, 0) // 3 months ahead
|
||||||
@@ -1063,6 +1081,19 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusCreated, response)
|
c.JSON(http.StatusCreated, response)
|
||||||
})
|
})
|
||||||
|
protected.GET("/catalog/class-sessions/:sessionID/roster", func(c *gin.Context) {
|
||||||
|
response, err := catalogService.ListSessionRoster(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, catalog.ErrTenantMembership), errors.Is(err, catalog.ErrClassSessionNotFound):
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
})
|
||||||
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
protected.DELETE("/catalog/class-sessions/:sessionID", func(c *gin.Context) {
|
||||||
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
err := catalogService.DeleteClassSession(c.Request.Context(), auth.PrincipalFromContext(c), c.Param("sessionID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
bookings, err := repo.ListBookingsByTenantBetween(ctx, tenantID, session.StartsAt.Add(-time.Second), session.EndsAt.Add(time.Second))
|
bookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,6 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
|||||||
confirmed := 0
|
confirmed := 0
|
||||||
var waitlisted []db.BookingRecord
|
var waitlisted []db.BookingRecord
|
||||||
for _, booking := range bookings {
|
for _, booking := range bookings {
|
||||||
if booking.ClassSessionID == nil || *booking.ClassSessionID != sessionID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch booking.Status {
|
switch booking.Status {
|
||||||
case "confirmed":
|
case "confirmed":
|
||||||
confirmed++
|
confirmed++
|
||||||
@@ -53,20 +50,72 @@ func PromoteWaitlist(ctx context.Context, repo db.Repository, tenantID, sessionI
|
|||||||
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
||||||
return promoted, err
|
return promoted, err
|
||||||
}
|
}
|
||||||
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
|
||||||
// A booking promoted after its reminder already dispatched-as-skipped
|
|
||||||
// would otherwise go unreminded; re-arm whatever the horizon allows.
|
|
||||||
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
|
||||||
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
|
||||||
confirmed++
|
confirmed++
|
||||||
promoted = append(promoted, booking)
|
promoted = append(promoted, booking)
|
||||||
}
|
}
|
||||||
return promoted, nil
|
return promoted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromoteBooking manually confirms one waitlisted class booking. Unlike the
|
||||||
|
// automatic FIFO promotion it targets a specific booking; when the session is
|
||||||
|
// full it fails with ErrSessionFull unless allowOverCapacity is set — a
|
||||||
|
// deliberate business override, not a silent overbooking.
|
||||||
|
func PromoteBooking(ctx context.Context, repo db.Repository, tenantID, bookingID string, allowOverCapacity bool) (db.BookingRecord, error) {
|
||||||
|
booking, err := repo.GetBookingByID(ctx, bookingID)
|
||||||
|
if err != nil {
|
||||||
|
return db.BookingRecord{}, err
|
||||||
|
}
|
||||||
|
if booking.TenantID != tenantID {
|
||||||
|
return db.BookingRecord{}, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
if booking.Status != "waitlisted" || booking.ClassSessionID == nil {
|
||||||
|
return db.BookingRecord{}, ErrNotWaitlisted
|
||||||
|
}
|
||||||
|
sessionID := *booking.ClassSessionID
|
||||||
|
|
||||||
|
if !allowOverCapacity {
|
||||||
|
session, err := repo.GetClassSessionByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return db.BookingRecord{}, err
|
||||||
|
}
|
||||||
|
sessionBookings, err := repo.ListBookingsBySession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return db.BookingRecord{}, err
|
||||||
|
}
|
||||||
|
confirmed := 0
|
||||||
|
for _, other := range sessionBookings {
|
||||||
|
if other.Status == "confirmed" {
|
||||||
|
confirmed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if session.Capacity > 0 && confirmed >= int(session.Capacity) {
|
||||||
|
return db.BookingRecord{}, ErrSessionFull
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := confirmWaitlistedBooking(ctx, repo, tenantID, sessionID, booking); err != nil {
|
||||||
|
return db.BookingRecord{}, err
|
||||||
|
}
|
||||||
|
booking.Status = "confirmed"
|
||||||
|
return booking, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmWaitlistedBooking flips a waitlisted booking to confirmed, resolves
|
||||||
|
// the ledger entry, and re-arms reminder jobs — a booking promoted after its
|
||||||
|
// reminder already dispatched-as-skipped would otherwise go unreminded.
|
||||||
|
func confirmWaitlistedBooking(ctx context.Context, repo db.Repository, tenantID, sessionID string, booking db.BookingRecord) error {
|
||||||
|
if err := repo.UpdateBookingStatus(ctx, booking.ID, "confirmed"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = repo.ResolveWaitlistEntry(ctx, tenantID, sessionID, booking.CustomerEmail)
|
||||||
|
_ = repo.DeletePendingReminderJobsForBooking(ctx, booking.ID)
|
||||||
|
_ = armReminderJobs(ctx, repo, tenantID, booking.ID, booking.StartsAt, booking.CustomerPhone)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// armReminderJobs schedules the email reminder — and the SMS reminder when the
|
// armReminderJobs schedules the email reminder — and the SMS reminder when the
|
||||||
// tenant opted into SMS and a phone number is on file — for a booking whose
|
// tenant opted into SMS and a phone number is on file — for a booking whose
|
||||||
// start still falls inside a reminder horizon. A start too close or too far
|
// start still falls inside a reminder horizon. A start too close or too far
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ var (
|
|||||||
ErrBookingConflict = errors.New("booking conflict")
|
ErrBookingConflict = errors.New("booking conflict")
|
||||||
ErrTenantMembership = errors.New("tenant membership not found")
|
ErrTenantMembership = errors.New("tenant membership not found")
|
||||||
ErrBookingNotFound = errors.New("booking not found")
|
ErrBookingNotFound = errors.New("booking not found")
|
||||||
|
ErrNotWaitlisted = errors.New("booking is not on a waitlist")
|
||||||
|
ErrSessionFull = errors.New("class session is at capacity")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -538,14 +540,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
}
|
}
|
||||||
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
|
toDomain := func(booking db.BookingRecord) domain.UpcomingBooking {
|
||||||
out := domain.UpcomingBooking{
|
out := domain.UpcomingBooking{
|
||||||
ID: booking.ID,
|
ID: booking.ID,
|
||||||
Reference: booking.Reference,
|
Reference: booking.Reference,
|
||||||
CustomerName: booking.CustomerName,
|
CustomerName: booking.CustomerName,
|
||||||
CustomerEmail: booking.CustomerEmail,
|
CustomerEmail: booking.CustomerEmail,
|
||||||
Notes: booking.Notes,
|
Notes: booking.Notes,
|
||||||
StartsAt: booking.StartsAt,
|
StartsAt: booking.StartsAt,
|
||||||
EndsAt: booking.EndsAt,
|
EndsAt: booking.EndsAt,
|
||||||
Status: booking.Status,
|
Status: booking.Status,
|
||||||
|
ClassSessionID: booking.ClassSessionID,
|
||||||
}
|
}
|
||||||
if booking.ServiceID != nil {
|
if booking.ServiceID != nil {
|
||||||
out.Service = serviceNames[*booking.ServiceID]
|
out.Service = serviceNames[*booking.ServiceID]
|
||||||
@@ -559,6 +562,24 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Waitlist positions are FIFO per session, ordered by booking creation —
|
||||||
|
// computed in memory to avoid one position query per queued booking.
|
||||||
|
waitlistPositions := func(records []db.BookingRecord) map[string]int {
|
||||||
|
positions := map[string]int{}
|
||||||
|
perSession := map[string]int{}
|
||||||
|
sorted := make([]db.BookingRecord, len(records))
|
||||||
|
copy(sorted, records)
|
||||||
|
sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.Before(sorted[j].CreatedAt) })
|
||||||
|
for _, booking := range sorted {
|
||||||
|
if booking.Status != "waitlisted" || booking.ClassSessionID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perSession[*booking.ClassSessionID]++
|
||||||
|
positions[booking.ID] = perSession[*booking.ClassSessionID]
|
||||||
|
}
|
||||||
|
return positions
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
weekEnd := now.AddDate(0, 0, 7)
|
weekEnd := now.AddDate(0, 0, 7)
|
||||||
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
|
metrics, err := s.repo.GetDashboardMetrics(ctx, membership.Tenant.ID, now, weekEnd)
|
||||||
@@ -577,12 +598,15 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DashboardSummary{}, err
|
return domain.DashboardSummary{}, err
|
||||||
}
|
}
|
||||||
|
upcomingPositions := waitlistPositions(upcomingRecords)
|
||||||
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
upcoming := make([]domain.UpcomingBooking, 0, len(upcomingRecords))
|
||||||
for _, booking := range upcomingRecords {
|
for _, booking := range upcomingRecords {
|
||||||
if booking.Status == "cancelled" {
|
if booking.Status == "cancelled" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
upcoming = append(upcoming, toDomain(booking))
|
entry := toDomain(booking)
|
||||||
|
entry.WaitlistPosition = upcomingPositions[booking.ID]
|
||||||
|
upcoming = append(upcoming, entry)
|
||||||
}
|
}
|
||||||
if len(upcoming) > 5 {
|
if len(upcoming) > 5 {
|
||||||
upcoming = upcoming[:5]
|
upcoming = upcoming[:5]
|
||||||
@@ -595,9 +619,12 @@ func (s *Service) DashboardSummary(ctx context.Context, principal domain.Princip
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.DashboardSummary{}, err
|
return domain.DashboardSummary{}, err
|
||||||
}
|
}
|
||||||
|
allPositions := waitlistPositions(allRecords)
|
||||||
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
|
allBookings := make([]domain.UpcomingBooking, 0, len(allRecords))
|
||||||
for _, booking := range allRecords {
|
for _, booking := range allRecords {
|
||||||
allBookings = append(allBookings, toDomain(booking))
|
entry := toDomain(booking)
|
||||||
|
entry.WaitlistPosition = allPositions[booking.ID]
|
||||||
|
allBookings = append(allBookings, entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
brandName := membership.Tenant.Name
|
brandName := membership.Tenant.Name
|
||||||
|
|||||||
@@ -1128,9 +1128,16 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
|||||||
out := make([]domain.ClassSession, len(sessions))
|
out := make([]domain.ClassSession, len(sessions))
|
||||||
for i, session := range sessions {
|
for i, session := range sessions {
|
||||||
booked := 0
|
booked := 0
|
||||||
|
waitlisted := 0
|
||||||
for _, booking := range bookings {
|
for _, booking := range bookings {
|
||||||
if booking.ClassSessionID != nil && *booking.ClassSessionID == session.ID &&
|
if booking.ClassSessionID == nil || *booking.ClassSessionID != session.ID {
|
||||||
booking.Status != "cancelled" && booking.Status != "waitlisted" {
|
continue
|
||||||
|
}
|
||||||
|
switch booking.Status {
|
||||||
|
case "waitlisted":
|
||||||
|
waitlisted++
|
||||||
|
case "cancelled":
|
||||||
|
default:
|
||||||
booked++
|
booked++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1145,11 +1152,73 @@ func (s *Service) ListClassSessions(ctx context.Context, principal domain.Princi
|
|||||||
EndsAt: session.EndsAt,
|
EndsAt: session.EndsAt,
|
||||||
Capacity: int(session.Capacity),
|
Capacity: int(session.Capacity),
|
||||||
BookedCount: booked,
|
BookedCount: booked,
|
||||||
|
WaitlistCount: waitlisted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSessionRoster returns the confirmed attendees and the FIFO waitlist of a
|
||||||
|
// tenant-owned class session for the dashboard roster view.
|
||||||
|
func (s *Service) ListSessionRoster(ctx context.Context, principal domain.Principal, sessionID string) (domain.SessionRoster, error) {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SessionRoster{}, ErrTenantMembership
|
||||||
|
}
|
||||||
|
session, err := s.repo.GetClassSessionByID(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return domain.SessionRoster{}, ErrClassSessionNotFound
|
||||||
|
}
|
||||||
|
return domain.SessionRoster{}, err
|
||||||
|
}
|
||||||
|
if session.TenantID != membership.Tenant.ID {
|
||||||
|
return domain.SessionRoster{}, ErrClassSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionBookings, err := s.repo.ListBookingsBySession(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.SessionRoster{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toEntry := func(booking db.BookingRecord) domain.SessionRosterEntry {
|
||||||
|
return domain.SessionRosterEntry{
|
||||||
|
BookingID: booking.ID,
|
||||||
|
Reference: booking.Reference,
|
||||||
|
CustomerName: booking.CustomerName,
|
||||||
|
CustomerEmail: booking.CustomerEmail,
|
||||||
|
CustomerPhone: booking.CustomerPhone,
|
||||||
|
Status: booking.Status,
|
||||||
|
CreatedAt: booking.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roster := domain.SessionRoster{
|
||||||
|
SessionID: session.ID,
|
||||||
|
TemplateTitle: session.Title,
|
||||||
|
StartsAt: session.StartsAt,
|
||||||
|
EndsAt: session.EndsAt,
|
||||||
|
Capacity: int(session.Capacity),
|
||||||
|
Confirmed: []domain.SessionRosterEntry{},
|
||||||
|
Waitlist: []domain.SessionRosterEntry{},
|
||||||
|
}
|
||||||
|
if session.LocationID != nil {
|
||||||
|
if location, err := s.repo.GetLocationByID(ctx, *session.LocationID); err == nil {
|
||||||
|
roster.LocationName = location.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, booking := range sessionBookings {
|
||||||
|
switch booking.Status {
|
||||||
|
case "confirmed":
|
||||||
|
roster.Confirmed = append(roster.Confirmed, toEntry(booking))
|
||||||
|
case "waitlisted":
|
||||||
|
entry := toEntry(booking)
|
||||||
|
entry.WaitlistPosition = len(roster.Waitlist) + 1
|
||||||
|
roster.Waitlist = append(roster.Waitlist, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roster, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
|
func (s *Service) CreateClassSession(ctx context.Context, principal domain.Principal, req domain.CreateClassSessionRequest) (domain.ClassSession, error) {
|
||||||
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1311,6 +1380,25 @@ func (s *Service) CancelBooking(ctx context.Context, principal domain.Principal,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PromoteBooking confirms one waitlisted class booking on behalf of the
|
||||||
|
// business. Sessions at capacity require the explicit allowOverCapacity flag;
|
||||||
|
// the promoted customer receives the standard confirmation email.
|
||||||
|
func (s *Service) PromoteBooking(ctx context.Context, principal domain.Principal, bookingID string, req domain.PromoteBookingRequest) error {
|
||||||
|
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
|
||||||
|
if err != nil {
|
||||||
|
return ErrTenantMembership
|
||||||
|
}
|
||||||
|
promoted, err := bookings.PromoteBooking(ctx, s.repo, membership.Tenant.ID, bookingID, req.AllowOverCapacity)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrBookingNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go s.sendBookingEmail(promoted, true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// sendBookingEmail builds the shared payload and fires a confirmation
|
// sendBookingEmail builds the shared payload and fires a confirmation
|
||||||
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
// (promoted=true) or cancellation notification. Failures are logged-and-done:
|
||||||
// email delivery must not fail the cancellation itself.
|
// email delivery must not fail the cancellation itself.
|
||||||
|
|||||||
@@ -641,6 +641,123 @@ func TestCancelBookingPromotesWaitlist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListSessionRoster(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
|
||||||
|
}
|
||||||
|
bookClass(t, repo, "[email protected]")
|
||||||
|
bookClass(t, repo, "[email protected]")
|
||||||
|
|
||||||
|
roster, err := svc.ListSessionRoster(ctx, owner, demoSessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("roster: %v", err)
|
||||||
|
}
|
||||||
|
if roster.Capacity != 4 || len(roster.Confirmed) != 4 || len(roster.Waitlist) != 2 {
|
||||||
|
t.Fatalf("unexpected roster %+v", roster)
|
||||||
|
}
|
||||||
|
if roster.Waitlist[0].CustomerEmail != "[email protected]" || roster.Waitlist[0].WaitlistPosition != 1 {
|
||||||
|
t.Fatalf("expected first waiter at position 1, got %+v", roster.Waitlist[0])
|
||||||
|
}
|
||||||
|
if roster.Waitlist[1].WaitlistPosition != 2 {
|
||||||
|
t.Fatalf("expected second waiter at position 2, got %+v", roster.Waitlist[1])
|
||||||
|
}
|
||||||
|
if roster.TemplateTitle != "Small group mobility class" || roster.LocationName != "Main Studio" {
|
||||||
|
t.Fatalf("unexpected roster header %+v", roster)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, _ := svc.ListClassSessions(ctx, owner)
|
||||||
|
if sessions[0].WaitlistCount != 2 {
|
||||||
|
t.Fatalf("expected waitlistCount 2, got %d", sessions[0].WaitlistCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||||
|
if _, err := svc.ListSessionRoster(ctx, stranger, demoSessionID); !errors.Is(err, ErrTenantMembership) {
|
||||||
|
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ListSessionRoster(ctx, owner, "00000000-0000-0000-0000-000000000099"); !errors.Is(err, ErrClassSessionNotFound) {
|
||||||
|
t.Fatalf("expected ErrClassSessionNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteBooking(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
bookClass(t, repo, fmt.Sprintf("seat%[email protected]", i))
|
||||||
|
}
|
||||||
|
first := bookClass(t, repo, "[email protected]")
|
||||||
|
second := bookClass(t, repo, "[email protected]")
|
||||||
|
|
||||||
|
// Full session: strict promote is rejected with ErrSessionFull.
|
||||||
|
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrSessionFull) {
|
||||||
|
t.Fatalf("expected ErrSessionFull, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The explicit override confirms anyway — deliberate out-of-order
|
||||||
|
// overbooking, skipping the first waiter.
|
||||||
|
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{AllowOverCapacity: true}); err != nil {
|
||||||
|
t.Fatalf("over-capacity promote: %v", err)
|
||||||
|
}
|
||||||
|
booking, _ := repo.GetBookingByID(ctx, second.BookingID)
|
||||||
|
if booking.Status != "confirmed" {
|
||||||
|
t.Fatalf("expected confirmed after override, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed bookings, unknown IDs, and foreign principals are rejected.
|
||||||
|
if err := svc.PromoteBooking(ctx, owner, second.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, bookings.ErrNotWaitlisted) {
|
||||||
|
t.Fatalf("expected ErrNotWaitlisted for confirmed booking, got %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.PromoteBooking(ctx, owner, "00000000-0000-0000-0000-000000000099", domain.PromoteBookingRequest{}); !errors.Is(err, ErrBookingNotFound) {
|
||||||
|
t.Fatalf("expected ErrBookingNotFound, got %v", err)
|
||||||
|
}
|
||||||
|
stranger := domain.Principal{Subject: "someone-else", Role: "staff"}
|
||||||
|
if err := svc.PromoteBooking(ctx, stranger, first.BookingID, domain.PromoteBookingRequest{}); !errors.Is(err, ErrTenantMembership) {
|
||||||
|
t.Fatalf("expected ErrTenantMembership, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteBookingWithFreeSeat(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := newCatalogService(repo)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// A waitlisted booking on a session with free capacity — an edge state the
|
||||||
|
// auto-promoter cannot reach, e.g. a queue entry left after manual edits.
|
||||||
|
session, err := repo.GetClassSessionByID(ctx, demoSessionID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get session: %v", err)
|
||||||
|
}
|
||||||
|
created, err := repo.CreateBooking(ctx, db.CreateBookingParams{
|
||||||
|
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||||
|
ClassSessionID: &[]string{demoSessionID}[0],
|
||||||
|
BookingMode: "class",
|
||||||
|
CustomerName: "Queued Customer",
|
||||||
|
CustomerEmail: "[email protected]",
|
||||||
|
StartsAt: session.StartsAt,
|
||||||
|
EndsAt: session.EndsAt,
|
||||||
|
Status: "waitlisted",
|
||||||
|
Reference: "BOK-QUEUED",
|
||||||
|
ManageToken: "queued-token",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed waitlisted booking: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.PromoteBooking(ctx, owner, created.ID, domain.PromoteBookingRequest{}); err != nil {
|
||||||
|
t.Fatalf("promote with free seat: %v", err)
|
||||||
|
}
|
||||||
|
booking, _ := repo.GetBookingByID(ctx, created.ID)
|
||||||
|
if booking.Status != "confirmed" {
|
||||||
|
t.Fatalf("expected confirmed, got %q", booking.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUpdateClassTemplate(t *testing.T) {
|
func TestUpdateClassTemplate(t *testing.T) {
|
||||||
repo := db.NewMemoryRepository()
|
repo := db.NewMemoryRepository()
|
||||||
svc := newCatalogService(repo)
|
svc := newCatalogService(repo)
|
||||||
|
|||||||
@@ -50,6 +50,50 @@ func (r *PGRepository) ListBookingsByTenantBetween(ctx context.Context, tenantID
|
|||||||
return records, rows.Err()
|
return records, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBookingsBySession returns every booking on a class session in
|
||||||
|
// creation order — the ordering that defines the waitlist queue.
|
||||||
|
func (r *PGRepository) ListBookingsBySession(ctx context.Context, sessionID string) ([]BookingRecord, error) {
|
||||||
|
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, ''), created_at
|
||||||
|
FROM bookings
|
||||||
|
WHERE class_session_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var records []BookingRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var record BookingRecord
|
||||||
|
if err := rows.Scan(
|
||||||
|
&record.ID,
|
||||||
|
&record.TenantID,
|
||||||
|
&record.ServiceID,
|
||||||
|
&record.ClassSessionID,
|
||||||
|
&record.StaffID,
|
||||||
|
&record.LocationID,
|
||||||
|
&record.CustomerName,
|
||||||
|
&record.CustomerEmail,
|
||||||
|
&record.CustomerPhone,
|
||||||
|
&record.StartsAt,
|
||||||
|
&record.EndsAt,
|
||||||
|
&record.Status,
|
||||||
|
&record.Reference,
|
||||||
|
&record.ManageToken,
|
||||||
|
&record.Notes,
|
||||||
|
&record.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
return records, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
func (r *PGRepository) CreateBooking(ctx context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
||||||
var created CreatedBooking
|
var created CreatedBooking
|
||||||
err := r.pool.QueryRow(ctx, `
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Repository interface {
|
|||||||
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
UpdateClassTemplate(ctx context.Context, templateID string, params CreateClassTemplateParams) (ClassTemplateRecord, error)
|
||||||
DeleteClassTemplate(ctx context.Context, templateID string) error
|
DeleteClassTemplate(ctx context.Context, templateID string) error
|
||||||
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)
|
||||||
|
ListBookingsBySession(ctx context.Context, sessionID string) ([]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
|
ResolveWaitlistEntry(ctx context.Context, tenantID, sessionID, customerEmail string) error
|
||||||
@@ -1356,6 +1357,17 @@ func (r *MemoryRepository) ListBookingsByTenantBetween(_ context.Context, tenant
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MemoryRepository) ListBookingsBySession(_ context.Context, sessionID string) ([]BookingRecord, error) {
|
||||||
|
var out []BookingRecord
|
||||||
|
for _, booking := range r.bookings {
|
||||||
|
if booking.ClassSessionID != nil && *booking.ClassSessionID == sessionID {
|
||||||
|
out = append(out, booking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
func (r *MemoryRepository) CreateBooking(_ context.Context, params CreateBookingParams) (CreatedBooking, error) {
|
||||||
created := CreatedBooking{
|
created := CreatedBooking{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
|
|||||||
@@ -18,18 +18,20 @@ type DashboardKPI struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpcomingBooking struct {
|
type UpcomingBooking struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
CustomerName string `json:"customerName"`
|
CustomerName string `json:"customerName"`
|
||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
Service string `json:"service,omitempty"`
|
Service string `json:"service,omitempty"`
|
||||||
Location string `json:"location,omitempty"`
|
Location string `json:"location,omitempty"`
|
||||||
StaffName string `json:"staffName,omitempty"`
|
StaffName string `json:"staffName,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Label string `json:"label,omitempty"`
|
Label string `json:"label,omitempty"`
|
||||||
|
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||||
|
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WidgetSnippet struct {
|
type WidgetSnippet struct {
|
||||||
@@ -401,6 +403,41 @@ type ClassSession struct {
|
|||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Capacity int `json:"capacity"`
|
Capacity int `json:"capacity"`
|
||||||
BookedCount int `json:"bookedCount"`
|
BookedCount int `json:"bookedCount"`
|
||||||
|
// WaitlistCount is the number of customers queued for a free seat.
|
||||||
|
WaitlistCount int `json:"waitlistCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionRosterEntry is one booking on a class session roster. WaitlistPosition
|
||||||
|
// is the 1-based queue spot for waitlisted entries; zero for confirmed ones.
|
||||||
|
type SessionRosterEntry struct {
|
||||||
|
BookingID string `json:"bookingId"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
CustomerName string `json:"customerName"`
|
||||||
|
CustomerEmail string `json:"customerEmail"`
|
||||||
|
CustomerPhone string `json:"customerPhone,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
WaitlistPosition int `json:"waitlistPosition,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionRoster is the dashboard attendee view of a class session: confirmed
|
||||||
|
// seats plus the waitlist queue in FIFO order.
|
||||||
|
type SessionRoster struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
TemplateTitle string `json:"templateTitle"`
|
||||||
|
LocationName string `json:"locationName,omitempty"`
|
||||||
|
StartsAt time.Time `json:"startsAt"`
|
||||||
|
EndsAt time.Time `json:"endsAt"`
|
||||||
|
Capacity int `json:"capacity"`
|
||||||
|
Confirmed []SessionRosterEntry `json:"confirmed"`
|
||||||
|
Waitlist []SessionRosterEntry `json:"waitlist"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromoteBookingRequest controls manual waitlist promotion. AllowOverCapacity
|
||||||
|
// lets a business deliberately overbook a session; without it the request fails
|
||||||
|
// when no free seat remains.
|
||||||
|
type PromoteBookingRequest struct {
|
||||||
|
AllowOverCapacity bool `json:"allowOverCapacity,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateClassSessionRequest struct {
|
type CreateClassSessionRequest struct {
|
||||||
|
|||||||
@@ -820,6 +820,34 @@ paths:
|
|||||||
description: Booking already cancelled
|
description: Booking already cancelled
|
||||||
"404":
|
"404":
|
||||||
description: Booking not found
|
description: Booking not found
|
||||||
|
/v1/catalog/bookings/{bookingID}/promote:
|
||||||
|
post:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: promoteDashboardBooking
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: bookingID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PromoteBookingRequest"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Waitlisted booking confirmed; customer notified
|
||||||
|
"400":
|
||||||
|
description: Booking is not on a waitlist
|
||||||
|
"404":
|
||||||
|
description: Booking not found
|
||||||
|
"409":
|
||||||
|
description: Session at capacity; retry with allowOverCapacity to override
|
||||||
/v1/catalog/blocked-days:
|
/v1/catalog/blocked-days:
|
||||||
get:
|
get:
|
||||||
tags: [Catalog]
|
tags: [Catalog]
|
||||||
@@ -1196,6 +1224,28 @@ paths:
|
|||||||
description: Class session deleted
|
description: Class session deleted
|
||||||
"404":
|
"404":
|
||||||
description: Class session not found
|
description: Class session not found
|
||||||
|
/v1/catalog/class-sessions/{sessionID}/roster:
|
||||||
|
get:
|
||||||
|
tags: [Catalog]
|
||||||
|
operationId: getSessionRoster
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: sessionID
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Confirmed attendees and FIFO waitlist for the session
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SessionRoster"
|
||||||
|
"404":
|
||||||
|
description: Class session not found
|
||||||
# ---------------- Billing ----------------
|
# ---------------- Billing ----------------
|
||||||
/v1/billing/subscription:
|
/v1/billing/subscription:
|
||||||
get:
|
get:
|
||||||
@@ -1714,6 +1764,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
notes:
|
notes:
|
||||||
type: string
|
type: string
|
||||||
|
classSessionId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
waitlistPosition:
|
||||||
|
type: integer
|
||||||
|
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||||
DashboardBookingRequest:
|
DashboardBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [customerName, customerEmail, startsAt]
|
required: [customerName, customerEmail, startsAt]
|
||||||
@@ -2126,7 +2182,7 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
ClassSession:
|
ClassSession:
|
||||||
type: object
|
type: object
|
||||||
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount]
|
required: [id, tenantId, templateId, templateTitle, startsAt, endsAt, capacity, bookedCount, waitlistCount]
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -2154,6 +2210,63 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
bookedCount:
|
bookedCount:
|
||||||
type: integer
|
type: integer
|
||||||
|
waitlistCount:
|
||||||
|
type: integer
|
||||||
|
SessionRosterEntry:
|
||||||
|
type: object
|
||||||
|
required: [bookingId, reference, customerName, customerEmail, status, createdAt]
|
||||||
|
properties:
|
||||||
|
bookingId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
reference:
|
||||||
|
type: string
|
||||||
|
customerName:
|
||||||
|
type: string
|
||||||
|
customerEmail:
|
||||||
|
type: string
|
||||||
|
customerPhone:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
waitlistPosition:
|
||||||
|
type: integer
|
||||||
|
createdAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
SessionRoster:
|
||||||
|
type: object
|
||||||
|
required: [sessionId, templateTitle, startsAt, endsAt, capacity, confirmed, waitlist]
|
||||||
|
properties:
|
||||||
|
sessionId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
templateTitle:
|
||||||
|
type: string
|
||||||
|
locationName:
|
||||||
|
type: string
|
||||||
|
startsAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
endsAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
capacity:
|
||||||
|
type: integer
|
||||||
|
confirmed:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/SessionRosterEntry"
|
||||||
|
waitlist:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/SessionRosterEntry"
|
||||||
|
PromoteBookingRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
allowOverCapacity:
|
||||||
|
type: boolean
|
||||||
|
description: Confirm even when the session is full (deliberate overbooking)
|
||||||
CreateClassSessionRequest:
|
CreateClassSessionRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [templateId, startsAt]
|
required: [templateId, startsAt]
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ export const ChevronRightIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ChevronDownIcon = () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m6 9 6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const SparklesIcon = () => (
|
export const SparklesIcon = () => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
|
||||||
|
|||||||
@@ -424,6 +424,23 @@ const dictionaries = {
|
|||||||
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
|
"dashboard.class.noTemplates": "Zatím žádné šablony lekcí.",
|
||||||
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
|
"dashboard.class.noSessions": "Zatím žádné naplánované lekce.",
|
||||||
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
|
"dashboard.class.sessionHint": "Vytvořte šablonu a poté z ní naplánujte konkrétní lekce.",
|
||||||
|
"dashboard.class.roster": "Prezenční listina",
|
||||||
|
"dashboard.class.attendees": "Potvrzení účastníci",
|
||||||
|
"dashboard.class.waitlist": "Čekací listina",
|
||||||
|
"dashboard.class.waiting": "čeká",
|
||||||
|
"dashboard.class.noAttendees": "Zatím žádní účastníci.",
|
||||||
|
"dashboard.class.noWaitlist": "Čekací listina je prázdná.",
|
||||||
|
"dashboard.class.promote": "Potvrdit místo",
|
||||||
|
"dashboard.class.promoteFull": "Lekce je plná — přesto potvrdit?",
|
||||||
|
"dashboard.class.promoteConfirm": "Přesto potvrdit",
|
||||||
|
"dashboard.class.promoted": "Rezervace byla potvrzena.",
|
||||||
|
"dashboard.class.promoteFailed": "Potvrzení rezervace selhalo.",
|
||||||
|
"dashboard.class.queuePosition": "v pořadí",
|
||||||
|
"dashboard.class.showRoster": "Účastníci",
|
||||||
|
"dashboard.class.hideRoster": "Skrýt",
|
||||||
|
"dashboard.waitlistPosition": "Pořadí v čekací listině",
|
||||||
|
"dashboard.booking.promote": "Potvrdit z čekací listiny",
|
||||||
|
"dashboard.booking.promoteFull": "Lekce je plná. Přesto potvrdit rezervaci?",
|
||||||
"dashboard.services": "Služby",
|
"dashboard.services": "Služby",
|
||||||
"dashboard.services.subtitle": "služeb v nabídce",
|
"dashboard.services.subtitle": "služeb v nabídce",
|
||||||
"dashboard.service.add": "Přidat službu",
|
"dashboard.service.add": "Přidat službu",
|
||||||
@@ -1002,6 +1019,23 @@ const dictionaries = {
|
|||||||
"dashboard.class.noTemplates": "No class templates yet.",
|
"dashboard.class.noTemplates": "No class templates yet.",
|
||||||
"dashboard.class.noSessions": "No scheduled sessions yet.",
|
"dashboard.class.noSessions": "No scheduled sessions yet.",
|
||||||
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
|
"dashboard.class.sessionHint": "Create a template, then schedule individual sessions from it.",
|
||||||
|
"dashboard.class.roster": "Session roster",
|
||||||
|
"dashboard.class.attendees": "Confirmed attendees",
|
||||||
|
"dashboard.class.waitlist": "Waitlist",
|
||||||
|
"dashboard.class.waiting": "waiting",
|
||||||
|
"dashboard.class.noAttendees": "No attendees yet.",
|
||||||
|
"dashboard.class.noWaitlist": "The waitlist is empty.",
|
||||||
|
"dashboard.class.promote": "Confirm seat",
|
||||||
|
"dashboard.class.promoteFull": "Session is full — confirm anyway?",
|
||||||
|
"dashboard.class.promoteConfirm": "Confirm anyway",
|
||||||
|
"dashboard.class.promoted": "Booking confirmed.",
|
||||||
|
"dashboard.class.promoteFailed": "Failed to confirm the booking.",
|
||||||
|
"dashboard.class.queuePosition": "in queue",
|
||||||
|
"dashboard.class.showRoster": "Roster",
|
||||||
|
"dashboard.class.hideRoster": "Hide",
|
||||||
|
"dashboard.waitlistPosition": "Waitlist position",
|
||||||
|
"dashboard.booking.promote": "Promote from waitlist",
|
||||||
|
"dashboard.booking.promoteFull": "The session is full. Confirm the booking anyway?",
|
||||||
"dashboard.services": "Services",
|
"dashboard.services": "Services",
|
||||||
"dashboard.services.subtitle": "services offered",
|
"dashboard.services.subtitle": "services offered",
|
||||||
"dashboard.service.add": "Add service",
|
"dashboard.service.add": "Add service",
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ function BookingsPage() {
|
|||||||
: "bg-canvas-subtle text-ink-subtle"
|
: "bg-canvas-subtle text-ink-subtle"
|
||||||
}`}>
|
}`}>
|
||||||
{statusLabels[booking.status]}
|
{statusLabels[booking.status]}
|
||||||
|
{booking.status === "waitlisted" && booking.waitlistPosition ? ` #${booking.waitlistPosition}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
|
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
|
||||||
<EyeIcon />
|
<EyeIcon />
|
||||||
|
|||||||
@@ -48,7 +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: "" },
|
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", waitlistPosition: 1, startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Show, createSignal, type JSX, type ParentComponent } from "solid-js";
|
import { Show, createEffect, createSignal, type JSX, type ParentComponent } from "solid-js";
|
||||||
import { A, useLocation } from "@solidjs/router";
|
import { A, useLocation } from "@solidjs/router";
|
||||||
import { useTheme } from "../../providers/theme-provider";
|
import { useTheme } from "../../providers/theme-provider";
|
||||||
import { BookraCharacter } from "../../components/bookra-character";
|
import { BookraCharacter } from "../../components/bookra-character";
|
||||||
@@ -32,6 +32,15 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
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 [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||||
|
// "armed" means the session reported full and the next click confirms anyway.
|
||||||
|
const [promoteConfirm, setPromoteConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||||
|
|
||||||
|
// Reset two-step confirmations whenever a different booking opens.
|
||||||
|
createEffect(() => {
|
||||||
|
data.selectedBooking()?.id;
|
||||||
|
setCancelConfirm("idle");
|
||||||
|
setPromoteConfirm("idle");
|
||||||
|
});
|
||||||
|
|
||||||
const handleCancelBooking = async () => {
|
const handleCancelBooking = async () => {
|
||||||
if (cancelConfirm() === "busy") return;
|
if (cancelConfirm() === "busy") return;
|
||||||
@@ -57,6 +66,39 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePromoteBooking = async () => {
|
||||||
|
if (promoteConfirm() === "busy") return;
|
||||||
|
const booking = data.selectedBooking();
|
||||||
|
const bearer = data.token();
|
||||||
|
if (!booking || booking.status !== "waitlisted") return;
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) {
|
||||||
|
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
|
||||||
|
setPromoteConfirm("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowOverCapacity = promoteConfirm() === "armed";
|
||||||
|
setPromoteConfirm("busy");
|
||||||
|
try {
|
||||||
|
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/promote`, {
|
||||||
|
headers: { Authorization: `Bearer ${bearer}` },
|
||||||
|
body: { allowOverCapacity },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
if (response.response?.status === 409 && !allowOverCapacity) {
|
||||||
|
setPromoteConfirm("armed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("promote failed");
|
||||||
|
}
|
||||||
|
data.setShowBookingDetail(false);
|
||||||
|
void data.refetchSummary();
|
||||||
|
} catch {
|
||||||
|
data.setDemoNotice(data.i18n.t("dashboard.booking.promoteFailed"));
|
||||||
|
} finally {
|
||||||
|
if (promoteConfirm() === "busy") setPromoteConfirm("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const activeSection = () => {
|
const activeSection = () => {
|
||||||
const p = location.pathname;
|
const p = location.pathname;
|
||||||
const exact = navItems.find(item => p === item.path);
|
const exact = navItems.find(item => p === item.path);
|
||||||
@@ -275,6 +317,12 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
|
{data.i18n.t(`dashboard.${data.selectedBooking()?.status}`)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={data.selectedBooking()?.status === "waitlisted" && data.selectedBooking()?.waitlistPosition}>
|
||||||
|
<div class="flex items-center justify-between p-4 bg-warning-subtle/50 rounded-xl">
|
||||||
|
<p class="text-xs text-ink-muted uppercase tracking-wider">{data.i18n.t("dashboard.waitlistPosition")}</p>
|
||||||
|
<p class="font-semibold text-warning">#{data.selectedBooking()?.waitlistPosition}</p>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<Show when={data.selectedBooking()?.notes}>
|
<Show when={data.selectedBooking()?.notes}>
|
||||||
<div class="p-4 bg-canvas-subtle rounded-xl">
|
<div class="p-4 bg-canvas-subtle rounded-xl">
|
||||||
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
|
<p class="text-xs text-ink-muted uppercase tracking-wider mb-1">{data.i18n.t("dashboard.bookingModal.notes")}</p>
|
||||||
@@ -283,7 +331,24 @@ function DashboardShell(props: { children: JSX.Element }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||||
<div class="p-6 border-t border-border">
|
<div class="p-6 border-t border-border space-y-3">
|
||||||
|
<Show when={data.selectedBooking()?.status === "waitlisted"}>
|
||||||
|
<button
|
||||||
|
onClick={() => void handlePromoteBooking()}
|
||||||
|
disabled={promoteConfirm() === "busy"}
|
||||||
|
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
|
||||||
|
promoteConfirm() === "armed"
|
||||||
|
? "bg-warning text-canvas hover:opacity-90"
|
||||||
|
: "bg-accent text-canvas hover:bg-accent-hover"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{promoteConfirm() === "busy"
|
||||||
|
? "…"
|
||||||
|
: promoteConfirm() === "armed"
|
||||||
|
? data.i18n.t("dashboard.booking.promoteFull")
|
||||||
|
: data.i18n.t("dashboard.booking.promote")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button
|
<button
|
||||||
onClick={() => void handleCancelBooking()}
|
onClick={() => void handleCancelBooking()}
|
||||||
disabled={cancelConfirm() === "busy"}
|
disabled={cancelConfirm() === "busy"}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { For, Show, createResource, createSignal } from "solid-js";
|
|||||||
import { apiClient } from "../../lib/api-client";
|
import { apiClient } from "../../lib/api-client";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import { Select } from "../../components/ui/select";
|
import { Select } from "../../components/ui/select";
|
||||||
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon } from "../../components/dashboard/icons";
|
import { getInitials } from "../../components/dashboard/types";
|
||||||
|
import { MapPinIcon, PlusIcon, BanIcon, ClockIcon, SparklesIcon, Trash2Icon, UsersIcon, PencilIcon, ChevronDownIcon } from "../../components/dashboard/icons";
|
||||||
import { DashboardLayout, useDashboardData } from "./layout";
|
import { DashboardLayout, useDashboardData } from "./layout";
|
||||||
|
|
||||||
function ZonesPage() {
|
function ZonesPage() {
|
||||||
@@ -29,6 +30,11 @@ function ZonesPage() {
|
|||||||
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
const [newSessionLocationId, setNewSessionLocationId] = createSignal("");
|
||||||
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
const [newSessionStartsAt, setNewSessionStartsAt] = createSignal("");
|
||||||
const [sessionSaving, setSessionSaving] = createSignal(false);
|
const [sessionSaving, setSessionSaving] = createSignal(false);
|
||||||
|
const [expandedSessionId, setExpandedSessionId] = createSignal<string | null>(null);
|
||||||
|
const [promoteBusy, setPromoteBusy] = createSignal<string | null>(null);
|
||||||
|
// Booking IDs whose promote attempt hit a full session — awaiting the
|
||||||
|
// explicit "confirm anyway" over-capacity resubmit.
|
||||||
|
const [promoteArmed, setPromoteArmed] = createSignal<Set<string>>(new Set());
|
||||||
|
|
||||||
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
const authHeaders = () => ({ headers: { Authorization: `Bearer ${data.token()}` } });
|
||||||
|
|
||||||
@@ -118,13 +124,74 @@ function ZonesPage() {
|
|||||||
if (!bearer || tab !== "classes") return [];
|
if (!bearer || tab !== "classes") return [];
|
||||||
if (bearer.startsWith("demo.")) {
|
if (bearer.startsWith("demo.")) {
|
||||||
const start = new Date(Date.now() + 3 * 86400000);
|
const start = new Date(Date.now() + 3 * 86400000);
|
||||||
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3 }];
|
return [{ id: "cs1", templateId: "ct1", templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga", locationName: cs() ? "Hlavní sál" : "Main Hall", startsAt: start.toISOString(), endsAt: new Date(start.getTime() + 3600000).toISOString(), capacity: 8, bookedCount: 3, waitlistCount: 2 }];
|
||||||
}
|
}
|
||||||
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
|
const response = await (apiClient as any).GET("/v1/catalog/class-sessions", authHeaders());
|
||||||
return (response.data as any[]) ?? [];
|
return (response.data as any[]) ?? [];
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [roster, { refetch: refetchRoster }] = createResource(
|
||||||
|
() => [data.token(), expandedSessionId()] as const,
|
||||||
|
async ([bearer, sessionId]) => {
|
||||||
|
if (!bearer || !sessionId) return null;
|
||||||
|
if (bearer.startsWith("demo.")) {
|
||||||
|
const start = new Date(Date.now() + 3 * 86400000);
|
||||||
|
const mk = (id: string, name: string, email: string, status: string, pos = 0) => ({
|
||||||
|
bookingId: id, reference: `BOK-${id.slice(-3).toUpperCase()}`, customerName: name, customerEmail: email,
|
||||||
|
status, waitlistPosition: pos, createdAt: start.toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
templateTitle: cs() ? "Jóga pro začátečníky" : "Beginner yoga",
|
||||||
|
locationName: cs() ? "Hlavní sál" : "Main Hall",
|
||||||
|
startsAt: start.toISOString(),
|
||||||
|
endsAt: new Date(start.getTime() + 3600000).toISOString(),
|
||||||
|
capacity: 8,
|
||||||
|
confirmed: [
|
||||||
|
mk("roster-001", cs() ? "Martina Nováková" : "Martina Novakova", "[email protected]", "confirmed"),
|
||||||
|
mk("roster-002", "David Svoboda", "[email protected]", "confirmed"),
|
||||||
|
mk("roster-003", "Alice Johnson", "[email protected]", "confirmed"),
|
||||||
|
],
|
||||||
|
waitlist: [
|
||||||
|
mk("roster-004", cs() ? "Jana Kovářová" : "Jana Kovarova", "[email protected]", "waitlisted", 1),
|
||||||
|
mk("roster-005", "Bob Smith", "[email protected]", "waitlisted", 2),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await (apiClient as any).GET(`/v1/catalog/class-sessions/${sessionId}/roster`, authHeaders());
|
||||||
|
return response.data ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePromote = async (bookingId: string, allowOverCapacity: boolean) => {
|
||||||
|
const bearer = data.token();
|
||||||
|
if (!bearer || bearer.startsWith("demo.")) {
|
||||||
|
data.setDemoNotice(cs() ? "V demo režimu nelze měnit rezervace." : "Cannot modify bookings in demo mode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPromoteBusy(bookingId);
|
||||||
|
try {
|
||||||
|
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${bookingId}/promote`, {
|
||||||
|
...authHeaders(),
|
||||||
|
body: { allowOverCapacity },
|
||||||
|
});
|
||||||
|
if (response.error) {
|
||||||
|
if (response.response?.status === 409 && !allowOverCapacity) {
|
||||||
|
setPromoteArmed((prev) => new Set(prev).add(bookingId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("promote failed");
|
||||||
|
}
|
||||||
|
setPromoteArmed((prev) => { const next = new Set(prev); next.delete(bookingId); return next; });
|
||||||
|
void refetchRoster(); void refetchSessions(); void data.refetchSummary();
|
||||||
|
} catch {
|
||||||
|
data.setDemoNotice(data.i18n.t("dashboard.class.promoteFailed"));
|
||||||
|
} finally {
|
||||||
|
setPromoteBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resolvedLocations = () => locations() ?? [];
|
const resolvedLocations = () => locations() ?? [];
|
||||||
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
const locationLimit = () => data.resolvedBilling()?.entitlements?.maxLocations ?? 3;
|
||||||
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
const canAddLocation = () => locationLimit() < 0 || resolvedLocations().length < locationLimit();
|
||||||
@@ -579,19 +646,121 @@ function ZonesPage() {
|
|||||||
}>
|
}>
|
||||||
<div class="divide-y divide-border/60">
|
<div class="divide-y divide-border/60">
|
||||||
<For each={classSessions() ?? []}>
|
<For each={classSessions() ?? []}>
|
||||||
{(session: any) => (
|
{(session: any) => {
|
||||||
<div class="flex items-center justify-between p-4 px-6">
|
const expanded = () => expandedSessionId() === session.id;
|
||||||
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-ink">{session.templateTitle}</p>
|
<div class="flex items-center justify-between gap-3 p-4 px-6">
|
||||||
<p class="text-sm text-ink-muted">
|
<div class="min-w-0">
|
||||||
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
{session.locationName ? ` • ${session.locationName}` : ""}
|
<p class="font-medium text-ink">{session.templateTitle}</p>
|
||||||
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
|
<Show when={session.waitlistCount > 0}>
|
||||||
</p>
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium bg-warning-subtle text-warning">
|
||||||
|
{session.waitlistCount} {data.i18n.t("dashboard.class.waiting")}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-ink-muted">
|
||||||
|
{new Date(session.startsAt).toLocaleString(cs() ? "cs-CZ" : "en-US", { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
|
||||||
|
{session.locationName ? ` • ${session.locationName}` : ""}
|
||||||
|
{` • ${session.bookedCount}/${session.capacity} ${data.i18n.t("dashboard.class.booked")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedSessionId(expanded() ? null : session.id)}
|
||||||
|
class={`flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${expanded() ? "bg-accent-subtle text-accent" : "text-ink-muted hover:text-ink hover:bg-canvas-subtle"}`}
|
||||||
|
>
|
||||||
|
{expanded() ? data.i18n.t("dashboard.class.hideRoster") : data.i18n.t("dashboard.class.showRoster")}
|
||||||
|
<span class={`inline-flex transition-transform duration-200 ${expanded() ? "rotate-180" : ""}`}><ChevronDownIcon /></span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" aria-label={cs() ? "Smazat" : "Delete"}><Trash2Icon /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<div class="px-6 pb-5 animate-fade-in">
|
||||||
|
<div class="rounded-xl border border-border/60 bg-canvas-subtle/30 overflow-hidden">
|
||||||
|
<Show when={!roster.loading} fallback={
|
||||||
|
<div class="p-8 text-center text-sm text-ink-muted animate-pulse">…</div>
|
||||||
|
}>
|
||||||
|
<div class="grid sm:grid-cols-2 divide-y sm:divide-y-0 sm:divide-x divide-border/60">
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
|
||||||
|
{data.i18n.t("dashboard.class.attendees")} ({(roster()?.confirmed ?? []).length}/{roster()?.capacity ?? session.capacity})
|
||||||
|
</p>
|
||||||
|
<Show when={(roster()?.confirmed ?? []).length > 0} fallback={
|
||||||
|
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noAttendees")}</p>
|
||||||
|
}>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={roster()?.confirmed ?? []}>
|
||||||
|
{(entry: any) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-accent-subtle flex items-center justify-center text-[11px] font-bold text-accent shrink-0">
|
||||||
|
{getInitials(entry.customerName)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
|
||||||
|
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wider text-ink-muted mb-3">
|
||||||
|
{data.i18n.t("dashboard.class.waitlist")} ({(roster()?.waitlist ?? []).length})
|
||||||
|
</p>
|
||||||
|
<Show when={(roster()?.waitlist ?? []).length > 0} fallback={
|
||||||
|
<p class="text-sm text-ink-subtle">{data.i18n.t("dashboard.class.noWaitlist")}</p>
|
||||||
|
}>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={roster()?.waitlist ?? []}>
|
||||||
|
{(entry: any) => (
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-warning-subtle flex items-center justify-center text-[11px] font-bold text-warning shrink-0">
|
||||||
|
#{entry.waitlistPosition}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-ink truncate">{entry.customerName}</p>
|
||||||
|
<p class="text-xs text-ink-muted truncate">{entry.customerEmail} • {entry.reference}</p>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={!promoteArmed().has(entry.bookingId)}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
onClick={() => void handlePromote(entry.bookingId, true)}
|
||||||
|
disabled={promoteBusy() === entry.bookingId}
|
||||||
|
title={data.i18n.t("dashboard.class.promoteFull")}
|
||||||
|
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-warning text-canvas hover:opacity-90 transition-opacity disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promoteConfirm")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => void handlePromote(entry.bookingId, false)}
|
||||||
|
disabled={promoteBusy() === entry.bookingId}
|
||||||
|
class="shrink-0 px-2.5 py-1.5 text-xs font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{promoteBusy() === entry.bookingId ? "…" : data.i18n.t("dashboard.class.promote")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => void handleDeleteSession(session.id)} class="p-2 text-ink-subtle hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2Icon /></button>
|
);
|
||||||
</div>
|
}}
|
||||||
)}
|
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -303,6 +303,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/catalog/bookings/{bookingID}/promote": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["promoteDashboardBooking"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/catalog/class-sessions": {
|
"/v1/catalog/class-sessions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -335,6 +351,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/catalog/class-sessions/{sessionID}/roster": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getSessionRoster"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/catalog/class-templates": {
|
"/v1/catalog/class-templates": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1024,6 +1056,7 @@ export interface components {
|
|||||||
templateTitle: string;
|
templateTitle: string;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
waitlistCount: number;
|
||||||
};
|
};
|
||||||
ClassTemplate: {
|
ClassTemplate: {
|
||||||
capacity: number;
|
capacity: number;
|
||||||
@@ -1196,6 +1229,10 @@ export interface components {
|
|||||||
/** Format: uri */
|
/** Format: uri */
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
PromoteBookingRequest: {
|
||||||
|
/** @description Confirm even when the session is full (deliberate overbooking) */
|
||||||
|
allowOverCapacity?: boolean;
|
||||||
|
};
|
||||||
PublicAvailabilityResponse: {
|
PublicAvailabilityResponse: {
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
locale: "cs" | "en";
|
locale: "cs" | "en";
|
||||||
@@ -1244,6 +1281,31 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
};
|
};
|
||||||
|
SessionRoster: {
|
||||||
|
capacity: number;
|
||||||
|
confirmed: components["schemas"]["SessionRosterEntry"][];
|
||||||
|
/** Format: date-time */
|
||||||
|
endsAt: string;
|
||||||
|
locationName?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
sessionId: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
startsAt: string;
|
||||||
|
templateTitle: string;
|
||||||
|
waitlist: components["schemas"]["SessionRosterEntry"][];
|
||||||
|
};
|
||||||
|
SessionRosterEntry: {
|
||||||
|
/** Format: uuid */
|
||||||
|
bookingId: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerName: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
waitlistPosition?: number;
|
||||||
|
};
|
||||||
StaffMember: {
|
StaffMember: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1334,6 +1396,8 @@ export interface components {
|
|||||||
siteId?: string;
|
siteId?: string;
|
||||||
};
|
};
|
||||||
UpcomingBooking: {
|
UpcomingBooking: {
|
||||||
|
/** Format: uuid */
|
||||||
|
classSessionId?: string;
|
||||||
customerEmail?: string;
|
customerEmail?: string;
|
||||||
customerName?: string;
|
customerName?: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1348,6 +1412,8 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startsAt?: string;
|
startsAt?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
|
/** @description 1-based queue spot for waitlisted class bookings; absent otherwise */
|
||||||
|
waitlistPosition?: number;
|
||||||
};
|
};
|
||||||
UpdateBrandRequest: {
|
UpdateBrandRequest: {
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
@@ -2084,6 +2150,51 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
promoteDashboardBooking: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
bookingID: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PromoteBookingRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Waitlisted booking confirmed; customer notified */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Booking is not on a waitlist */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Booking not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Session at capacity; retry with allowOverCapacity to override */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listClassSessions: {
|
listClassSessions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2169,6 +2280,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getSessionRoster: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
sessionID: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Confirmed attendees and FIFO waitlist for the session */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SessionRoster"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Class session not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
listClassTemplates: {
|
listClassTemplates: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
+27
-4
@@ -81,7 +81,8 @@
|
|||||||
- shared `bookings.PromoteWaitlist` helper drives both cancel paths; waitlist ledger entries are marked resolved on promotion
|
- 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
|
- 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
|
- `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
|
- waitlist position display landed in Milestone 16; manual promotion controls landed in Milestone 20
|
||||||
|
- remaining: live staging validation
|
||||||
- `[done/partial]` Milestone 12: Real dashboard business metrics
|
- `[done/partial]` Milestone 12: Real dashboard business metrics
|
||||||
- `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories
|
- `DashboardMetrics` gained `CompletedCount` (confirmed bookings already ended within the window) in both pgx and in-memory repositories
|
||||||
- `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points)
|
- `DashboardSummary` now queries three trailing windows (next 7d, last 7d, 7-14d ago) and emits four KPIs with real trend deltas: `bookings_this_week`, `completed`, `cancellations`, `utilization` (delta in percentage points)
|
||||||
@@ -111,7 +112,8 @@
|
|||||||
- `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1
|
- `GetWaitlistPosition` repository method (pgx + memory) returns the 1-based queue spot: count of earlier-created waitlisted bookings on the same session + 1
|
||||||
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
|
- `CustomerBookingView` carries `waitlistPosition` for waitlisted class bookings; the manage page now shows a proper status badge for `waitlisted`/`cancelled` (previously both rendered as "Pending") plus a notice with the queue position and the auto-promotion explanation, in CS and EN
|
||||||
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
|
- fixed invalid token names along the way (`--danger-soft`/`--warning-soft` do not exist; `--error-subtle`/`--warning-subtle` do)
|
||||||
- remaining: live staging validation; manual waitlist promotion controls are still not exposed
|
- manual promotion controls landed in Milestone 20
|
||||||
|
- remaining: live staging validation
|
||||||
- `[done/partial]` Milestone 17: Class template editing
|
- `[done/partial]` Milestone 17: Class template editing
|
||||||
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
|
- `PUT /v1/catalog/class-templates/{id}` end-to-end: `UpdateClassTemplate` in both repositories, catalog service method sharing `validateClassTemplateInput` with create, route, OpenAPI path, regenerated client
|
||||||
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
|
- classes tab template rows gained a pencil edit action that loads the template into the form; the submit button switches to save-changes with a cancel affordance
|
||||||
@@ -127,6 +129,15 @@
|
|||||||
- validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean
|
- validated end-to-end against a local `postgres:17` container: SQL-level matrix (staff/location/unassigned overlap blocked, abutting allowed, cancel frees the slot), repo-level 23P01 detection, and a 10-request concurrent burst on one slot producing exactly one 201 + 409s for the rest; down/up migration roundtrip clean
|
||||||
- local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it
|
- local test container: `docker run -d --name bookra-pg-local -p 5439:5432 -e POSTGRES_PASSWORD=bookra-local postgres:17-alpine`, migrations via `npm run db:migrate:up` with `BOOKRA_DATABASE_DIRECT_URL` pointed at it
|
||||||
- remaining: live staging validation on Neon
|
- remaining: live staging validation on Neon
|
||||||
|
- `[done/partial]` Milestone 20: Class roster + manual waitlist promotion
|
||||||
|
- `GET /v1/catalog/class-sessions/{id}/roster` returns session metadata plus `confirmed` and `waitlist` entry lists (booking id, reference, customer name/email, status, `waitlistPosition`, `createdAt`); `ClassSession` now carries `waitlistCount` so session rows can badge queue length without an extra request
|
||||||
|
- `POST /v1/catalog/bookings/{id}/promote` manually confirms a waitlisted booking: `409` when the session is full unless the caller passes `allowOverCapacity`, `400` for non-waitlisted bookings, `404` for missing or foreign bookings; promotion reuses the shared confirm path so waitlist ledger rows resolve and reminders re-arm automatically
|
||||||
|
- `ListBookingsBySession` repository method (pgx + memory) backs the roster and simplifies `PromoteWaitlist`; `UpcomingBooking` carries `classSessionId` + `waitlistPosition`, computed per session in the dashboard summary
|
||||||
|
- classes tab session rows expand into a two-column roster panel (attendees vs waitlist with `#N` position badges) with per-entry promote buttons; a full session arms an explicit "confirm anyway" over-capacity step instead of silently overriding
|
||||||
|
- the booking detail modal shows the waitlist position and offers the same promote action for waitlisted bookings; the bookings list badges waitlisted rows with `#N`
|
||||||
|
- demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing
|
||||||
|
- verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
|
||||||
## 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.
|
||||||
@@ -361,7 +372,7 @@ Key entities:
|
|||||||
## Current Gaps
|
## Current Gaps
|
||||||
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
|
- Neon Auth token extraction is wired structurally, but still needs live browser validation against a real Neon Auth environment.
|
||||||
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
|
- Tenant membership bootstrap now syncs identities structurally, but staging still needs seeded membership records and onboarding flows for first-time tenants.
|
||||||
- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, and customers; class/session template management and per-staff availability rules still have no management UI.
|
- Post-onboarding CRUD now covers services, staff, locations, blocked days, working hours, customers, and class templates/sessions with rosters; per-staff availability rules still have no management UI.
|
||||||
- Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
|
- Stripe lifecycle sync is implemented; still needs live environment validation against real products, price IDs, and webhook signatures.
|
||||||
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
|
- Real email/SMS provider integrations are not implemented yet; current delivery providers are safe noops.
|
||||||
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
|
- The internal reminder dispatch route exists, but Railway scheduling and secret rotation still need production setup.
|
||||||
@@ -371,7 +382,19 @@ Key entities:
|
|||||||
- sync Neon Auth identities into tenant membership records and onboarding bootstrap
|
- sync Neon Auth identities into tenant membership records and onboarding bootstrap
|
||||||
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
|
- validate Stripe checkout/webhooks/portal and reminder dispatch against live staging infrastructure
|
||||||
- replace noop notification providers with the first production email transport and a gated SMS transport
|
- replace noop notification providers with the first production email transport and a gated SMS transport
|
||||||
- add management UI for class/session templates and per-staff availability rules so service duration, capacity, and staff schedules are editable after onboarding
|
- add management UI for per-staff availability rules so staff schedules are editable after onboarding
|
||||||
|
|
||||||
|
## Future Phases
|
||||||
|
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
||||||
|
|
||||||
|
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
|
||||||
|
- **Customer self-service rebooking**: let customers move to a different free slot from the manage page instead of cancel-and-rebook; the reschedule path already validates targets, so this is mostly UI plus a picker.
|
||||||
|
- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed.
|
||||||
|
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
||||||
|
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
||||||
|
- **Persistent notification center**: the dropdown currently derives notifications from summary data; a `notifications` table would enable read/unread state and delivery history in-app.
|
||||||
|
- **Staff performance view**: bookings/completion/utilization per staff member — the metrics primitives already exist; this is a filtered presentation layer.
|
||||||
|
- **CSV export**: bookings and customers export for accounting/backup; cheap to build, frequently requested by small businesses.
|
||||||
|
|
||||||
## Product Principles
|
## Product Principles
|
||||||
- calm, premium, structured UX
|
- calm, premium, structured UX
|
||||||
|
|||||||
Reference in new issue
Block a user