feat(bookings): improve admin booking wizard and user dashboard

Backend:
- Enriched GetAllUserBookings response with calculated total_amount,
  amount_paid, and duration_minutes.
- Refactored GetBookingHandler to return a flat booking object matching
  frontend expectations.
- Added account_role to admin user list response and sorted users by
  booking activity.
- Corrected function name oo to AdminCreateBookingForUserHandler.

Frontend:
- Rebuilt BookingCreateModal into a 4-step wizard supporting guest
  bookings, service overrides, and real-time availability checks.
- Fixed account dashboard logic to correctly identify upcoming vs past
  bookings and sort unpaid items to the top.
- Extracted booking flow into a shared BookingFlow component.
- Redirected admin users from home page to /today.
This commit is contained in:
2026-02-12 22:15:10 +00:00
parent 2ace6d4d87
commit 50746595e7
34 changed files with 7688 additions and 5413 deletions
+75 -31
View File
@@ -257,11 +257,33 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
req := parseGetAllBookingsRequest(r)
// Build base query with user filter
// We now calculate Total Amount, Amount Paid, AND Duration
baseQuery := `
SELECT id, start_time, status, notes, created_at, updated_at, created_by
FROM bookings
WHERE user_id = $1
`
SELECT
id, start_time, status, notes, created_at, updated_at, created_by,
-- Total Amount
(SELECT COALESCE(SUM(CASE
WHEN bs.override_price IS NOT NULL THEN bs.override_price
ELSE s.price
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id) as total_amount,
-- Amount Paid
(SELECT COALESCE(SUM(amount), 0)
FROM payments
WHERE booking_id = bookings.id AND status = 'completed') as amount_paid,
-- Duration Minutes
(SELECT COALESCE(SUM(CASE
WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes
ELSE s.duration_minutes
END), 0)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id) as duration_minutes
FROM bookings
WHERE user_id = $1
`
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args []interface{}
@@ -338,7 +360,16 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
for rows.Next() {
var b Booking
var createdBy sql.NullString
err := rows.Scan(&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy)
// Scan duration_minutes as well
var totalAmount, amountPaid float64
var durationMinutes int
err := rows.Scan(
&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy,
&totalAmount,
&amountPaid,
&durationMinutes,
)
if err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -347,6 +378,12 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
if createdBy.Valid {
b.CreatedBy = &createdBy.String
}
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
b.AmountDue = totalAmount - amountPaid
b.DurationMinutes = durationMinutes // Populate the struct
bookings = append(bookings, b)
}
@@ -1648,14 +1685,17 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
// 1. Fetch booking
// ----------------------------
var booking Booking
// Initialize slices/maps to avoid null in JSON
booking.Payments = []Payment{}
booking.Services = []BookingService{}
booking.User = &UserSummary{}
var createdBy sql.NullString
err := db.DB.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
)
@@ -1713,21 +1753,29 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Calculate Totals based on overrides or base values
var priceToAdd float64
var durationToAdd int
if overridePrice.Valid {
s.OverridePrice = &overridePrice.Float64
totalAmount += *s.OverridePrice
priceToAdd = overridePrice.Float64
} else if basePrice.Valid {
totalAmount += basePrice.Float64
priceToAdd = basePrice.Float64
}
if overrideDuration.Valid {
d := int(overrideDuration.Int32)
s.OverrideDurationMinutes = &d
durationMinutes += *s.OverrideDurationMinutes
durationToAdd = d
} else if baseDuration.Valid {
durationMinutes += int(baseDuration.Int32)
durationToAdd = int(baseDuration.Int32)
}
totalAmount += priceToAdd
durationMinutes += durationToAdd
// Map nullable strings to pointers
if name.Valid {
s.ServiceName = &name.String
}
@@ -1810,27 +1858,23 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.Payments = append(booking.Payments, p)
}
amountDue := totalAmount - amountPaid
// Create enhanced response with user-friendly totals
enhancedResponse := struct {
Booking Booking `json:"booking"`
TotalAmount float64 `json:"total_amount"`
AmountPaid float64 `json:"amount_paid"`
AmountDue float64 `json:"amount_due"`
DurationMinutes int `json:"duration_minutes"`
}{
Booking: booking,
TotalAmount: totalAmount,
AmountPaid: amountPaid,
AmountDue: amountDue,
DurationMinutes: durationMinutes,
}
// ----------------------------
// 4. Assign calculated totals to Booking Struct
// ----------------------------
booking.TotalAmount = totalAmount
booking.AmountPaid = amountPaid
booking.AmountDue = totalAmount - amountPaid
booking.DurationMinutes = durationMinutes
// ----------------------------
// 5. Return Response
// ----------------------------
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(enhancedResponse); err != nil {
// We encode the 'booking' object directly.
// This matches the frontend expectation: selectedBooking = data;
if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode booking response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
+2 -2
View File
@@ -173,10 +173,10 @@ type AdminCreateBookingForUserRequest struct {
StartTime time.Time `json:"start_time" validate:"required"`
ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
Notes *string `json:"notes,omitempty"` // staff notes
Notes *string `json:"notes,omitempty"` // appointment notes, visible to customers and staff
}
func oo(w http.ResponseWriter, r *http.Request) {
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Admin identity (creator)
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || adminID == "" {
+36 -25
View File
@@ -78,10 +78,11 @@ type SocialLogin struct {
}
type UserListItem struct {
ID string `json:"id"`
FullName string `json:"fullName"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
ID string `json:"id"`
FullName string `json:"fullName"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
AccountRole string `json:"account_role"`
}
type UserListResponse struct {
@@ -386,35 +387,39 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
searchPattern := "%" + searchTerm + "%"
countQuery = `
SELECT COUNT(*)
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
`
SELECT COUNT(*)
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
`
countArgs = []interface{}{searchPattern}
listQuery = `
SELECT id, fn, email, phone
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
SELECT u.id, u.fn, u.email, u.phone, u.account_role
FROM users u
LEFT JOIN bookings b ON u.id = b.user_id
WHERE u.fn ILIKE $1
OR u.email ILIKE $1
OR u.phone ILIKE $1
GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at
ORDER BY COUNT(b.id) DESC, u.created_at DESC
LIMIT $2 OFFSET $3
`
listArgs = []interface{}{searchPattern, perPage, offset}
} else {
// No search - get all users
// No search - get all users, sorted by booking count
countQuery = `SELECT COUNT(*) FROM users`
countArgs = []interface{}{}
listQuery = `
SELECT id, fn, email, phone
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
SELECT u.id, u.fn, u.email, u.phone, u.account_role
FROM users u
LEFT JOIN bookings b ON u.id = b.user_id
GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at
ORDER BY COUNT(b.id) DESC, u.created_at DESC
LIMIT $1 OFFSET $2
`
listArgs = []interface{}{perPage, offset}
}
@@ -439,7 +444,13 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
var users []UserListItem
for rows.Next() {
var user UserListItem
err := rows.Scan(&user.ID, &user.FullName, &user.Email, &user.Phone)
err := rows.Scan(
&user.ID,
&user.FullName,
&user.Email,
&user.Phone,
&user.AccountRole,
)
if err != nil {
log.Printf("Failed to scan user row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)