Introduce DoneForDay state and DailySummary struct in current-next endpoint. Adds computeAggregateSummary for daily/weekly revenue, tips, duration, customer stats, and new booking services. Adds isDayOpen, findWeekSummaryRange, and getClosingTime with exceptional hours lookup. Scopes all current/next queries to today's date range. Ultraworked with Sisyphus Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1039 lines
31 KiB
Go
1039 lines
31 KiB
Go
package today
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"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" validate:"omitempty,max=1000000"`
|
|
User *UserInfo `json:"user,omitempty"`
|
|
Services []ServiceInfo `json:"services"`
|
|
DurationMinutes int `json:"duration_minutes"`
|
|
TotalAmount float64 `json:"total_amount"`
|
|
}
|
|
|
|
type ServiceBookingCount struct {
|
|
ServiceName string `json:"service_name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type DailySummary struct {
|
|
TotalPaymentsToday float64 `json:"total_payments_today"`
|
|
TotalTipsToday float64 `json:"total_tips_today"`
|
|
AmountDueToday float64 `json:"amount_due_today"`
|
|
TotalDurationSpent int `json:"total_duration_spent"`
|
|
CustomersServed int `json:"customers_served"`
|
|
TotalBookings int `json:"total_bookings"`
|
|
NewCustomers int `json:"new_customers"`
|
|
ReturningCustomers int `json:"returning_customers"`
|
|
GuestCustomers int `json:"guest_customers"`
|
|
GiftCardsSold int `json:"gift_cards_sold"`
|
|
LastCustomerName string `json:"last_customer_name,omitempty"`
|
|
LastCustomerVisits int `json:"last_customer_visits,omitempty"`
|
|
NewBookingServices []ServiceBookingCount `json:"new_booking_services,omitempty"`
|
|
SummaryScope string `json:"summary_scope"`
|
|
SummaryStartDate string `json:"summary_start_date"`
|
|
SummaryEndDate string `json:"summary_end_date"`
|
|
}
|
|
|
|
type CurrentNextResponse struct {
|
|
Current *AppointmentInfo `json:"current"`
|
|
Next *AppointmentInfo `json:"next"`
|
|
ClosingTime *string `json:"closing_time,omitempty"`
|
|
DoneForDay *bool `json:"done_for_day,omitempty"`
|
|
Summary *DailySummary `json:"summary,omitempty"`
|
|
WeekSummary *DailySummary `json:"week_summary,omitempty"`
|
|
}
|
|
|
|
// GET /api/admin/today/current-next
|
|
func GetCurrentAndNextHandler(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)
|
|
|
|
// Auto-transition confirmed bookings that have started but not ended to in_progress
|
|
_, err := db.DB.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'in_progress'
|
|
WHERE status = 'confirmed'
|
|
AND start_time <= $1
|
|
AND (
|
|
start_time + (
|
|
COALESCE(
|
|
(SELECT SUM(dur) FROM (
|
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id
|
|
UNION ALL
|
|
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = bookings.id
|
|
) sub),
|
|
0
|
|
) || ' minutes'
|
|
)::interval
|
|
) > $1
|
|
`, now)
|
|
if err != nil {
|
|
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
|
|
}
|
|
|
|
// Auto-transition in_progress bookings that have ended to completed
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'completed'
|
|
WHERE status = 'in_progress'
|
|
AND (
|
|
start_time + (
|
|
COALESCE(
|
|
(SELECT SUM(dur) FROM (
|
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id
|
|
UNION ALL
|
|
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = bookings.id
|
|
) sub),
|
|
0
|
|
) || ' minutes'
|
|
)::interval
|
|
) <= $1
|
|
`, now)
|
|
if err != nil {
|
|
log.Printf("Failed to auto-transition bookings to completed: %v", err)
|
|
}
|
|
|
|
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'
|
|
AND b.start_time >= $1
|
|
AND b.start_time < $2
|
|
ORDER BY b.start_time ASC
|
|
LIMIT 1
|
|
`, todayStart, todayEnd)
|
|
|
|
if err != nil && err != sql.ErrNoRows {
|
|
log.Printf("Error querying current appointment: %v", err)
|
|
}
|
|
|
|
if err == nil && currentBooking != nil {
|
|
current = currentBooking
|
|
|
|
// Also fetch the appointment AFTER this one within today
|
|
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.start_time < $2
|
|
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
|
|
ORDER BY b.start_time ASC
|
|
LIMIT 1
|
|
`, currentBooking.StartTime, todayEnd)
|
|
|
|
if err == nil && nextBooking != nil {
|
|
next = nextBooking
|
|
}
|
|
} else {
|
|
// No current in-progress appointment, find the next upcoming one within today
|
|
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.start_time < $2
|
|
AND (b.status = 'confirmed' OR b.status = 'pending')
|
|
ORDER BY b.start_time ASC
|
|
LIMIT 1
|
|
`, now, todayEnd)
|
|
|
|
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.start_time < $2
|
|
AND (b.status = 'confirmed' OR b.status = 'pending' OR b.status = 'in_progress')
|
|
ORDER BY b.start_time ASC
|
|
LIMIT 1
|
|
`, nextBooking.StartTime, todayEnd)
|
|
|
|
if err == nil && afterNext != nil {
|
|
next = afterNext
|
|
}
|
|
}
|
|
}
|
|
|
|
response := CurrentNextResponse{
|
|
Current: current,
|
|
Next: next,
|
|
}
|
|
|
|
// Get closing time respecting exceptional hours
|
|
todayOpen := isDayOpen(r, now)
|
|
if todayOpen {
|
|
closeTime := getClosingTime(r, now)
|
|
if closeTime != "" {
|
|
response.ClosingTime = &closeTime
|
|
}
|
|
}
|
|
|
|
// Determine if we're done for the day: no current, no next
|
|
if current == nil && next == nil {
|
|
done := true
|
|
response.DoneForDay = &done
|
|
|
|
if todayOpen {
|
|
// Regular done-for-the-day: show daily summary
|
|
summary := computeAggregateSummary(r, todayStart, todayEnd)
|
|
summary.SummaryScope = "day"
|
|
summary.SummaryStartDate = todayStart.Format("2006-01-02")
|
|
summary.SummaryEndDate = todayEnd.Format("2006-01-02")
|
|
response.Summary = summary
|
|
|
|
// If tomorrow is closed, also compute a week summary
|
|
tomorrow := now.AddDate(0, 0, 1)
|
|
if !isDayOpen(r, tomorrow) {
|
|
weekStart, _ := findWeekSummaryRange(r, tomorrow)
|
|
ws := computeAggregateSummary(r, weekStart, todayEnd)
|
|
ws.SummaryScope = "week"
|
|
ws.SummaryStartDate = weekStart.Format("2006-01-02")
|
|
ws.SummaryEndDate = todayStart.Format("2006-01-02")
|
|
response.WeekSummary = ws
|
|
}
|
|
} else {
|
|
// Closed day: show week summary — from start of last work period to now
|
|
weekStart, _ := findWeekSummaryRange(r, now)
|
|
summary := computeAggregateSummary(r, weekStart, todayEnd)
|
|
summary.SummaryScope = "week"
|
|
summary.SummaryStartDate = weekStart.Format("2006-01-02")
|
|
summary.SummaryEndDate = todayStart.Format("2006-01-02")
|
|
response.Summary = summary
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *DailySummary {
|
|
summary := &DailySummary{}
|
|
|
|
// 1. Total payments today (non-tip, completed)
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(p.amount), 0)
|
|
FROM bookings b
|
|
JOIN payments p ON p.booking_id = b.id
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND p.status = 'completed'
|
|
AND p.payment_type != 'tip'
|
|
`, rangeStart, rangeEnd).Scan(&summary.TotalPaymentsToday)
|
|
|
|
// 2. Total tips today (completed)
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(p.amount), 0)
|
|
FROM bookings b
|
|
JOIN payments p ON p.booking_id = b.id
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND p.status = 'completed'
|
|
AND p.payment_type = 'tip'
|
|
`, rangeStart, rangeEnd).Scan(&summary.TotalTipsToday)
|
|
|
|
// 3. Amount still due from today's non-cancelled bookings
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(sub.amount_due), 0)
|
|
FROM (
|
|
SELECT
|
|
b.id,
|
|
COALESCE(service_total, 0) - COALESCE(payment_total, 0) AS amount_due
|
|
FROM bookings b
|
|
LEFT JOIN (
|
|
SELECT booking_id, SUM(amount) AS payment_total
|
|
FROM payments
|
|
WHERE status = 'completed'
|
|
GROUP BY booking_id
|
|
) p ON p.booking_id = b.id
|
|
LEFT JOIN (
|
|
SELECT bs.booking_id, SUM(COALESCE(bs.override_price, s.price)) AS service_total
|
|
FROM booking_services bs
|
|
JOIN services s ON bs.service_id = s.id
|
|
GROUP BY bs.booking_id
|
|
) s ON s.booking_id = b.id
|
|
LEFT JOIN (
|
|
SELECT bcs.booking_id, SUM(COALESCE(bcs.override_price, cs.price)) AS service_total
|
|
FROM booking_custom_services bcs
|
|
JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
GROUP BY bcs.booking_id
|
|
) cs ON cs.booking_id = b.id
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
|
|
) sub
|
|
WHERE sub.amount_due > 0
|
|
`, rangeStart, rangeEnd).Scan(&summary.AmountDueToday)
|
|
|
|
// 4. Total duration spent in completed + in_progress appointments
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COALESCE(SUM(duration_minutes), 0) FROM (
|
|
SELECT
|
|
b.id,
|
|
COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 0)
|
|
+ COALESCE(SUM(COALESCE(bcs.override_duration_minutes, cs.duration_minutes)), 0)
|
|
AS duration_minutes
|
|
FROM bookings b
|
|
LEFT JOIN booking_services bs ON bs.booking_id = b.id
|
|
LEFT JOIN services s ON s.id = bs.service_id
|
|
LEFT JOIN booking_custom_services bcs ON bcs.booking_id = b.id
|
|
LEFT JOIN custom_services cs ON cs.id = bcs.custom_service_id
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status IN ('completed', 'in_progress')
|
|
GROUP BY b.id
|
|
) sub
|
|
`, rangeStart, rangeEnd).Scan(&summary.TotalDurationSpent)
|
|
|
|
// 5. Total bookings (completed + in-progress + confirmed, excluding cancelled/no-show)
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(DISTINCT b.id)
|
|
FROM bookings b
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show', 'no_deposit')
|
|
`, rangeStart, rangeEnd).Scan(&summary.TotalBookings)
|
|
|
|
// 6. New vs returning customers
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
WITH range_customers AS (
|
|
SELECT DISTINCT b.user_id
|
|
FROM bookings b
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show')
|
|
AND b.user_id IS NOT NULL
|
|
)
|
|
SELECT
|
|
COALESCE(COUNT(*) FILTER (WHERE NOT EXISTS (
|
|
SELECT 1 FROM bookings b2
|
|
WHERE b2.user_id = rc.user_id AND b2.start_time < $1
|
|
LIMIT 1
|
|
)), 0) AS new_customers,
|
|
COALESCE(COUNT(*) FILTER (WHERE EXISTS (
|
|
SELECT 1 FROM bookings b2
|
|
WHERE b2.user_id = rc.user_id AND b2.start_time < $1
|
|
LIMIT 1
|
|
)), 0) AS returning_customers
|
|
FROM range_customers rc
|
|
`, rangeStart, rangeEnd).Scan(&summary.NewCustomers, &summary.ReturningCustomers)
|
|
|
|
// 7. Gift cards sold today (non-inventory)
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(*)
|
|
FROM gift_cards
|
|
WHERE created_at >= $1 AND created_at < $2
|
|
AND is_inventory = false
|
|
`, rangeStart, rangeEnd).Scan(&summary.GiftCardsSold)
|
|
|
|
// 8. Last completed customer and their total visit count
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT u.fn, COUNT(b2.id)
|
|
FROM bookings b
|
|
JOIN users u ON u.id = b.user_id
|
|
LEFT JOIN bookings b2 ON b2.user_id = b.user_id AND b2.status NOT IN ('client_cancelled', 'we_cancelled')
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status = 'completed'
|
|
AND b.user_id IS NOT NULL
|
|
GROUP BY u.id, u.fn, b.start_time
|
|
ORDER BY b.start_time DESC
|
|
LIMIT 1
|
|
`, rangeStart, rangeEnd).Scan(&summary.LastCustomerName, &summary.LastCustomerVisits)
|
|
|
|
// 9. Guest customers (user_id IS NULL)
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT COUNT(DISTINCT b.id)
|
|
FROM bookings b
|
|
WHERE b.start_time >= $1 AND b.start_time < $2
|
|
AND b.status NOT IN ('client_cancelled', 'we_cancelled', 'no_show')
|
|
AND b.user_id IS NULL
|
|
`, rangeStart, rangeEnd).Scan(&summary.GuestCustomers)
|
|
|
|
// Customers served = new + returning + guest (distinct people, not bookings)
|
|
summary.CustomersServed = summary.NewCustomers + summary.ReturningCustomers + summary.GuestCustomers
|
|
|
|
// 10. New bookings since last working day close
|
|
summary.NewBookingServices = findNewBookingServices(r, rangeStart)
|
|
|
|
return summary
|
|
}
|
|
|
|
func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBookingCount {
|
|
now := time.Now()
|
|
|
|
// Walk back up to 14 days to find the last working day's closing time
|
|
var lastClose time.Time
|
|
found := false
|
|
|
|
for i := 1; i <= 14; i++ {
|
|
d := now.AddDate(0, 0, -i)
|
|
dateStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
|
weekday := int(d.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 6
|
|
} else {
|
|
weekday -= 1
|
|
}
|
|
|
|
// Check if this day has exceptional hours making it open
|
|
var exceptionalClose sql.NullString
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT ewh.end_time::text
|
|
FROM exceptional_working_hours ewh
|
|
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
|
|
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
|
|
WHERE ewh.weekday = $1
|
|
AND ega.week_start <= $2
|
|
AND ega.week_start + INTERVAL '7 days' > $2
|
|
AND ewh.is_open = true
|
|
LIMIT 1
|
|
`, weekday, dateStart).Scan(&exceptionalClose)
|
|
|
|
if exceptionalClose.Valid {
|
|
closeTime := exceptionalClose.String
|
|
parts := strings.Split(closeTime, ":")
|
|
h, m := 0, 0
|
|
fmt.Sscanf(parts[0], "%d", &h)
|
|
if len(parts) > 1 {
|
|
fmt.Sscanf(parts[1], "%d", &m)
|
|
}
|
|
lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location())
|
|
found = true
|
|
break
|
|
}
|
|
|
|
// Check default working hours
|
|
var defaultClose sql.NullString
|
|
_ = db.DB.QueryRow(r.Context(), `
|
|
SELECT end_time::text FROM working_hours
|
|
WHERE weekday = $1 AND is_open = true
|
|
LIMIT 1
|
|
`, weekday).Scan(&defaultClose)
|
|
|
|
if defaultClose.Valid {
|
|
closeTime := defaultClose.String
|
|
parts := strings.Split(closeTime, ":")
|
|
h, m := 0, 0
|
|
fmt.Sscanf(parts[0], "%d", &h)
|
|
if len(parts) > 1 {
|
|
fmt.Sscanf(parts[1], "%d", &m)
|
|
}
|
|
lastClose = time.Date(d.Year(), d.Month(), d.Day(), h, m, 0, 0, d.Location())
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
// Fallback: 5pm yesterday
|
|
lastClose = time.Date(now.Year(), now.Month(), now.Day()-1, 17, 0, 0, 0, now.Location())
|
|
}
|
|
|
|
// Query services across all new bookings, aggregated by service name
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT name, COUNT(*) as count
|
|
FROM (
|
|
SELECT s.name FROM booking_services bs2
|
|
JOIN services s ON s.id = bs2.service_id
|
|
JOIN bookings b ON b.id = bs2.booking_id
|
|
WHERE b.created_at > $1 AND b.created_at <= $2
|
|
AND b.status != 'pending'
|
|
UNION ALL
|
|
SELECT cs.name FROM booking_custom_services bcs2
|
|
JOIN custom_services cs ON cs.id = bcs2.custom_service_id
|
|
JOIN bookings b ON b.id = bcs2.booking_id
|
|
WHERE b.created_at > $1 AND b.created_at <= $2
|
|
AND b.status != 'pending'
|
|
) all_services
|
|
GROUP BY name
|
|
ORDER BY count DESC, name ASC
|
|
`, lastClose, now)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to fetch new booking services since close: %v", err)
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []ServiceBookingCount
|
|
for rows.Next() {
|
|
var item ServiceBookingCount
|
|
if err := rows.Scan(&item.ServiceName, &item.Count); err != nil {
|
|
log.Printf("Failed to scan new booking service row: %v", err)
|
|
continue
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
func isDayOpen(r *http.Request, date time.Time) bool {
|
|
weekday := int(date.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 6
|
|
} else {
|
|
weekday -= 1
|
|
}
|
|
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
|
|
|
var exceptionalOpen sql.NullBool
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT ewh.is_open
|
|
FROM exceptional_working_hours ewh
|
|
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
|
|
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
|
|
WHERE ewh.weekday = $1
|
|
AND ega.week_start <= $2
|
|
AND ega.week_start + INTERVAL '7 days' > $2
|
|
LIMIT 1
|
|
`, weekday, dateStart).Scan(&exceptionalOpen)
|
|
|
|
if err == nil {
|
|
return exceptionalOpen.Bool
|
|
}
|
|
|
|
var isOpen bool
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT is_open FROM working_hours WHERE weekday = $1
|
|
`, weekday).Scan(&isOpen)
|
|
|
|
return err == nil && isOpen
|
|
}
|
|
|
|
// findWeekSummaryRange returns the start and end of the working period
|
|
// before the current run of consecutive closed days.
|
|
// For adjacent closed days (e.g. Sat+Sun), both days show stats for the
|
|
// same working period (e.g. Mon-Fri).
|
|
func findWeekSummaryRange(r *http.Request, today time.Time) (time.Time, time.Time) {
|
|
// Find the start of this run of consecutive closed days
|
|
closedRunStart := today
|
|
for i := 0; i <= 14; i++ {
|
|
d := today.AddDate(0, 0, -i)
|
|
if !isDayOpen(r, d) {
|
|
closedRunStart = d
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
// The working period ends the day before the closed run starts
|
|
workingEnd := closedRunStart.AddDate(0, 0, -1)
|
|
workingEndStart := time.Date(workingEnd.Year(), workingEnd.Month(), workingEnd.Day(), 0, 0, 0, 0, workingEnd.Location())
|
|
|
|
// Walk back from workingEnd to find where the previous closed run ended
|
|
workingStart := workingEndStart
|
|
for i := 0; i <= 14; i++ {
|
|
d := workingEnd.AddDate(0, 0, -i)
|
|
if !isDayOpen(r, d) {
|
|
workingStart = d.AddDate(0, 0, 1) // day after the closed day
|
|
workingStart = time.Date(workingStart.Year(), workingStart.Month(), workingStart.Day(), 0, 0, 0, 0, workingStart.Location())
|
|
break
|
|
}
|
|
}
|
|
|
|
// workingEndEnd is the end boundary (exclusive)
|
|
workingEndEnd := workingEndStart.Add(24 * time.Hour)
|
|
|
|
return workingStart, workingEndEnd
|
|
}
|
|
|
|
func getClosingTime(r *http.Request, date time.Time) string {
|
|
weekday := int(date.Weekday())
|
|
if weekday == 0 {
|
|
weekday = 6
|
|
} else {
|
|
weekday -= 1
|
|
}
|
|
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
|
|
|
// Check exceptional hours first
|
|
var exceptionalClose sql.NullString
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT ewh.end_time::text
|
|
FROM exceptional_working_hours ewh
|
|
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
|
|
JOIN exceptional_group_applications ega ON ega.group_id = ewhg.id
|
|
WHERE ewh.weekday = $1
|
|
AND ega.week_start <= $2
|
|
AND ega.week_start + INTERVAL '7 days' > $2
|
|
AND ewh.is_open = true
|
|
LIMIT 1
|
|
`, weekday, dateStart).Scan(&exceptionalClose)
|
|
|
|
if err == nil && exceptionalClose.Valid {
|
|
return exceptionalClose.String
|
|
}
|
|
|
|
// Fall back to default working hours
|
|
var defaultClose sql.NullString
|
|
err = db.DB.QueryRow(r.Context(), `
|
|
SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true
|
|
`, weekday).Scan(&defaultClose)
|
|
|
|
if err == nil && defaultClose.Valid {
|
|
return defaultClose.String
|
|
}
|
|
|
|
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
|
|
UNION ALL
|
|
SELECT
|
|
cs.name,
|
|
cs.description,
|
|
COALESCE(bcs.override_price, cs.price),
|
|
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs
|
|
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = $1
|
|
ORDER BY 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()
|
|
|
|
// Auto-transition confirmed bookings that have started but not ended to in_progress
|
|
_, err := db.DB.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'in_progress'
|
|
WHERE status = 'confirmed'
|
|
AND start_time <= $1
|
|
AND (
|
|
start_time + (
|
|
COALESCE(
|
|
(SELECT SUM(dur) FROM (
|
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id
|
|
UNION ALL
|
|
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = bookings.id
|
|
) sub),
|
|
0
|
|
) || ' minutes'
|
|
)::interval
|
|
) > $1
|
|
`, now)
|
|
if err != nil {
|
|
log.Printf("Failed to auto-transition bookings to in_progress: %v", err)
|
|
}
|
|
|
|
// Auto-transition in_progress bookings that have ended to completed
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
UPDATE bookings
|
|
SET status = 'completed'
|
|
WHERE status = 'in_progress'
|
|
AND (
|
|
start_time + (
|
|
COALESCE(
|
|
(SELECT SUM(dur) FROM (
|
|
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
|
|
FROM booking_services bs JOIN services s ON bs.service_id = s.id
|
|
WHERE bs.booking_id = bookings.id
|
|
UNION ALL
|
|
SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = bookings.id
|
|
) sub),
|
|
0
|
|
) || ' minutes'
|
|
)::interval
|
|
) <= $1
|
|
`, now)
|
|
if err != nil {
|
|
log.Printf("Failed to auto-transition bookings to completed: %v", err)
|
|
}
|
|
|
|
rangeStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
todayEnd := rangeStart.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
|
|
`, rangeStart, 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
|
|
UNION ALL
|
|
SELECT
|
|
cs.name,
|
|
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs
|
|
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = $1
|
|
ORDER BY 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
|
|
UNION ALL
|
|
SELECT
|
|
cs.name,
|
|
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
FROM booking_custom_services bcs
|
|
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
WHERE bcs.booking_id = $1
|
|
ORDER BY 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
|
|
}
|
|
}
|