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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-2
@@ -133,8 +133,8 @@ func main() {
|
||||
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
|
||||
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
||||
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) // todo
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler) // HERE
|
||||
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
|
||||
})
|
||||
|
||||
r.Route("/admin/users", func(r chi.Router) {
|
||||
|
||||
Reference in New Issue
Block a user