From 3a8ea4c98fd698ca96d94d0adba18dcef2e8936c Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Mon, 19 Jan 2026 22:32:35 +0000 Subject: [PATCH] added booking approvals --- backend/handlers/today/today.go | 464 +++ .../lib/components/admin/ApprovalModal.svelte | 443 +++ .../today/CurrentAppointment.svelte | 407 ++ .../components/today/PendingApprovals.svelte | 248 ++ .../lib/components/today/TodayCalendar.svelte | 212 + .../src/lib/components/ui/badge/badge.svelte | 50 + frontend/src/lib/components/ui/badge/index.ts | 2 + frontend/src/routes/admin/+page._svelte | 3404 +++++++++++++++++ frontend/src/routes/demo/+page.svelte | 410 ++ frontend/src/routes/today/+page.svelte | 153 + 10 files changed, 5793 insertions(+) create mode 100644 backend/handlers/today/today.go create mode 100644 frontend/src/lib/components/admin/ApprovalModal.svelte create mode 100644 frontend/src/lib/components/today/CurrentAppointment.svelte create mode 100644 frontend/src/lib/components/today/PendingApprovals.svelte create mode 100644 frontend/src/lib/components/today/TodayCalendar.svelte create mode 100644 frontend/src/lib/components/ui/badge/badge.svelte create mode 100644 frontend/src/lib/components/ui/badge/index.ts create mode 100644 frontend/src/routes/admin/+page._svelte create mode 100644 frontend/src/routes/demo/+page.svelte create mode 100644 frontend/src/routes/today/+page.svelte diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go new file mode 100644 index 0000000..46b70d2 --- /dev/null +++ b/backend/handlers/today/today.go @@ -0,0 +1,464 @@ +package today + +import ( + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "crussell/db" +) + +type ServiceInfo struct { + ServiceName *string `json:"service_name,omitempty"` + ServiceDescription *string `json:"service_description,omitempty"` + Price *float64 `json:"price,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` +} + +type UserInfo struct { + ID string `json:"id"` + FullName string `json:"full_name"` + Phone *string `json:"phone,omitempty"` + Email *string `json:"email,omitempty"` + ProfilePicURL *string `json:"profile_pic_url,omitempty"` +} + +type AppointmentInfo struct { + ID string `json:"id"` + StartTime time.Time `json:"start_time"` + Status string `json:"status"` + Notes *string `json:"notes,omitempty"` + User *UserInfo `json:"user,omitempty"` + Services []ServiceInfo `json:"services"` + DurationMinutes int `json:"duration_minutes"` + TotalAmount float64 `json:"total_amount"` +} + +type CurrentNextResponse struct { + Current *AppointmentInfo `json:"current"` + Next *AppointmentInfo `json:"next"` +} + +// GET /api/admin/today/current-next +func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) { + now := time.Now() + + var current *AppointmentInfo + var next *AppointmentInfo + + // Step 1: Try to find current in-progress appointment + currentBooking, err := fetchAppointment(r, ` + SELECT b.id, b.start_time, b.status, b.notes, b.user_id + FROM bookings b + WHERE b.status = 'in_progress' + ORDER BY b.start_time ASC + LIMIT 1 + `) + + if err != nil && err != sql.ErrNoRows { + log.Printf("Error querying current appointment: %v", err) + // Don't fail, continue to find next + } + + if err == nil && currentBooking != nil { + current = currentBooking + + // Also fetch the appointment AFTER this one for free time calculation + nextBooking, err := fetchAppointment(r, ` + SELECT b.id, b.start_time, b.status, b.notes, b.user_id + FROM bookings b + WHERE b.start_time > $1 + AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress') + ORDER BY b.start_time ASC + LIMIT 1 + `, currentBooking.StartTime) + + if err == nil && nextBooking != nil { + next = nextBooking + } + } else { + // No current in-progress appointment, find the next upcoming one + nextBooking, err := fetchAppointment(r, ` + SELECT b.id, b.start_time, b.status, b.notes, b.user_id + FROM bookings b + WHERE b.start_time >= $1 + AND (b.status = 'confirmed' OR b.status = 'pending') + ORDER BY b.start_time ASC + LIMIT 1 + `, now) + + if err != nil && err != sql.ErrNoRows { + log.Printf("Error querying next appointment: %v", err) + } + + if err == nil && nextBooking != nil { + current = nextBooking + + // Fetch the one after for free time calculation + afterNext, err := fetchAppointment(r, ` + SELECT b.id, b.start_time, b.status, b.notes, b.user_id + FROM bookings b + WHERE b.start_time > $1 + AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress') + ORDER BY b.start_time ASC + LIMIT 1 + `, nextBooking.StartTime) + + if err == nil && afterNext != nil { + next = afterNext + } + } + } + + response := CurrentNextResponse{ + Current: current, + Next: next, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode current/next response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +// Helper function to fetch a single appointment with all details +func fetchAppointment(r *http.Request, query string, args ...interface{}) (*AppointmentInfo, error) { + var bookingID string + var startTime time.Time + var status string + var notes sql.NullString + var userID string + + err := db.DB.QueryRow(r.Context(), query, args...).Scan( + &bookingID, &startTime, &status, ¬es, &userID, + ) + if err != nil { + return nil, err + } + + appointment := &AppointmentInfo{ + ID: bookingID, + StartTime: startTime, + Status: status, + } + + if notes.Valid { + appointment.Notes = ¬es.String + } + + // Fetch user info + var user UserInfo + var phone sql.NullString + var email sql.NullString + var profilePicURL sql.NullString + + err = db.DB.QueryRow(r.Context(), ` + SELECT id, fn, phone, email, profile_pic_url + FROM users + WHERE id = $1 + `, userID).Scan(&user.ID, &user.FullName, &phone, &email, &profilePicURL) + + if err == nil { + if phone.Valid { + user.Phone = &phone.String + } + if profilePicURL.Valid { + user.ProfilePicURL = &profilePicURL.String + } + if email.Valid { + user.Email = &email.String + } + appointment.User = &user + } else { + log.Printf("Failed to fetch user %s: %v", userID, err) + } + + // Fetch services + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + s.name, + s.description, + COALESCE(bs.override_price, s.price) as price, + COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, bookingID) + + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", bookingID, err) + return appointment, nil + } + defer serviceRows.Close() + + var totalAmount float64 + var totalDuration int + + for serviceRows.Next() { + var service ServiceInfo + var name string + var description sql.NullString + var price float64 + var duration int + + if err := serviceRows.Scan(&name, &description, &price, &duration); err != nil { + log.Printf("Failed to scan service: %v", err) + continue + } + + service.ServiceName = &name + if description.Valid { + service.ServiceDescription = &description.String + } + service.Price = &price + service.DurationMinutes = &duration + + totalAmount += price + totalDuration += duration + + appointment.Services = append(appointment.Services, service) + } + + appointment.TotalAmount = totalAmount + appointment.DurationMinutes = totalDuration + + return appointment, nil +} + +type TodayAppointment struct { + ID string `json:"id"` + StartTime string `json:"start_time"` + Status string `json:"status"` + UserName string `json:"user_name"` + UserID string `json:"user_id"` + Services []string `json:"services"` + DurationMinutes int `json:"duration_minutes"` +} + +type TodayAppointmentsResponse struct { + Appointments []TodayAppointment `json:"appointments"` +} + +// GET /api/admin/today/appointments +func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) { + now := time.Now() + todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + todayEnd := todayStart.Add(24 * time.Hour) + + // Fetch all bookings for today + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + u.fn as user_name, + u.id as user_id + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.start_time >= $1 + AND b.start_time < $2 + ORDER BY b.start_time ASC + `, todayStart, todayEnd) + + if err != nil { + log.Printf("Failed to fetch today's appointments: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var appointments []TodayAppointment + + for rows.Next() { + var apt TodayAppointment + var startTime time.Time + + err := rows.Scan( + &apt.ID, + &startTime, + &apt.Status, + &apt.UserName, + &apt.UserID, + ) + if err != nil { + log.Printf("Failed to scan appointment row: %v", err) + continue + } + + apt.StartTime = startTime.Format(time.RFC3339) + + // Fetch services for this booking + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + s.name, + COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, apt.ID) + + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err) + continue + } + + var services []string + var totalDuration int + + for serviceRows.Next() { + var serviceName string + var duration int + + if err := serviceRows.Scan(&serviceName, &duration); err != nil { + log.Printf("Failed to scan service: %v", err) + continue + } + + services = append(services, serviceName) + totalDuration += duration + } + serviceRows.Close() + + apt.Services = services + apt.DurationMinutes = totalDuration + + appointments = append(appointments, apt) + } + + if appointments == nil { + appointments = []TodayAppointment{} + } + + response := TodayAppointmentsResponse{ + Appointments: appointments, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode today's appointments response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +type PendingApproval struct { + ID string `json:"id"` + StartTime string `json:"start_time"` + UserID string `json:"user_id"` + UserName string `json:"user_name"` + Services []string `json:"services"` + DurationMinutes int `json:"duration_minutes"` + CreatedAt string `json:"created_at"` +} + +type PendingApprovalsResponse struct { + Approvals []PendingApproval `json:"approvals"` +} + +// GET /api/admin/today/pending-approvals +func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) { + rows, err := db.DB.Query(r.Context(), ` + SELECT + b.id, + b.start_time, + b.created_at, + u.id as user_id, + u.fn as user_name + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.status = 'pending' + ORDER BY b.created_at ASC + `) + + if err != nil { + log.Printf("Failed to fetch pending approvals: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var approvals []PendingApproval + + for rows.Next() { + var apt PendingApproval + var startTime time.Time + var createdAt time.Time + + err := rows.Scan( + &apt.ID, + &startTime, + &createdAt, + &apt.UserID, + &apt.UserName, + ) + if err != nil { + log.Printf("Failed to scan pending approval row: %v", err) + continue + } + + apt.StartTime = startTime.Format(time.RFC3339) + apt.CreatedAt = createdAt.Format(time.RFC3339) + + // Fetch services + serviceRows, err := db.DB.Query(r.Context(), ` + SELECT + s.name, + COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes + FROM booking_services bs + LEFT JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 + ORDER BY s.name + `, apt.ID) + + if err != nil { + log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err) + continue + } + + var services []string + var totalDuration int + + for serviceRows.Next() { + var name string + var duration int + + if err := serviceRows.Scan(&name, &duration); err != nil { + log.Printf("Failed to scan service: %v", err) + continue + } + + services = append(services, name) + totalDuration += duration + } + serviceRows.Close() + + apt.Services = services + apt.DurationMinutes = totalDuration + + approvals = append(approvals, apt) + } + + if approvals == nil { + approvals = []PendingApproval{} + } + + response := PendingApprovalsResponse{ + Approvals: approvals, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Failed to encode pending approvals response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte new file mode 100644 index 0000000..0ed1d6e --- /dev/null +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -0,0 +1,443 @@ + + + + + + Approve Booking + + Review and confirm this booking. Adjust pricing or duration if needed. + + + +
+ +
+

+ Customer Contact +

+
+
+
Name
+
{booking.user?.full_name || '—'}
+
+
+
+
Phone
+
{booking.user?.phone || '—'}
+
+
+
Email
+
{booking.user?.email || '—'}
+
+
+
+
+ + +
+ +