Split admin dashboard, implement user and booking search
This commit is contained in:
@@ -171,9 +171,10 @@ type GetAllBookingsRequest struct {
|
||||
// BookingListResponse represents a paginated list of bookings
|
||||
type BookingListResponse struct {
|
||||
Bookings []Booking `json:"bookings"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
}
|
||||
|
||||
// SearchBookingsRequest represents search parameters
|
||||
@@ -573,76 +574,53 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
req := parseGetAllBookingsRequest(r)
|
||||
// Parse pagination parameters
|
||||
query := r.URL.Query()
|
||||
page := 1
|
||||
perPage := 5
|
||||
|
||||
// Build query with specific user filter
|
||||
baseQuery := `
|
||||
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by,
|
||||
u.fn, u.profile_pic_url, u.notes as user_notes
|
||||
FROM bookings b
|
||||
LEFT JOIN users u ON b.user_id = u.id
|
||||
WHERE b.user_id = $1
|
||||
`
|
||||
|
||||
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
|
||||
var args []interface{}
|
||||
args = append(args, userID)
|
||||
paramCount := 2
|
||||
|
||||
// Add filters
|
||||
if req.Status != nil {
|
||||
baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount)
|
||||
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
|
||||
args = append(args, *req.Status)
|
||||
paramCount++
|
||||
if pageStr := query.Get("page"); pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if req.StartDate != nil {
|
||||
baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
|
||||
countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount)
|
||||
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
||||
return
|
||||
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
||||
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
|
||||
perPage = pp
|
||||
}
|
||||
// Ensure it's at start of day in London time
|
||||
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
|
||||
args = append(args, startTime)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
if req.EndDate != nil {
|
||||
baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount)
|
||||
countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount)
|
||||
endTime, err := time.Parse("2006-01-02", *req.EndDate)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
||||
args = append(args, endTime)
|
||||
paramCount++
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
baseQuery += " ORDER BY b.start_time ASC"
|
||||
if req.PerPage > 0 {
|
||||
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
|
||||
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total)
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*)
|
||||
FROM bookings
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&total)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get booking count for user %s: %v", userID, err)
|
||||
log.Printf("Failed to count bookings for user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get bookings
|
||||
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
||||
// Fetch bookings with pagination, ordered by start_time DESC (future to past)
|
||||
rows, err := db.DB.Query(r.Context(), `
|
||||
SELECT
|
||||
b.id,
|
||||
b.start_time,
|
||||
b.status,
|
||||
b.notes,
|
||||
b.created_at,
|
||||
b.updated_at,
|
||||
b.created_by
|
||||
FROM bookings b
|
||||
WHERE b.user_id = $1
|
||||
ORDER BY b.start_time DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, userID, perPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
@@ -653,45 +631,81 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var bookings []Booking
|
||||
for rows.Next() {
|
||||
var b Booking
|
||||
b.User = &UserSummary{}
|
||||
var createdBy sql.NullString
|
||||
|
||||
var userFullName, userPicURL, userNotes sql.NullString
|
||||
err := rows.Scan(
|
||||
&b.ID, &b.User.ID, &b.StartTime, &b.Status, &b.Notes,
|
||||
&b.CreatedAt, &b.UpdatedAt, &createdBy,
|
||||
&userFullName, &userPicURL, &userNotes,
|
||||
&b.ID,
|
||||
&b.StartTime,
|
||||
&b.Status,
|
||||
&b.Notes,
|
||||
&b.CreatedAt,
|
||||
&b.UpdatedAt,
|
||||
&b.CreatedBy,
|
||||
)
|
||||
if userFullName.Valid {
|
||||
b.User.FullName = userFullName.String
|
||||
}
|
||||
if userPicURL.Valid {
|
||||
b.User.ProfilePicURL = &userPicURL.String
|
||||
}
|
||||
if userNotes.Valid {
|
||||
b.User.Notes = &userNotes.String
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan booking row: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if createdBy.Valid {
|
||||
b.CreatedBy = &createdBy.String
|
||||
|
||||
// Fetch services for this booking
|
||||
serviceRows, err := db.DB.Query(r.Context(), `
|
||||
SELECT
|
||||
s.name,
|
||||
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
|
||||
`, b.ID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
for serviceRows.Next() {
|
||||
var service BookingService
|
||||
var price float64
|
||||
var durationMinutes int
|
||||
|
||||
if err := serviceRows.Scan(&service.ServiceName, &price, &durationMinutes); err != nil {
|
||||
log.Printf("Failed to scan service: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
service.Price = &price
|
||||
service.DurationMinutes = &durationMinutes
|
||||
totalAmount += price
|
||||
|
||||
b.Services = append(b.Services, service)
|
||||
}
|
||||
serviceRows.Close()
|
||||
|
||||
b.TotalAmount = totalAmount
|
||||
bookings = append(bookings, b)
|
||||
}
|
||||
|
||||
if bookings == nil {
|
||||
bookings = []Booking{}
|
||||
}
|
||||
|
||||
// Calculate total pages
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
response := BookingListResponse{
|
||||
Bookings: bookings,
|
||||
Page: req.Page,
|
||||
PerPage: req.PerPage,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
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 response: %v", err)
|
||||
log.Printf("Failed to encode bookings response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -773,7 +787,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer serviceRows.Close()
|
||||
|
||||
var totalAmount float64
|
||||
var durationMinutes int
|
||||
var durationMinutesTotal int
|
||||
|
||||
for serviceRows.Next() {
|
||||
var name string
|
||||
@@ -788,7 +802,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Calculate totals
|
||||
totalAmount += price
|
||||
durationMinutes += durationMinutes
|
||||
durationMinutesTotal += durationMinutes
|
||||
booking.Services = append(booking.Services, BookingService{
|
||||
ServiceName: &name,
|
||||
Price: &price,
|
||||
@@ -797,7 +811,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
booking.TotalAmount = totalAmount
|
||||
booking.DurationMinutes = durationMinutes
|
||||
booking.DurationMinutes = durationMinutesTotal
|
||||
|
||||
// ----------------------------
|
||||
// 3. Fetch payments and calculate amount paid
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package portfolio
|
||||
|
||||
// import (
|
||||
// "crussell/db"
|
||||
// "net/http"
|
||||
// )
|
||||
|
||||
// func GetImages(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select id, r2_url
|
||||
// from images
|
||||
// where
|
||||
// ($1::text[] is null or tag_names @> $1)
|
||||
// and ($2::text is null or exists (
|
||||
// select 1 from unnest(tag_names) as tag
|
||||
// where tag ilike '%' || $2 || '%'
|
||||
// ))
|
||||
// order by created_at desc
|
||||
// limit $3 offset $4
|
||||
// `,
|
||||
// semanticTags,
|
||||
// searchTerm,
|
||||
// limit,
|
||||
// offset,
|
||||
// )
|
||||
// }
|
||||
|
||||
// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select name
|
||||
// from tags
|
||||
// where name ilike '%' || $1 || '%'
|
||||
// order by similarity(name, $1) desc
|
||||
// limit 10
|
||||
// `, query)
|
||||
// }
|
||||
|
||||
// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
// rows, err := db.DB.Query(r.Context(), `
|
||||
// select name
|
||||
// from tags
|
||||
// where
|
||||
// name not like '%:%'
|
||||
// and name ilike '%' || $1 || '%'
|
||||
// order by similarity(name, $1) desc
|
||||
// limit 10
|
||||
// `, query)
|
||||
// }
|
||||
@@ -2,13 +2,17 @@ package user
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
@@ -39,6 +43,55 @@ type UpdateProfileRequest struct {
|
||||
Phone string `json:"phone"`
|
||||
}
|
||||
|
||||
type AdminUserDetail struct {
|
||||
ID string `json:"id"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
FullName string `json:"fullName"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
DateOfBirth *string `json:"dateOfBirth,omitempty"`
|
||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||
AccountRole string `json:"accountRole"`
|
||||
AccountType string `json:"accountType"`
|
||||
LoyaltyStamps int `json:"loyaltyStamps"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
ReferralCodeUses int `json:"referralCodeUses"`
|
||||
LastLoginAt *string `json:"lastLoginAt,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
|
||||
// GDPR consent fields
|
||||
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
|
||||
PolicyConsentUpdatedAt *string `json:"policyConsentUpdatedAt,omitempty"`
|
||||
DataRetentionConsent bool `json:"dataRetentionConsent"`
|
||||
DataConsentUpdatedAt *string `json:"dataConsentUpdatedAt,omitempty"`
|
||||
|
||||
// Social logins
|
||||
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
|
||||
}
|
||||
|
||||
type SocialLogin struct {
|
||||
Provider string `json:"provider"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type UserListItem struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type UserListResponse struct {
|
||||
Users []UserListItem `json:"users"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"perPage"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
}
|
||||
|
||||
// GET /api/user/profile
|
||||
func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
@@ -209,3 +262,216 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// GET /api/admin/users/{id}
|
||||
func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID := chi.URLParam(r, "id")
|
||||
if userID == "" {
|
||||
http.Error(w, "User ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user details
|
||||
var user AdminUserDetail
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT
|
||||
id, email, n_first_name, n_last_name, fn, phone,
|
||||
date_of_birth::text, profile_pic_url,
|
||||
account_role, account_type, loyalty_stamps, referral_code,
|
||||
last_login_at::text, created_at::text, updated_at::text, notes,
|
||||
privacy_policy_and_terms_consent,
|
||||
policy_consent_updated_at::text,
|
||||
data_retention_consent,
|
||||
data_consent_updated_at::text
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, userID).Scan(
|
||||
&user.ID, &user.Email, &user.FirstName, &user.LastName, &user.FullName,
|
||||
&user.Phone, &user.DateOfBirth, &user.ProfilePicURL,
|
||||
&user.AccountRole, &user.AccountType, &user.LoyaltyStamps, &user.ReferralCode,
|
||||
&user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes,
|
||||
&user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt,
|
||||
&user.DataRetentionConsent, &user.DataConsentUpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to fetch user %s: %v", userID, err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch referral code uses count
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT COUNT(*)
|
||||
FROM user_referrals
|
||||
WHERE referrer_id = $1 AND claimed_booking_id IS NOT NULL
|
||||
`, userID).Scan(&user.ReferralCodeUses)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch referral code uses for user %s: %v", userID, err)
|
||||
user.ReferralCodeUses = 0
|
||||
}
|
||||
|
||||
// Fetch social logins
|
||||
socialRows, err := db.DB.Query(r.Context(), `
|
||||
SELECT provider, created_at::text
|
||||
FROM user_social_logins
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch social logins for user %s: %v", userID, err)
|
||||
} else {
|
||||
defer socialRows.Close()
|
||||
|
||||
var socialLogins []SocialLogin
|
||||
for socialRows.Next() {
|
||||
var sl SocialLogin
|
||||
if err := socialRows.Scan(&sl.Provider, &sl.CreatedAt); err != nil {
|
||||
log.Printf("Failed to scan social login for user %s: %v", userID, err)
|
||||
continue
|
||||
}
|
||||
socialLogins = append(socialLogins, sl)
|
||||
}
|
||||
|
||||
if len(socialLogins) > 0 {
|
||||
user.SocialLogins = socialLogins
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(user); err != nil {
|
||||
log.Printf("Failed to encode user response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/users
|
||||
func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse query parameters
|
||||
query := r.URL.Query()
|
||||
searchTerm := query.Get("q")
|
||||
|
||||
// Pagination parameters
|
||||
page := 1
|
||||
perPage := 10
|
||||
|
||||
if pageStr := query.Get("page"); pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if perPageStr := query.Get("per_page"); perPageStr != "" {
|
||||
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
|
||||
perPage = pp
|
||||
}
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
|
||||
// Build query based on whether search is provided
|
||||
var countQuery string
|
||||
var listQuery string
|
||||
var countArgs []interface{}
|
||||
var listArgs []interface{}
|
||||
|
||||
if searchTerm != "" {
|
||||
// Search in name, email, or phone
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
|
||||
countQuery = `
|
||||
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
|
||||
`
|
||||
listArgs = []interface{}{searchPattern, perPage, offset}
|
||||
} else {
|
||||
// No search - get all users
|
||||
countQuery = `SELECT COUNT(*) FROM users`
|
||||
countArgs = []interface{}{}
|
||||
|
||||
listQuery = `
|
||||
SELECT id, fn, email, phone
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
listArgs = []interface{}{perPage, offset}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int
|
||||
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
|
||||
if err != nil {
|
||||
log.Printf("Failed to count users: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get users list
|
||||
rows, err := db.DB.Query(r.Context(), listQuery, listArgs...)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch users: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []UserListItem
|
||||
for rows.Next() {
|
||||
var user UserListItem
|
||||
err := rows.Scan(&user.ID, &user.FullName, &user.Email, &user.Phone)
|
||||
if err != nil {
|
||||
log.Printf("Failed to scan user row: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
// Handle empty results
|
||||
if users == nil {
|
||||
users = []UserListItem{}
|
||||
}
|
||||
|
||||
// Calculate total pages
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
response := UserListResponse{
|
||||
Users: users,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
|
||||
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 users response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,12 @@ func main() {
|
||||
r.Get("/{id}", bookings.GetAdminBookingHandler)
|
||||
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
|
||||
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
|
||||
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
|
||||
})
|
||||
|
||||
r.Route("/admin/users", func(r chi.Router) {
|
||||
r.Get("/", user.ListAdminUsersHandler)
|
||||
r.Get("/{id}", user.GetAdminUserHandler)
|
||||
})
|
||||
|
||||
// --- Admin Notifications ---
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(), bookingId }: Props = $props();
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'client_cancelled'
|
||||
| 'we_cancelled'
|
||||
| 're-schedule'
|
||||
| 'no_show';
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
|
||||
user?: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
profile_pic_url?: string;
|
||||
date_of_birth?: string;
|
||||
account_role: string;
|
||||
loyalty_stamps?: number;
|
||||
referral_code?: string;
|
||||
referral_code_uses?: number;
|
||||
created_at: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
services: Array<{
|
||||
booking_id: string;
|
||||
service_id: string;
|
||||
override_price?: number;
|
||||
override_duration_minutes?: number;
|
||||
service_name?: string;
|
||||
service_description?: string;
|
||||
price?: number;
|
||||
duration_minutes?: number;
|
||||
}>;
|
||||
|
||||
payments: Array<{
|
||||
id: string;
|
||||
booking_id: string;
|
||||
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
|
||||
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
|
||||
vendor_code?: string;
|
||||
invoice_number?: number;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
amount: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_rate?: number;
|
||||
vat_amount?: number;
|
||||
net_amount?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}>;
|
||||
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
amount_due: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
|
||||
async function fetchBookingDetails() {
|
||||
if (!bookingId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${bookingId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
selectedBooking = {
|
||||
id: data.id,
|
||||
start_time: data.start_time,
|
||||
status: data.status,
|
||||
notes: data.notes,
|
||||
user: data.user
|
||||
? {
|
||||
id: data.user.id,
|
||||
full_name: data.user.full_name,
|
||||
email: data.user.email,
|
||||
phone: data.user.phone,
|
||||
profile_pic_url: data.user.profile_pic_url,
|
||||
date_of_birth: data.user.date_of_birth,
|
||||
account_role: data.user.account_role,
|
||||
loyalty_stamps: data.user.loyalty_stamps,
|
||||
referral_code: data.user.referral_code,
|
||||
referral_code_uses: data.user.referral_code_uses,
|
||||
created_at: data.user.created_at,
|
||||
notes: data.user.notes
|
||||
}
|
||||
: undefined,
|
||||
services: (data.services || []).map((s) => ({
|
||||
booking_id: s.booking_id,
|
||||
service_id: s.service_id,
|
||||
service_name: s.service_name,
|
||||
service_description: s.service_description,
|
||||
price: s.price,
|
||||
duration_minutes: s.duration_minutes
|
||||
})),
|
||||
payments: (data.payments || []).map((p) => ({
|
||||
id: p.id,
|
||||
booking_id: p.booking_id,
|
||||
payment_type: p.payment_type,
|
||||
payment_method: p.payment_method,
|
||||
vendor_code: p.vendor_code,
|
||||
invoice_number: p.invoice_number,
|
||||
status: p.status,
|
||||
amount: p.amount,
|
||||
is_vat_applicable: p.is_vat_applicable,
|
||||
vat_rate: p.vat_rate,
|
||||
vat_amount: p.vat_amount,
|
||||
net_amount: p.net_amount,
|
||||
created_at: p.created_at,
|
||||
updated_at: p.updated_at,
|
||||
created_by: p.created_by
|
||||
})),
|
||||
total_amount: data.total_amount || 0,
|
||||
amount_paid: data.amount_paid || 0,
|
||||
amount_due: data.amount_due || 0,
|
||||
duration_minutes: data.duration_minutes || 0,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
created_by: data.created_by
|
||||
};
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load booking details: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching booking details:', err);
|
||||
toast.error('Network error loading booking details');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && bookingId) {
|
||||
fetchBookingDetails();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
|
||||
{#if selectedBooking}
|
||||
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedBooking}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||
{selectedBooking.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: selectedBooking.status === 'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: selectedBooking.status === 'in_progress'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: selectedBooking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: selectedBooking.status === 'client_cancelled'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: selectedBooking.status === 'we_cancelled'
|
||||
? 'bg-rose-100 text-rose-800'
|
||||
: selectedBooking.status === 're-schedule'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: selectedBooking.status === 'no_show'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{selectedBooking.status.charAt(0).toUpperCase() + selectedBooking.status.slice(1)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
{#if selectedBooking}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Appointment Details -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Appointment Details
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(selectedBooking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Duration</div>
|
||||
<div class="font-medium">{selectedBooking.duration_minutes} minutes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Created</div>
|
||||
<div class="text-sm">
|
||||
{new SvelteDate(selectedBooking.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Last Updated</div>
|
||||
<div class="text-sm">
|
||||
{new SvelteDate(selectedBooking.updated_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedBooking.created_by}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs text-gray-500">Created By</div>
|
||||
<div class="text-sm">{selectedBooking.created_by}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedBooking.notes}
|
||||
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div class="mb-1 text-xs font-semibold text-amber-800">Booking Notes</div>
|
||||
<div class="text-sm text-amber-900">{selectedBooking.notes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Customer Information -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Customer Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Name</div>
|
||||
<div class="font-medium">{selectedBooking.user?.full_name || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">{selectedBooking.user?.email || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
<div class="font-medium">{selectedBooking.user?.phone || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Customer ID</div>
|
||||
<div class="font-medium">{selectedBooking.user?.id || '—'}</div>
|
||||
</div>
|
||||
{#if selectedBooking.user?.loyalty_stamps !== undefined && selectedBooking.user?.loyalty_stamps !== null}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Loyalty Stamps</div>
|
||||
<div class="font-medium">{selectedBooking.user.loyalty_stamps}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedBooking.user?.referral_code}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Referral Code</div>
|
||||
<div class="font-medium">{selectedBooking.user.referral_code}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if selectedBooking.user?.referral_code_uses !== undefined && selectedBooking.user?.referral_code_uses !== null}
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Referral Uses</div>
|
||||
<div class="font-medium">{selectedBooking.user.referral_code_uses}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedBooking.user?.notes}
|
||||
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<div class="mb-1 text-xs font-semibold text-blue-800">Customer Notes</div>
|
||||
<div class="text-sm text-blue-900">{selectedBooking.user.notes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
{#if selectedBooking.services && selectedBooking.services.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Services
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.services as service, index (index)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="font-medium">{service.service_name || '—'}</div>
|
||||
{#if service.service_description}
|
||||
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">{service.duration_minutes} min</span>
|
||||
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Financial Summary -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Financial Summary
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Total Amount</span>
|
||||
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid</span>
|
||||
<span class="font-semibold text-green-700"
|
||||
>£{selectedBooking.amount_paid.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||
<span class="font-medium text-gray-900">Amount Due</span>
|
||||
<span
|
||||
class="text-lg font-bold {selectedBooking.amount_due > 0
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'}"
|
||||
>
|
||||
£{selectedBooking.amount_due.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Payment History
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.payments as payment (payment.id)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium capitalize"
|
||||
>{payment.payment_method.replace('_', ' ')}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{payment.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: payment.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: payment.status === 'failed'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{payment.payment_type.charAt(0).toUpperCase() +
|
||||
payment.payment_type.slice(1)}
|
||||
</div>
|
||||
{#if payment.vendor_code || payment.invoice_number}
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{#if payment.vendor_code}Vendor: {payment.vendor_code}{/if}
|
||||
{#if payment.vendor_code && payment.invoice_number}
|
||||
•
|
||||
{/if}
|
||||
{#if payment.invoice_number}Invoice: #{payment.invoice_number}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if payment.is_vat_applicable}
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
<div>
|
||||
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed(
|
||||
2
|
||||
) || '0.00'}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold">
|
||||
£{payment.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// Props
|
||||
let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props();
|
||||
|
||||
// Types
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'client_cancelled'
|
||||
| 'we_cancelled'
|
||||
| 're-schedule'
|
||||
| 'no_show';
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
user?: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
};
|
||||
services: Array<{
|
||||
booking_id: string;
|
||||
service_id: string;
|
||||
override_price?: number;
|
||||
override_duration_minutes?: number;
|
||||
service_name?: string;
|
||||
service_description?: string;
|
||||
price?: number;
|
||||
duration_minutes?: number;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
booking_id: string;
|
||||
payment_type: 'deposit' | 'full' | 'tip' | 'balance' | 'partial';
|
||||
payment_method: 'online_square' | 'in_person_card' | 'cash' | 'giftcard' | 'discount';
|
||||
vendor_code?: string;
|
||||
invoice_number?: number;
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded';
|
||||
amount: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_rate?: number;
|
||||
vat_amount?: number;
|
||||
net_amount?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
}>;
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
amount_due: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
// State
|
||||
let bookings = $state<Booking[]>([]);
|
||||
let bookingQuery = $state('');
|
||||
let loadingSearch = $state(false);
|
||||
|
||||
// Fetch bookings from API
|
||||
async function fetchBookings() {
|
||||
loadingSearch = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/bookings', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.bookings && data.bookings.length === 0) {
|
||||
bookings = [];
|
||||
return;
|
||||
}
|
||||
|
||||
bookings = data.bookings.map((b: any) => ({
|
||||
id: b.id,
|
||||
start_time: b.start_time,
|
||||
status: b.status,
|
||||
notes: b.notes,
|
||||
created_at: b.created_at,
|
||||
updated_at: b.updated_at,
|
||||
created_by: b.created_by,
|
||||
user: b.user
|
||||
? {
|
||||
id: b.user.id,
|
||||
full_name: b.user.full_name
|
||||
}
|
||||
: undefined,
|
||||
services: b.services || [],
|
||||
total_amount: b.total_amount || 0,
|
||||
amount_paid: b.amount_paid || 0,
|
||||
amount_due: b.amount_due || 0,
|
||||
duration_minutes: b.duration_minutes || 0
|
||||
}));
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load bookings: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching bookings:', err);
|
||||
toast.error('Network error loading bookings');
|
||||
} finally {
|
||||
loadingSearch = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search bookings via API
|
||||
async function searchBookings() {
|
||||
loadingSearch = true;
|
||||
|
||||
if (!bookingQuery.trim()) {
|
||||
await fetchBookings();
|
||||
loadingSearch = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
bookings = data.bookings.map((b: any) => ({
|
||||
id: b.id,
|
||||
start_time: b.start_time,
|
||||
status: b.status,
|
||||
notes: b.notes,
|
||||
created_at: b.created_at,
|
||||
updated_at: b.updated_at,
|
||||
created_by: b.created_by,
|
||||
user: b.user
|
||||
? {
|
||||
id: b.user.id,
|
||||
full_name: b.user.full_name
|
||||
}
|
||||
: undefined,
|
||||
services: b.services || [],
|
||||
total_amount: b.total_amount || 0,
|
||||
amount_paid: b.amount_paid || 0,
|
||||
amount_due: b.amount_due || 0,
|
||||
duration_minutes: b.duration_minutes || 0
|
||||
}));
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to search bookings: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error searching bookings:', err);
|
||||
toast.error('Network error searching bookings');
|
||||
} finally {
|
||||
loadingSearch = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format booking date/time
|
||||
function formatBookingDateTime(startTime: string): string {
|
||||
const date = new SvelteDate(startTime);
|
||||
const now = new SvelteDate();
|
||||
const today = new SvelteDate(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const bookingDate = new SvelteDate(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const daysDiff = Math.floor((bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'Aug',
|
||||
'Sept',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
const day = days[date.getDay()];
|
||||
const dateNum = date.getDate();
|
||||
const month = months[date.getMonth()];
|
||||
const year = date.getFullYear();
|
||||
const currentYear = now.getFullYear();
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const ampm = hours >= 12 ? 'pm' : 'am';
|
||||
const hour12 = hours % 12 || 12;
|
||||
const time = `${hour12}:${minutes}${ampm}`;
|
||||
|
||||
if (daysDiff === 0) return `Today, ${time}`;
|
||||
if (daysDiff === 1) return `Tomorrow, ${time}`;
|
||||
if (daysDiff > 1 && daysDiff <= 6) return `${day}, ${time}`;
|
||||
if (daysDiff < 0 && daysDiff >= -6) return `Last ${day}, ${time}`;
|
||||
|
||||
const suffix =
|
||||
dateNum === 1 || dateNum === 21 || dateNum === 31
|
||||
? 'st'
|
||||
: dateNum === 2 || dateNum === 22
|
||||
? 'nd'
|
||||
: dateNum === 3 || dateNum === 23
|
||||
? 'rd'
|
||||
: 'th';
|
||||
const yearStr = year !== currentYear ? ` ${year}` : '';
|
||||
return `${day}, ${dateNum}${suffix} ${month}${yearStr} at ${time}`;
|
||||
}
|
||||
|
||||
// Format services list
|
||||
function formatServices(services: Booking['services']): string {
|
||||
const serviceNames = services.map((s) => s.service_name || 'Unknown Service');
|
||||
if (serviceNames.length === 0) return 'No services';
|
||||
if (serviceNames.length === 1) return serviceNames[0];
|
||||
if (serviceNames.length === 2) return serviceNames.join(' and ');
|
||||
return `${serviceNames[0]} and ${serviceNames.length - 1} other${serviceNames.length - 1 > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
// Get status badge classes
|
||||
function getStatusClasses(status: Booking['status']): string {
|
||||
const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium';
|
||||
const statusMap = {
|
||||
confirmed: 'bg-emerald-100 text-emerald-800',
|
||||
pending: 'bg-amber-100 text-amber-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
client_cancelled: 'bg-red-100 text-red-800',
|
||||
we_cancelled: 'bg-rose-100 text-rose-800',
|
||||
're-schedule': 'bg-purple-100 text-purple-800',
|
||||
no_show: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
|
||||
}
|
||||
|
||||
function getStatusDotClasses(status: Booking['status']): string {
|
||||
const statusMap = {
|
||||
confirmed: 'bg-emerald-600',
|
||||
pending: 'bg-amber-600',
|
||||
in_progress: 'bg-blue-600',
|
||||
completed: 'bg-green-600',
|
||||
client_cancelled: 'bg-red-600',
|
||||
we_cancelled: 'bg-rose-600',
|
||||
're-schedule': 'bg-purple-600',
|
||||
no_show: 'bg-gray-600'
|
||||
};
|
||||
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
|
||||
}
|
||||
|
||||
// Fetch bookings on mount
|
||||
$effect(() => {
|
||||
fetchBookings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class="h-full">
|
||||
<Card.Header>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Bookings
|
||||
</Card.Title>
|
||||
<Card.Description>Search and manage booking history.</Card.Description>
|
||||
</div>
|
||||
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||
<span class="text-xs font-semibold">{bookings.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search by customer name, email, phone, or service"
|
||||
bind:value={bookingQuery}
|
||||
onkeyup={(e) => {
|
||||
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
|
||||
}}
|
||||
/>
|
||||
<Button onclick={searchBookings} disabled={loadingSearch}>
|
||||
{loadingSearch ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
||||
{#if loadingSearch}
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
</div>
|
||||
{:else if bookings.length === 0}
|
||||
<div class="text-center text-sm text-gray-500">No bookings found.</div>
|
||||
{:else}
|
||||
{#each bookings as b (b.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{formatBookingDateTime(b.start_time)}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span class={getStatusClasses(b.status)}>
|
||||
<span class={getStatusDotClasses(b.status)}></span>
|
||||
{b.status}
|
||||
</span>
|
||||
<span>• {b.user?.full_name || 'Unknown User'}</span>
|
||||
<span>
|
||||
- {formatServices(b.services)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,678 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// =============== Types ===============
|
||||
type WorkingHourRow = {
|
||||
weekday: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_open: boolean;
|
||||
};
|
||||
|
||||
type ExceptionGroup = {
|
||||
id?: number;
|
||||
name: string;
|
||||
description: string;
|
||||
weekStarts: string[];
|
||||
hours: WorkingHourRow[];
|
||||
};
|
||||
|
||||
// =============== State ===============
|
||||
let exceptionGroups = $state<ExceptionGroup[]>([]);
|
||||
let exceptionGroupsLoading = $state(true);
|
||||
let savingHours = $state(false);
|
||||
|
||||
// Exception modal state
|
||||
let showExceptionModal = $state(false);
|
||||
let exceptionDraft = $state<ExceptionGroup>({
|
||||
name: '',
|
||||
description: '',
|
||||
weekStarts: [],
|
||||
hours: [
|
||||
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
|
||||
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
|
||||
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
|
||||
]
|
||||
});
|
||||
|
||||
let weekRangeFrom = $state('');
|
||||
let weekRangeTo = $state('');
|
||||
|
||||
// Delete confirmation state
|
||||
let showDeleteExceptionAlert = $state(false);
|
||||
let exceptionToDelete = $state<number | undefined>(undefined);
|
||||
|
||||
// View exception state
|
||||
let showViewExceptionModal = $state(false);
|
||||
let viewingException = $state<ExceptionGroup | null>(null);
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
function weekdayLabel(i: number) {
|
||||
return dayNames[i];
|
||||
}
|
||||
|
||||
function isoDateOf(d: Date) {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addWeeksToException(fromISO: string, toISO: string, dest: string[]) {
|
||||
const from = new SvelteDate(fromISO + 'T00:00:00');
|
||||
const to = new SvelteDate(toISO + 'T00:00:00');
|
||||
const first = new SvelteDate(from);
|
||||
const day = first.getDay();
|
||||
const daysToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
// Set to the Monday of the current week
|
||||
first.setDate(first.getDate() + daysToMonday);
|
||||
|
||||
// Add all Mondays in the range
|
||||
for (let d = new SvelteDate(first); d <= to; d.setDate(d.getDate() + 7)) {
|
||||
dest.push(isoDateOf(new SvelteDate(d)));
|
||||
}
|
||||
}
|
||||
|
||||
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
|
||||
function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
|
||||
// Special case for 12:00
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
// =============== API Functions ===============
|
||||
async function fetchExceptionGroups() {
|
||||
exceptionGroupsLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/scheduling/exceptional-groups', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data === null || data.length === 0) {
|
||||
return;
|
||||
}
|
||||
exceptionGroups = data.map((group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
weekStarts: group.weekStarts || [],
|
||||
hours:
|
||||
group.hours?.map((h) => ({
|
||||
id: h.id,
|
||||
weekday: h.weekday,
|
||||
start_time: formatTime(h.startTime),
|
||||
end_time: formatTime(h.endTime),
|
||||
is_open: h.isOpen
|
||||
})) || []
|
||||
}));
|
||||
} else {
|
||||
console.error('Failed to fetch exception groups:', response.status);
|
||||
toast.error('Failed to load exception groups');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching exception groups:', err);
|
||||
toast.error('Network error loading exception groups');
|
||||
} finally {
|
||||
exceptionGroupsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExceptionGroup() {
|
||||
// Validate
|
||||
if (!exceptionDraft.name.trim()) {
|
||||
toast.error('Please enter a group name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (exceptionDraft.weekStarts.length === 0) {
|
||||
toast.error('Please add at least one week');
|
||||
return;
|
||||
}
|
||||
|
||||
savingHours = true;
|
||||
const loadingToast = toast.loading('Creating exception group...');
|
||||
|
||||
try {
|
||||
// Map to API format
|
||||
const payload = {
|
||||
name: exceptionDraft.name,
|
||||
description: exceptionDraft.description,
|
||||
weekStarts: exceptionDraft.weekStarts,
|
||||
hours: exceptionDraft.hours.map((h) => ({
|
||||
weekday: h.weekday,
|
||||
startTime: h.start_time,
|
||||
endTime: h.end_time,
|
||||
isOpen: h.is_open
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await fetch('/api/scheduling/exceptional-groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Exception group created successfully!', { id: loadingToast });
|
||||
showExceptionModal = false;
|
||||
resetExceptionForm();
|
||||
await fetchExceptionGroups();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to create: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating exception group:', err);
|
||||
toast.error('Network error creating exception group', { id: loadingToast });
|
||||
} finally {
|
||||
savingHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteExceptionGroup() {
|
||||
if (exceptionToDelete === undefined) return;
|
||||
|
||||
const loadingToast = toast.loading('Deleting exception group...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scheduling/exceptional-groups?id=${exceptionToDelete}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 204) {
|
||||
toast.success('Exception group deleted successfully!', { id: loadingToast });
|
||||
showDeleteExceptionAlert = false;
|
||||
exceptionToDelete = undefined;
|
||||
// Refresh the exception groups list
|
||||
await fetchExceptionGroups();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to delete: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting exception group:', err);
|
||||
toast.error('Network error deleting exception group', { id: loadingToast });
|
||||
}
|
||||
}
|
||||
|
||||
// =============== UI Actions ===============
|
||||
function resetExceptionForm() {
|
||||
exceptionDraft = {
|
||||
name: '',
|
||||
description: '',
|
||||
weekStarts: [],
|
||||
hours: [
|
||||
{ weekday: 0, start_time: '00:00', end_time: '00:00', is_open: false },
|
||||
{ weekday: 1, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 2, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 3, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 4, start_time: '12:00', end_time: '20:00', is_open: true },
|
||||
{ weekday: 5, start_time: '09:00', end_time: '17:00', is_open: true },
|
||||
{ weekday: 6, start_time: '00:00', end_time: '00:00', is_open: false }
|
||||
]
|
||||
};
|
||||
weekRangeFrom = '';
|
||||
weekRangeTo = '';
|
||||
}
|
||||
|
||||
function createNewException() {
|
||||
resetExceptionForm();
|
||||
showExceptionModal = true;
|
||||
}
|
||||
|
||||
function addWeekRange() {
|
||||
if (!weekRangeFrom || !weekRangeTo) {
|
||||
toast.error('Please select both start and end dates');
|
||||
return;
|
||||
}
|
||||
|
||||
addWeeksToException(weekRangeFrom, weekRangeTo, exceptionDraft.weekStarts);
|
||||
weekRangeFrom = '';
|
||||
weekRangeTo = '';
|
||||
}
|
||||
|
||||
function removeWeek(index: number) {
|
||||
exceptionDraft.weekStarts = exceptionDraft.weekStarts.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function openViewExceptionModal(exception: ExceptionGroup) {
|
||||
viewingException = exception;
|
||||
showViewExceptionModal = true;
|
||||
}
|
||||
|
||||
// =============== Effects ===============
|
||||
$effect(() => {
|
||||
fetchExceptionGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Holiday Hours</Card.Title>
|
||||
<Card.Description>
|
||||
Manage temporary schedules for holidays, closures, and special events.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button variant="default" onclick={createNewException}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="space-y-4">
|
||||
{#if exceptionGroupsLoading}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each Array(2) as _, i (i)}
|
||||
<Skeleton class="h-32 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#if exceptionGroups.length === 0}
|
||||
<p class="col-span-2 text-sm text-gray-500">No exception groups found.</p>
|
||||
{/if}
|
||||
|
||||
{#each exceptionGroups as g (g.weekStarts)}
|
||||
<div class="group relative h-full rounded-lg border p-4 transition-all">
|
||||
<div class="flex h-full flex-col gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="rounded-lg bg-gray-50 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="font-semibold text-gray-900">{g.name}</h3>
|
||||
</div>
|
||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||
{g.weekStarts?.length || 0} weeks
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600">{g.description}</p>
|
||||
|
||||
<div class="rounded-lg bg-gray-50 p-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">Applies to weeks:</div>
|
||||
<div class="text-xs text-gray-700">
|
||||
{g.weekStarts
|
||||
?.slice(0, 3)
|
||||
.map((w) =>
|
||||
new SvelteDate(w).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})
|
||||
)
|
||||
.join(', ')}
|
||||
{#if (g.weekStarts?.length ?? 0) > 3}
|
||||
<span class="text-gray-500"> (+{(g.weekStarts?.length ?? 0) - 3} more)</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 border-t pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => openViewExceptionModal(g)}
|
||||
class="flex-1"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-1 h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
View Details
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
exceptionToDelete = g.id;
|
||||
showDeleteExceptionAlert = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Exception Group Modal -->
|
||||
<Modal.Root bind:open={showExceptionModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Create Exception Schedule</Modal.Title>
|
||||
<Modal.Description>
|
||||
Define custom working hours for holidays, closures, or special events.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="exception-name" class="text-sm font-medium">Schedule Name *</label>
|
||||
<Input
|
||||
id="exception-name"
|
||||
type="text"
|
||||
placeholder="e.g., Christmas Week, Summer Holiday"
|
||||
bind:value={exceptionDraft.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="exception-description" class="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
id="exception-description"
|
||||
type="text"
|
||||
placeholder="Brief description of the service, will be shown to customers"
|
||||
bind:value={exceptionDraft.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Week Selection -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="mb-2 text-sm font-medium">Apply to Weeks *</h3>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
Select a date range to add all Mondays within that range
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label for="week-from" class="text-xs text-gray-600">From Date</label>
|
||||
<Input id="week-from" type="date" bind:value={weekRangeFrom} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="week-to" class="text-xs text-gray-600">To Date</label>
|
||||
<Input id="week-to" type="date" bind:value={weekRangeTo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onclick={addWeekRange} class="mt-3">
|
||||
Add Week Range
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if exceptionDraft.weekStarts.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs text-gray-600">
|
||||
Selected weeks ({exceptionDraft.weekStarts.length}):
|
||||
</div>
|
||||
<div class="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||
{#each exceptionDraft.weekStarts as week, index (week)}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>Week starting: {week}</span>
|
||||
<button
|
||||
class="text-xs text-red-500 hover:text-red-700"
|
||||
onclick={() => removeWeek(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Working Hours -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">Working Hours for these Weeks *</h3>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full table-auto text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500">
|
||||
<th class="py-2">Day</th>
|
||||
<th class="py-2">Open</th>
|
||||
<th class="py-2">Start</th>
|
||||
<th class="py-2">End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each exceptionDraft.hours as row (row.weekday)}
|
||||
<tr class="border-t">
|
||||
<td class="py-2">{weekdayLabel(row.weekday)}</td>
|
||||
<td class="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={row.is_open}
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.start_time}
|
||||
disabled={!row.is_open}
|
||||
class="w-24 text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.end_time}
|
||||
disabled={!row.is_open}
|
||||
class="w-24 text-sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showExceptionModal = false;
|
||||
resetExceptionForm();
|
||||
}}
|
||||
disabled={savingHours}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={saveExceptionGroup} disabled={savingHours}>
|
||||
{savingHours ? 'Creating…' : 'Create Schedule'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Delete Exception Confirmation -->
|
||||
<AlertDialog.Root bind:open={showDeleteExceptionAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Delete exception group?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
This action cannot be undone. This will permanently delete this exception group and all its
|
||||
associated schedule rows.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel
|
||||
onclick={() => {
|
||||
exceptionToDelete = undefined;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmDeleteExceptionGroup}>Delete</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<!-- View Exception Modal -->
|
||||
{#if viewingException}
|
||||
<Modal.Root bind:open={showViewExceptionModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">{viewingException.name}</Modal.Title>
|
||||
<Modal.Description>
|
||||
{viewingException.description || 'Holiday schedule details'}
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Applied Weeks -->
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-sm font-medium">Applied to Weeks</h3>
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto rounded border bg-gray-50 p-3">
|
||||
{#if viewingException.weekStarts && viewingException.weekStarts.length > 0}
|
||||
<div class="grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
{#each viewingException.weekStarts as week (week)}
|
||||
<div class="text-sm">
|
||||
Week of {new SvelteDate(week).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No weeks specified</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Working Hours -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium">Working Hours</h3>
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full table-auto">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-gray-500">
|
||||
<th class="py-2">Day</th>
|
||||
<th class="py-2">Status</th>
|
||||
<th class="py-2">Start</th>
|
||||
<th class="py-2">End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each viewingException.hours as row (row.weekday)}
|
||||
<tr class="border-t">
|
||||
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
|
||||
<td class="py-2">
|
||||
<span
|
||||
class="text-sm font-medium {row.is_open
|
||||
? 'text-emerald-600'
|
||||
: 'text-red-600'}"
|
||||
>
|
||||
{row.is_open ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 text-sm">
|
||||
{row.is_open ? row.start_time : '—'}
|
||||
</td>
|
||||
<td class="py-2 text-sm">
|
||||
{row.is_open ? row.end_time : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (showViewExceptionModal = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import FileDropZone from '$lib/components/ui/file-drop-zone.svelte';
|
||||
|
||||
// =============== Image Upload ===============
|
||||
let uploading = $state(false);
|
||||
let uploadFiles = $state<File[]>([]);
|
||||
let uploadProgress = $state(0);
|
||||
let uploadResults = $state<{ name: string; url?: string; error?: string }[]>([]);
|
||||
|
||||
function handleFilesDropped(files: File[]) {
|
||||
uploadFiles = files;
|
||||
}
|
||||
|
||||
/** Helper: turn any File into a JPEG-encoded Blob. */
|
||||
function toJpegBlob(file: File): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return reject(new Error('2D context not available'));
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
resolve(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** Resize to max 1500px on the *short* side, only scale down, never up. */
|
||||
function resizeShortSide(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
let { width, height } = img;
|
||||
const maxShortSide = 1500;
|
||||
|
||||
// Only resize if image is larger than target
|
||||
const shortSide = Math.min(width, height);
|
||||
if (shortSide > maxShortSide) {
|
||||
if (width < height) {
|
||||
const scale = maxShortSide / width;
|
||||
width = maxShortSide;
|
||||
height = Math.round(height * scale);
|
||||
} else {
|
||||
const scale = maxShortSide / height;
|
||||
height = maxShortSide;
|
||||
width = Math.round(width * scale);
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return reject(new Error('2D context not available'));
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
resolve(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a 250×250 thumbnail (square, center-cropped). */
|
||||
function createThumbnail(blob: Blob): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const thumbSize = 250;
|
||||
const { width, height } = img;
|
||||
|
||||
// Scale up *or* down so that the image covers 250×250
|
||||
const scale = Math.max(thumbSize / width, thumbSize / height);
|
||||
const scaledW = Math.round(width * scale);
|
||||
const scaledH = Math.round(height * scale);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = thumbSize;
|
||||
canvas.height = thumbSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return reject(new Error('2D context not available'));
|
||||
|
||||
// Draw the scaled image, then crop the center 250×250
|
||||
ctx.drawImage(
|
||||
img,
|
||||
(scaledW - thumbSize) / -2, // offset to center
|
||||
(scaledH - thumbSize) / -2,
|
||||
scaledW,
|
||||
scaledH,
|
||||
0,
|
||||
0,
|
||||
thumbSize,
|
||||
thumbSize
|
||||
);
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) return reject(new Error('Canvas toBlob failed'));
|
||||
resolve(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92
|
||||
);
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
const knownTags = [
|
||||
'portfolio',
|
||||
'gel',
|
||||
'acrylic',
|
||||
'french',
|
||||
'ombre',
|
||||
'summer',
|
||||
'wedding',
|
||||
'holiday',
|
||||
'pink',
|
||||
'red',
|
||||
'style:french',
|
||||
'style:minimal',
|
||||
'colour:red',
|
||||
'colour:pink',
|
||||
'season:summer'
|
||||
];
|
||||
|
||||
let tags = $state<string[]>([]);
|
||||
let input = $state('');
|
||||
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = input.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
|
||||
return knownTags
|
||||
.map((t) => t.toLowerCase())
|
||||
.filter((t) => t.startsWith(q) && !tags.includes(t))
|
||||
.slice(0, 6);
|
||||
});
|
||||
|
||||
function handleTagInput(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
|
||||
if (value.includes(',')) {
|
||||
addTag(value);
|
||||
input = '';
|
||||
}
|
||||
}
|
||||
|
||||
function isSemantic(tag: string) {
|
||||
return tag.includes(':');
|
||||
}
|
||||
|
||||
function addTag(raw: string) {
|
||||
raw.split(',').forEach((p) => {
|
||||
const t = p.trim().toLowerCase();
|
||||
if (t && !tags.includes(t)) tags = [...tags, t];
|
||||
});
|
||||
}
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag(input);
|
||||
input = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !input && tags.length) {
|
||||
tags = tags.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
function selectSuggestion(tag: string) {
|
||||
addTag(tag);
|
||||
input = '';
|
||||
}
|
||||
|
||||
function removeTag(tag: string) {
|
||||
tags = tags.filter((t) => t !== tag);
|
||||
}
|
||||
|
||||
/** Core upload function – now processes the images before sending. */
|
||||
async function uploadOneOrMany() {
|
||||
if (!uploadFiles.length) return;
|
||||
uploading = true;
|
||||
uploadResults = [];
|
||||
uploadProgress = 0;
|
||||
|
||||
const startTs = Date.now(); // timestamp of button click
|
||||
|
||||
for (let i = 0; i < uploadFiles.length; i++) {
|
||||
const file = uploadFiles[i];
|
||||
const fd = new FormData();
|
||||
|
||||
try {
|
||||
/* -------- 1. Turn whatever the user gave us into JPEG ------- */
|
||||
const jpegBlob = await toJpegBlob(file);
|
||||
|
||||
/* -------- 2. Create the two processed versions ------------- */
|
||||
const resizedBlob = await resizeShortSide(jpegBlob);
|
||||
const thumbBlob = await createThumbnail(jpegBlob);
|
||||
|
||||
/* -------- 3. Generate filenames -------------------------------- */
|
||||
const ts = startTs - i; // 1 ms decrement per file
|
||||
const baseName = `${ts}.jpg`;
|
||||
const thumbName = `${ts}_thumb.jpg`;
|
||||
|
||||
/* -------- 4. Attach to FormData -------------------------------- */
|
||||
fd.append('file', resizedBlob, baseName); // this will be the "original"
|
||||
fd.append('file', thumbBlob, thumbName); // the thumbnail
|
||||
|
||||
/* -------- 5. Mock the API call --------------------------------- */
|
||||
await new Promise((r) => setTimeout(r, 500)); // Simulate network delay
|
||||
if (file.name.toLowerCase().includes('fail')) {
|
||||
uploadResults.push({
|
||||
name: file.name,
|
||||
error: 'Mocked API error'
|
||||
});
|
||||
} else {
|
||||
// In a real app you would `await fetch('/api/upload', {method:'POST', body:fd})`
|
||||
uploadResults.push({
|
||||
name: file.name,
|
||||
url: `/images/${baseName}` // pretend this is the returned URL
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
uploadResults.push({
|
||||
name: file.name,
|
||||
error: err instanceof Error ? err.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
|
||||
uploadProgress = Math.round(((i + 1) / uploadFiles.length) * 100);
|
||||
}
|
||||
|
||||
uploading = false;
|
||||
uploadFiles = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Image Upload</Card.Title>
|
||||
<Card.Description>Upload images for the portfolio or other uses.</Card.Description>
|
||||
</div>
|
||||
<div class="hidden rounded-lg p-2 md:block">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<FileDropZone onfiles={handleFilesDropped} accept="image/*" multiple>
|
||||
<div class="p-6 text-center">
|
||||
<p class="text-sm text-gray-500">Drop files here, or click to open the file picker</p>
|
||||
</div>
|
||||
</FileDropZone>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="text-sm text-gray-600">Selected files ({uploadFiles.length})</div>
|
||||
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
|
||||
{#each uploadFiles as f (f.name)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div>{f.name} • {Math.round(f.size / 1024)}KB</div>
|
||||
<button
|
||||
class="text-red-500"
|
||||
onclick={() => (uploadFiles = uploadFiles.filter((x) => x !== f))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if uploadResults.length > 0}
|
||||
<div class="mt-4 text-sm text-gray-600">Upload Results</div>
|
||||
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto">
|
||||
{#each uploadResults as result (result.url || result.name)}
|
||||
<div
|
||||
class="rounded p-2 text-xs {result.error
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-emerald-100 text-emerald-800'}"
|
||||
>
|
||||
{result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700">Tags</label>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex min-h-[38px] w-full flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:outline-none"
|
||||
>
|
||||
{#each tags as tag (tag)}
|
||||
<span
|
||||
class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs
|
||||
{isSemantic(tag) ? 'bg-indigo-100 text-indigo-800' : 'bg-emerald-100 text-emerald-800'}"
|
||||
>
|
||||
{tag}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 leading-none
|
||||
{isSemantic(tag)
|
||||
? 'text-indigo-700 hover:text-indigo-900'
|
||||
: 'text-emerald-700 hover:text-emerald-900'}"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
removeTag(tag);
|
||||
}}
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
<input
|
||||
class="min-w-[120px] flex-1 border-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
bind:value={input}
|
||||
onkeydown={handleKey}
|
||||
oninput={handleTagInput}
|
||||
placeholder={tags.length ? '' : 'Add tags…'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if input.length && suggestions.length}
|
||||
<div class="absolute right-0 left-0 z-10 mt-1 rounded-md border bg-white shadow">
|
||||
{#each suggestions as s (s)}
|
||||
<div
|
||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
||||
onmousedown={(e) => {
|
||||
e.preventDefault();
|
||||
selectSuggestion(s);
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
Add searchable tags here such as <code>`scooby doo`</code> or filterable categories like
|
||||
<code>`style:french`</code>
|
||||
or
|
||||
<code>`colour:green`</code>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button onclick={uploadOneOrMany} disabled={!uploadFiles.length || uploading}>
|
||||
{uploading ? `Uploading (${uploadProgress}%)` : 'Upload Selected Files'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,679 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// =============== Types ===============
|
||||
type Service = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
duration_minutes: number;
|
||||
is_active: boolean;
|
||||
patch_test_duration_hours: number;
|
||||
minimum_age_required: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
};
|
||||
|
||||
// =============== State ===============
|
||||
let services = $state<Service[]>([]);
|
||||
let servicesLoading = $state(true);
|
||||
let servicesUpdating = $state<Record<string, boolean>>({});
|
||||
|
||||
// Service Creation State
|
||||
let showServiceModal = $state(false);
|
||||
let creatingService = $state(false);
|
||||
let newService = $state({
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: 60,
|
||||
patch_test_duration_hours: 0,
|
||||
minimum_age_required: 0
|
||||
});
|
||||
|
||||
let serviceErrors = $state({
|
||||
name: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
patch_test_duration_hours: '',
|
||||
minimum_age_required: ''
|
||||
});
|
||||
|
||||
// =============== Validation ===============
|
||||
let isFormValid = $derived(
|
||||
newService.name.trim() !== '' &&
|
||||
/^\d+(\.\d{1,2})?$/.test(newService.price) &&
|
||||
parseFloat(newService.price) > 0 &&
|
||||
Number.isInteger(newService.duration_minutes) &&
|
||||
newService.duration_minutes > 0 &&
|
||||
Number.isInteger(newService.patch_test_duration_hours) &&
|
||||
newService.patch_test_duration_hours >= 0 &&
|
||||
Number.isInteger(newService.minimum_age_required) &&
|
||||
newService.minimum_age_required >= 0 &&
|
||||
newService.minimum_age_required <= 100
|
||||
);
|
||||
|
||||
function validatePrice(price: string): string {
|
||||
const validFormat = /^\d*\.?\d*$/.test(price);
|
||||
if (!validFormat) {
|
||||
return 'Price must be a valid number (e.g., 4.50)';
|
||||
}
|
||||
|
||||
const numPrice = parseFloat(price);
|
||||
if (isNaN(numPrice)) {
|
||||
return 'Price must be a valid number';
|
||||
}
|
||||
|
||||
if (numPrice <= 0) {
|
||||
return 'Price must be greater than 0';
|
||||
}
|
||||
|
||||
const decimalRegex = /^\d+(\.\d{1,2})?$/;
|
||||
if (!decimalRegex.test(price)) {
|
||||
return 'Price can have up to 2 decimal places';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateDuration(value: number, field: string): string {
|
||||
if (isNaN(value)) {
|
||||
return 'Must be a valid number';
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value)) {
|
||||
return 'Must be a whole number';
|
||||
}
|
||||
|
||||
if (field === 'duration_minutes' && value <= 0) {
|
||||
return 'Duration must be greater than 0';
|
||||
}
|
||||
|
||||
if (field === 'patch_test_duration_hours' && value < 0) {
|
||||
return 'Cannot be negative';
|
||||
}
|
||||
|
||||
if (field === 'minimum_age_required' && (value < 0 || value > 100)) {
|
||||
return 'Must be between 0 and 100';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function validateName(name: string): string {
|
||||
if (!name.trim()) {
|
||||
return 'Service name is required';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function updateAllErrors() {
|
||||
serviceErrors = {
|
||||
name: validateName(newService.name),
|
||||
price: validatePrice(newService.price),
|
||||
duration_minutes: validateDuration(newService.duration_minutes, 'duration_minutes'),
|
||||
patch_test_duration_hours: validateDuration(
|
||||
newService.patch_test_duration_hours,
|
||||
'patch_test_duration_hours'
|
||||
),
|
||||
minimum_age_required: validateDuration(
|
||||
newService.minimum_age_required,
|
||||
'minimum_age_required'
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function validateNameField() {
|
||||
serviceErrors.name = validateName(newService.name);
|
||||
}
|
||||
|
||||
function validatePriceField() {
|
||||
serviceErrors.price = validatePrice(newService.price);
|
||||
}
|
||||
|
||||
function validateDurationField() {
|
||||
serviceErrors.duration_minutes = validateDuration(
|
||||
newService.duration_minutes,
|
||||
'duration_minutes'
|
||||
);
|
||||
}
|
||||
|
||||
function validatePatchTestField() {
|
||||
serviceErrors.patch_test_duration_hours = validateDuration(
|
||||
newService.patch_test_duration_hours,
|
||||
'patch_test_duration_hours'
|
||||
);
|
||||
}
|
||||
|
||||
function validateMinimumAgeField() {
|
||||
serviceErrors.minimum_age_required = validateDuration(
|
||||
newService.minimum_age_required,
|
||||
'minimum_age_required'
|
||||
);
|
||||
}
|
||||
|
||||
// =============== API Functions ===============
|
||||
async function fetchServices() {
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
services = data.filter((s: Service) => s.id);
|
||||
if (data.length !== services.length) {
|
||||
console.warn('Some services missing IDs were filtered out');
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch services:', response.status);
|
||||
toast.error('Failed to load services');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching services:', err);
|
||||
toast.error('Network error loading services');
|
||||
} finally {
|
||||
servicesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleService(serviceId: string) {
|
||||
servicesUpdating[serviceId] = true;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/services/${serviceId}/toggle`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Service status updated');
|
||||
await fetchServices();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to update service: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling service:', err);
|
||||
toast.error('Network error updating service');
|
||||
} finally {
|
||||
servicesUpdating[serviceId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteService(serviceId: string) {
|
||||
if (!confirm('Are you sure you want to delete this service? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
servicesUpdating[serviceId] = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/services/${serviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Service deleted successfully');
|
||||
await fetchServices();
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to delete service: ${errorText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting service:', err);
|
||||
toast.error('Network error deleting service');
|
||||
} finally {
|
||||
servicesUpdating[serviceId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createService() {
|
||||
updateAllErrors();
|
||||
|
||||
const hasErrors = Object.values(serviceErrors).some((error) => error !== '');
|
||||
if (hasErrors) {
|
||||
toast.error('Please fix the validation errors before submitting');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isFormValid) {
|
||||
toast.error('Form validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
creatingService = true;
|
||||
const loadingToast = toast.loading('Creating service...');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: newService.name.trim(),
|
||||
description: newService.description.trim() || undefined,
|
||||
price: parseFloat(newService.price),
|
||||
duration_minutes: newService.duration_minutes,
|
||||
patch_test_duration_hours: newService.patch_test_duration_hours,
|
||||
minimum_age_required: newService.minimum_age_required
|
||||
};
|
||||
|
||||
const response = await fetch('/api/admin/services', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await response.json();
|
||||
toast.success('Service created successfully!', { id: loadingToast });
|
||||
|
||||
resetServiceForm();
|
||||
showServiceModal = false;
|
||||
|
||||
await fetchServices();
|
||||
} else if (response.status === 409) {
|
||||
toast.error('A service with this name already exists', { id: loadingToast });
|
||||
} else if (response.status === 400) {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Validation error: ${errorText}`, { id: loadingToast });
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
toast.error(`Failed to create service: ${errorText}`, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating service:', err);
|
||||
toast.error('Network error creating service', { id: loadingToast });
|
||||
} finally {
|
||||
creatingService = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetServiceForm() {
|
||||
newService = {
|
||||
name: '',
|
||||
description: '',
|
||||
price: '',
|
||||
duration_minutes: 60,
|
||||
patch_test_duration_hours: 0,
|
||||
minimum_age_required: 0
|
||||
};
|
||||
serviceErrors = {
|
||||
name: '',
|
||||
price: '',
|
||||
duration_minutes: '',
|
||||
patch_test_duration_hours: '',
|
||||
minimum_age_required: ''
|
||||
};
|
||||
}
|
||||
|
||||
function openServiceModal() {
|
||||
resetServiceForm();
|
||||
showServiceModal = true;
|
||||
}
|
||||
|
||||
// =============== Lifecycle ===============
|
||||
$effect(() => {
|
||||
fetchServices();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Card.Title>Services Management</Card.Title>
|
||||
<Card.Description>
|
||||
Manage your services - add, edit, toggle availability, or delete services.
|
||||
</Card.Description>
|
||||
</div>
|
||||
<Button onclick={openServiceModal}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
Add Service
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden w-full overflow-x-auto md:block">
|
||||
<table class="w-full table-auto border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="w-[20%] py-3 font-medium">Name</th>
|
||||
<th class="w-[30%] py-3 font-medium">Description</th>
|
||||
<th class="w-[10%] py-3 text-right font-medium">Price</th>
|
||||
<th class="w-[12%] py-3 text-right font-medium">Duration</th>
|
||||
<th class="w-[12%] py-3 text-center font-medium">Status</th>
|
||||
<th class="w-[16%] py-3 text-center font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if servicesLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<tr class="border-b">
|
||||
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
|
||||
<td class="py-3"><Skeleton class="h-4 w-48" /></td>
|
||||
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-16" /></td>
|
||||
<td class="py-3 text-right"><Skeleton class="ml-auto h-4 w-20" /></td>
|
||||
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-16" /></td>
|
||||
<td class="py-3 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Skeleton class="h-8 w-16" />
|
||||
<Skeleton class="h-8 w-16" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each services as service (service.id)}
|
||||
<tr class="border-b hover:bg-gray-50">
|
||||
<td class="py-3 font-medium">{service.name}</td>
|
||||
<td class="py-3 text-gray-600">
|
||||
{#if service.description}
|
||||
<div class="line-clamp-2" title={service.description}>
|
||||
{service.description}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-3 text-right font-medium">£{service.price.toFixed(2)}</td>
|
||||
<td class="py-3 text-right">{service.duration_minutes} min</td>
|
||||
<td class="py-3 text-center">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{service.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => toggleService(service.id)}
|
||||
disabled={servicesUpdating[service.id]}
|
||||
>
|
||||
{servicesUpdating[service.id]
|
||||
? '...'
|
||||
: service.is_active
|
||||
? 'Deactivate'
|
||||
: 'Activate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => deleteService(service.id)}
|
||||
disabled={servicesUpdating[service.id]}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="space-y-4 md:hidden">
|
||||
{#if servicesLoading}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="space-y-3">
|
||||
<Skeleton class="h-5 w-32" />
|
||||
<Skeleton class="h-4 w-48" />
|
||||
<div class="flex justify-between">
|
||||
<Skeleton class="h-4 w-16" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Skeleton class="h-8 w-16" />
|
||||
<Skeleton class="h-8 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each services as service (service.id)}
|
||||
<div class="rounded-lg border p-4 hover:bg-gray-50">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-medium">{service.name}</h3>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {service.is_active
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{service.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if service.description}
|
||||
<p class="text-sm text-gray-600">{service.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<div>
|
||||
<span class="font-medium">Price:</span> £{service.price.toFixed(2)}
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Duration:</span>
|
||||
{service.duration_minutes} min
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => toggleService(service.id)}
|
||||
disabled={servicesUpdating[service.id]}
|
||||
class="flex-1"
|
||||
>
|
||||
{servicesUpdating[service.id]
|
||||
? '...'
|
||||
: service.is_active
|
||||
? 'Deactivate'
|
||||
: 'Activate'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => deleteService(service.id)}
|
||||
disabled={servicesUpdating[service.id]}
|
||||
class="flex-1"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !servicesLoading && services.length === 0}
|
||||
<div class="py-8 text-center text-gray-500">
|
||||
No services found. Click "Add Service" to create your first service.
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Add Service Modal -->
|
||||
<Modal.Root bind:open={showServiceModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Add New Service</Modal.Title>
|
||||
<Modal.Description>Create a new service that customers can book.</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<!-- Service Name -->
|
||||
<div class="space-y-2">
|
||||
<label for="service-name" class="text-sm font-medium">Service Name *</label>
|
||||
<Input
|
||||
id="service-name"
|
||||
type="text"
|
||||
placeholder="e.g., Haircut, Color, Blowdry"
|
||||
bind:value={newService.name}
|
||||
onblur={validateNameField}
|
||||
class="w-full border-red-500={serviceErrors.name}"
|
||||
/>
|
||||
{#if serviceErrors.name}
|
||||
<p class="text-sm text-red-600">{serviceErrors.name}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="space-y-2">
|
||||
<label for="service-description" class="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
id="service-description"
|
||||
type="text"
|
||||
placeholder="Brief description of the service, will be shown to customers"
|
||||
bind:value={newService.description}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Price and Duration - Side by side on desktop -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Price -->
|
||||
<div class="space-y-2">
|
||||
<label for="service-price" class="text-sm font-medium">Price (£) *</label>
|
||||
<div class="relative">
|
||||
<span class="absolute top-1/2 left-3 -translate-y-1/2 text-sm text-gray-500">£</span>
|
||||
<Input
|
||||
id="service-price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
bind:value={newService.price}
|
||||
onblur={validatePriceField}
|
||||
class="w-full pl-8 border-red-500={serviceErrors.price}"
|
||||
/>
|
||||
</div>
|
||||
{#if serviceErrors.price}
|
||||
<p class="text-sm text-red-600">{serviceErrors.price}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="space-y-2">
|
||||
<label for="service-duration" class="text-sm font-medium">Duration (minutes) *</label>
|
||||
<Input
|
||||
id="service-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="60"
|
||||
bind:value={newService.duration_minutes}
|
||||
onblur={validateDurationField}
|
||||
class="w-full border-red-500={serviceErrors.duration_minutes}"
|
||||
/>
|
||||
{#if serviceErrors.duration_minutes}
|
||||
<p class="text-sm text-red-600">{serviceErrors.duration_minutes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patch Test and Minimum Age - Side by side on desktop -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Patch Test Duration -->
|
||||
<div class="space-y-2">
|
||||
<label for="patch-test-duration" class="text-sm font-medium"
|
||||
>Patch Test Duration (hours)</label
|
||||
>
|
||||
<Input
|
||||
id="patch-test-duration"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
bind:value={newService.patch_test_duration_hours}
|
||||
onblur={validatePatchTestField}
|
||||
class="w-full border-red-500={serviceErrors.patch_test_duration_hours}"
|
||||
/>
|
||||
{#if serviceErrors.patch_test_duration_hours}
|
||||
<p class="text-sm text-red-600">{serviceErrors.patch_test_duration_hours}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500">Hours required before service (0 for none)</p>
|
||||
</div>
|
||||
|
||||
<!-- Minimum Age -->
|
||||
<div class="space-y-2">
|
||||
<label for="minimum-age" class="text-sm font-medium">Minimum Age</label>
|
||||
<Input
|
||||
id="minimum-age"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
placeholder="0"
|
||||
bind:value={newService.minimum_age_required}
|
||||
onblur={validateMinimumAgeField}
|
||||
class="w-full border-red-500={serviceErrors.minimum_age_required}"
|
||||
/>
|
||||
{#if serviceErrors.minimum_age_required}
|
||||
<p class="text-sm text-red-600">{serviceErrors.minimum_age_required}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-500">0 for no age restriction</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showServiceModal = false;
|
||||
resetServiceForm();
|
||||
}}
|
||||
disabled={creatingService}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={createService} disabled={creatingService || !isFormValid}>
|
||||
{creatingService ? 'Creating...' : 'Create Service'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
userId: string;
|
||||
openBookingModal: (bookingId: string) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), userId, openBookingModal }: Props = $props();
|
||||
|
||||
type SocialLogin = {
|
||||
provider: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type AdminUserDetail = {
|
||||
id: string;
|
||||
email?: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
phone?: string;
|
||||
dateOfBirth?: string;
|
||||
profilePicUrl?: string;
|
||||
accountRole: string;
|
||||
accountType: string;
|
||||
loyaltyStamps: number;
|
||||
referralCode: string;
|
||||
referralCodeUses: number;
|
||||
lastLoginAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
notes?: string;
|
||||
privacyPolicyConsent: boolean;
|
||||
policyConsentUpdatedAt?: string;
|
||||
dataRetentionConsent: boolean;
|
||||
dataConsentUpdatedAt?: string;
|
||||
socialLogins?: SocialLogin[];
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'client_cancelled'
|
||||
| 'we_cancelled'
|
||||
| 're-schedule'
|
||||
| 'no_show';
|
||||
services: Array<{
|
||||
service_name?: string;
|
||||
}>;
|
||||
total_amount: number;
|
||||
};
|
||||
|
||||
let selectedUser = $state<AdminUserDetail | null>(null);
|
||||
let bookingUserHistory = $state<Booking[]>([]);
|
||||
let totalBookings = $state(0);
|
||||
let currentBookingPage = $state(1);
|
||||
let totalBookingPages = $state(1);
|
||||
let loadingBookings = $state(false);
|
||||
|
||||
async function fetchUserDetails() {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
selectedUser = await response.json();
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load user details: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user details:', err);
|
||||
toast.error('Network error loading user details');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserBookings(page: number = 1) {
|
||||
if (!userId) return;
|
||||
|
||||
loadingBookings = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: '5'
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
bookingUserHistory = data.bookings || [];
|
||||
totalBookings = data.total || 0;
|
||||
currentBookingPage = data.page || 1;
|
||||
totalBookingPages = data.totalPages || 1;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load user bookings: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching user bookings:', err);
|
||||
toast.error('Network error loading user bookings');
|
||||
} finally {
|
||||
loadingBookings = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nextBookingPage() {
|
||||
if (currentBookingPage < totalBookingPages) {
|
||||
fetchUserBookings(currentBookingPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function previousBookingPage() {
|
||||
if (currentBookingPage > 1) {
|
||||
fetchUserBookings(currentBookingPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && userId) {
|
||||
fetchUserDetails();
|
||||
fetchUserBookings();
|
||||
}
|
||||
});
|
||||
|
||||
function handleOpenBooking(bookingId: string) {
|
||||
open = false;
|
||||
openBookingModal(bookingId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Modal.Title class="text-lg font-semibold">User Details</Modal.Title>
|
||||
{#if selectedUser}
|
||||
<div class="mt-1 text-sm text-gray-500">ID: {selectedUser.id}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedUser}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||
{selectedUser.accountRole === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: selectedUser.accountRole === 'verified_email'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: selectedUser.accountRole === 'unverified_email'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{selectedUser.accountRole}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
{#if selectedUser}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Personal Information -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Personal Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Full Name</div>
|
||||
<div class="font-medium">{selectedUser.fullName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">{selectedUser.email || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Phone</div>
|
||||
<div class="font-medium">{selectedUser.phone || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Date of Birth</div>
|
||||
<div class="font-medium">
|
||||
{selectedUser.dateOfBirth
|
||||
? new SvelteDate(selectedUser.dateOfBirth).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedUser.profilePicUrl}
|
||||
<div class="mt-3">
|
||||
<div class="text-xs text-gray-500">Profile Picture</div>
|
||||
<img
|
||||
src={selectedUser.profilePicUrl}
|
||||
alt="Profile"
|
||||
class="mt-2 h-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Account Information -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Account Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Account Type</div>
|
||||
<div class="font-medium capitalize">{selectedUser.accountType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Account Role</div>
|
||||
<div class="font-medium capitalize">{selectedUser.accountRole.replace('_', ' ')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Created</div>
|
||||
<div class="font-medium">
|
||||
{new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Last Login</div>
|
||||
<div class="font-medium">
|
||||
{selectedUser.lastLoginAt
|
||||
? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0}
|
||||
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<div class="mb-2 text-xs font-semibold text-blue-800">Connected Social Accounts</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each selectedUser.socialLogins as social (social.provider)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{social.provider.charAt(0).toUpperCase() + social.provider.slice(1)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Loyalty & Referrals -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Loyalty & Referrals
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Loyalty Stamps</div>
|
||||
<div class="text-2xl font-bold text-emerald-600">{selectedUser.loyaltyStamps}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Referral Code</div>
|
||||
<div class="font-mono text-sm font-medium">{selectedUser.referralCode}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Referrals Made</div>
|
||||
<div class="text-2xl font-bold text-purple-600">{selectedUser.referralCodeUses}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GDPR Consents -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Privacy & Consent
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Privacy Policy & Terms</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{selectedUser.policyConsentUpdatedAt
|
||||
? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{selectedUser.privacyPolicyConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Data Retention</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{selectedUser.dataConsentUpdatedAt
|
||||
? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{selectedUser.dataRetentionConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff Notes -->
|
||||
{#if selectedUser.notes}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-amber-800 uppercase">
|
||||
Staff Notes
|
||||
</h3>
|
||||
<div class="text-sm text-amber-900">{selectedUser.notes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Booking History -->
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||
Booking History ({totalBookings})
|
||||
</h3>
|
||||
{#if loadingBookings}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="h-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if bookingUserHistory.length === 0}
|
||||
<div class="text-center text-sm text-gray-500">No bookings found</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each bookingUserHistory as booking (booking.id)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(booking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
})()}
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{booking.status === 'confirmed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: booking.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: booking.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: booking.status === 'cancelled' ||
|
||||
booking.status === 'client_cancelled' ||
|
||||
booking.status === 'we_cancelled'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
<span class="text-gray-500">
|
||||
{booking.services.map((s) => s.service_name).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-semibold text-gray-900">
|
||||
£{booking.total_amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handleOpenBooking(booking.id)}>View</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if totalBookingPages > 1}
|
||||
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={previousBookingPage}
|
||||
disabled={currentBookingPage === 1 || loadingBookings}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span class="text-xs text-gray-600">
|
||||
Page {currentBookingPage} of {totalBookingPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={nextBookingPage}
|
||||
disabled={currentBookingPage === totalBookingPages || loadingBookings}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
openUserModal: (userId: string) => void;
|
||||
}
|
||||
|
||||
let { openUserModal }: Props = $props();
|
||||
|
||||
type UserListItem = {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
type UserListResponse = {
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
let userQuery = $state('');
|
||||
let users = $state<UserListItem[]>([]);
|
||||
let totalUsers = $state(0);
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(1);
|
||||
let loadingSearch = $state(false);
|
||||
let initialLoad = $state(true);
|
||||
|
||||
async function fetchUsers(page: number = 1, search: string = '') {
|
||||
loadingSearch = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: '10'
|
||||
});
|
||||
|
||||
if (search.trim()) {
|
||||
params.append('q', search.trim());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data: UserListResponse = await response.json();
|
||||
users = data.users;
|
||||
totalUsers = data.total;
|
||||
currentPage = data.page;
|
||||
totalPages = data.totalPages;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load users: ' + text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching users:', err);
|
||||
toast.error('Network error loading users');
|
||||
} finally {
|
||||
loadingSearch = false;
|
||||
initialLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
function searchUsers() {
|
||||
currentPage = 1;
|
||||
fetchUsers(1, userQuery);
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
fetchUsers(currentPage + 1, userQuery);
|
||||
}
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (currentPage > 1) {
|
||||
fetchUsers(currentPage - 1, userQuery);
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial users on mount
|
||||
$effect(() => {
|
||||
fetchUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class="h-full">
|
||||
<Card.Header>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
Users
|
||||
</Card.Title>
|
||||
<Card.Description>Search and manage user details.</Card.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||
<span class="text-xs font-semibold">{totalUsers}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-4">
|
||||
<div>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
placeholder="Name, email or phone"
|
||||
bind:value={userQuery}
|
||||
onkeyup={(e) => {
|
||||
if ((e as KeyboardEvent).key === 'Enter') searchUsers();
|
||||
}}
|
||||
/>
|
||||
<Button onclick={searchUsers} disabled={loadingSearch}>
|
||||
{loadingSearch ? 'Searching...' : 'Search'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
||||
{#if initialLoad || loadingSearch}
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="rounded bg-gray-50 p-2">
|
||||
<Skeleton class="mb-1 h-4 w-32" />
|
||||
<Skeleton class="h-3 w-48" />
|
||||
</div>
|
||||
{/each}
|
||||
{:else if users.length === 0}
|
||||
<div class="py-8 text-center text-sm text-gray-500">
|
||||
{userQuery ? 'No users found matching your search.' : 'No users found.'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each users as user (user.id)}
|
||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||
<div>
|
||||
<div class="font-medium">{user.fullName}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{user.email || '—'} • {user.phone || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !initialLoad && totalPages > 1}
|
||||
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={previousPage}
|
||||
disabled={currentPage === 1 || loadingSearch}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span class="text-xs text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={nextPage}
|
||||
disabled={currentPage === totalPages || loadingSearch}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,537 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// shadcn-svelte components
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
// =============== Types ===============
|
||||
type WorkingHourRow = {
|
||||
weekday: number;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
is_open: boolean;
|
||||
};
|
||||
|
||||
// =============== State ===============
|
||||
let defaultHours = $state<WorkingHourRow[]>([]);
|
||||
let defaultHoursIsLoading = $state(true);
|
||||
let defaultHoursDraft = $state<WorkingHourRow[]>([]);
|
||||
let showDefaultHoursModal = $state(false);
|
||||
let showSaveDefaultHoursAlert = $state(false);
|
||||
let savingHours = $state(false);
|
||||
|
||||
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
|
||||
// =============== Helper Functions ===============
|
||||
/** Format time from HH:MM:SS to 12-hour format, with "Noon" for 12:00 PM */
|
||||
function formatTime(time: string): string {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
|
||||
// Special case for 12:00
|
||||
if (hours === 12 && minutes === 0) {
|
||||
return 'Noon';
|
||||
} else if (hours === 0 && minutes === 0) {
|
||||
return 'Midnight';
|
||||
}
|
||||
|
||||
const period = hours >= 12 ? 'PM' : 'AM';
|
||||
const displayHours = hours % 12 || 12;
|
||||
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
|
||||
}
|
||||
|
||||
/** Convert formatted time back to HH:MM for input fields */
|
||||
function timeToInputValue(time: string): string {
|
||||
// Handle special cases
|
||||
if (time === 'Noon') return '12:00';
|
||||
if (time === 'Midnight') return '00:00';
|
||||
|
||||
// Parse 12-hour format
|
||||
const match = time.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
||||
if (!match) return time; // Return as-is if not in expected format
|
||||
|
||||
let hours = parseInt(match[1]);
|
||||
const minutes = match[2];
|
||||
const period = match[3].toUpperCase();
|
||||
|
||||
if (period === 'PM' && hours !== 12) hours += 12;
|
||||
if (period === 'AM' && hours === 12) hours = 0;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes}`;
|
||||
}
|
||||
|
||||
function weekdayLabel(i: number): string {
|
||||
return dayNames[i];
|
||||
}
|
||||
|
||||
/** Calculate hours between start and end time */
|
||||
function calculateHours(startTime: string, endTime: string): string {
|
||||
// Convert formatted times to 24-hour format for calculation
|
||||
const start = timeToInputValue(startTime);
|
||||
const end = timeToInputValue(endTime);
|
||||
|
||||
const [startHours, startMinutes] = start.split(':').map(Number);
|
||||
const [endHours, endMinutes] = end.split(':').map(Number);
|
||||
|
||||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||
|
||||
const diffMinutes = endTotalMinutes - startTotalMinutes;
|
||||
const hours = Math.floor(diffMinutes / 60);
|
||||
const minutes = diffMinutes % 60;
|
||||
|
||||
if (minutes === 0) {
|
||||
return `${hours}`;
|
||||
}
|
||||
return `${hours}.${minutes === 30 ? '5' : Math.round((minutes / 60) * 10)}`;
|
||||
}
|
||||
|
||||
// =============== API Functions ===============
|
||||
async function fetchDefaultHours() {
|
||||
if (!browser) return;
|
||||
|
||||
defaultHoursIsLoading = true;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
defaultHours = data.map((hour) => ({
|
||||
weekday: hour.weekday,
|
||||
start_time: formatTime(hour.startTime),
|
||||
end_time: formatTime(hour.endTime),
|
||||
is_open: hour.isOpen
|
||||
}));
|
||||
} else {
|
||||
const text = await response.text();
|
||||
error = 'Failed to load working hours: ' + text;
|
||||
console.error('Error fetching default hours:', text);
|
||||
}
|
||||
} catch (err) {
|
||||
error = 'Network error: ' + (err instanceof Error ? err.message : 'Unknown error');
|
||||
console.error('Error fetching default hours:', err);
|
||||
} finally {
|
||||
if (error) toast.error(error);
|
||||
defaultHoursIsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the modal and creates a deep copy of current hours for editing. */
|
||||
function prepareDefaultHoursEdit() {
|
||||
// Deep copy the current default hours into the draft state
|
||||
defaultHoursDraft = JSON.parse(JSON.stringify(defaultHours));
|
||||
// Convert display format back to input format
|
||||
defaultHoursDraft = defaultHoursDraft.map((row) => ({
|
||||
...row,
|
||||
start_time: timeToInputValue(row.start_time),
|
||||
end_time: timeToInputValue(row.end_time)
|
||||
}));
|
||||
showDefaultHoursModal = true;
|
||||
}
|
||||
|
||||
/** Saves the default hours draft after confirmation. */
|
||||
async function confirmSaveDefaultHours() {
|
||||
savingHours = true;
|
||||
const loadingToast = toast.loading('Saving default hours...');
|
||||
|
||||
try {
|
||||
// Map snake_case to camelCase for API
|
||||
const payload = defaultHoursDraft.map((hour) => ({
|
||||
weekday: hour.weekday,
|
||||
startTime: hour.start_time,
|
||||
endTime: hour.end_time,
|
||||
isOpen: hour.is_open
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/scheduling/default-hours', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update the main state from the draft state if successful
|
||||
defaultHours = JSON.parse(JSON.stringify(defaultHoursDraft));
|
||||
showDefaultHoursModal = false;
|
||||
showSaveDefaultHoursAlert = false;
|
||||
toast.success('Default hours saved successfully!', { id: loadingToast });
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
toast.error('Unauthorized. Please log in again.', { id: loadingToast });
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to save: ' + text, { id: loadingToast });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('save default hours', err);
|
||||
toast.error('Network error saving hours', { id: loadingToast });
|
||||
} finally {
|
||||
savingHours = false;
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Effects ===============
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
fetchDefaultHours();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Default Hours Card -->
|
||||
<Card.Root>
|
||||
{#if !defaultHoursIsLoading}
|
||||
<Card.Content class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Weekly Schedule</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Your standard operating hours for each day of the week
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={prepareDefaultHoursEdit} disabled={savingHours}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
Edit Schedule
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden w-full overflow-x-auto sm:block">
|
||||
<table class="w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="px-4 py-3">Day</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3">Opening Time</th>
|
||||
<th class="px-4 py-3">Closing Time</th>
|
||||
<th class="px-4 py-3 text-right">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each defaultHours as row (row.weekday)}
|
||||
<tr class="transition-colors hover:bg-gray-50">
|
||||
<td class="p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"
|
||||
>{weekdayLabel(row.weekday) === 'Mon'
|
||||
? 'Monday'
|
||||
: weekdayLabel(row.weekday) === 'Tue'
|
||||
? 'Tuesday'
|
||||
: weekdayLabel(row.weekday) === 'Wed'
|
||||
? 'Wednesday'
|
||||
: weekdayLabel(row.weekday) === 'Thu'
|
||||
? 'Thursday'
|
||||
: weekdayLabel(row.weekday) === 'Fri'
|
||||
? 'Friday'
|
||||
: weekdayLabel(row.weekday) === 'Sat'
|
||||
? 'Saturday'
|
||||
: 'Sunday'}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-center">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-xs font-medium {row.is_open
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{row.is_open ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{#if row.is_open}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{row.start_time}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
{#if row.is_open}
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{row.end_time}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
{#if row.is_open}
|
||||
<span class="inline-flex items-center gap-1 text-sm font-medium text-gray-700">
|
||||
{calculateHours(row.start_time, row.end_time)}
|
||||
<span class="text-xs text-gray-500">hrs</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-gray-400">—</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Cards -->
|
||||
<div class="space-y-3 sm:hidden">
|
||||
{#each defaultHours as row (row.weekday)}
|
||||
<div class="rounded-lg border p-4 transition-colors hover:bg-gray-50">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900">
|
||||
{weekdayLabel(row.weekday) === 'Mon'
|
||||
? 'Monday'
|
||||
: weekdayLabel(row.weekday) === 'Tue'
|
||||
? 'Tuesday'
|
||||
: weekdayLabel(row.weekday) === 'Wed'
|
||||
? 'Wednesday'
|
||||
: weekdayLabel(row.weekday) === 'Thu'
|
||||
? 'Thursday'
|
||||
: weekdayLabel(row.weekday) === 'Fri'
|
||||
? 'Friday'
|
||||
: weekdayLabel(row.weekday) === 'Sat'
|
||||
? 'Saturday'
|
||||
: 'Sunday'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium {row.is_open
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
<span
|
||||
class="mr-1.5 h-1.5 w-1.5 rounded-full {row.is_open
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-gray-600'}"
|
||||
></span>
|
||||
{row.is_open ? 'Open' : 'Closed'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if row.is_open}
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Opening</div>
|
||||
<div class="font-medium text-gray-900">{row.start_time}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-gray-500">Closing</div>
|
||||
<div class="font-medium text-gray-900">{row.end_time}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 border-t pt-3 text-xs text-gray-600">
|
||||
Total: <span class="font-medium text-gray-900"
|
||||
>{calculateHours(row.start_time, row.end_time)} hours</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-gray-500">No hours scheduled for this day</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
{:else}
|
||||
<!-- Skeleton loading -->
|
||||
<Card.Content class="space-y-4">
|
||||
<!-- Desktop Skeleton -->
|
||||
<div class="hidden w-full overflow-x-auto md:block">
|
||||
<table class="w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-b bg-gray-50 text-left text-xs font-medium tracking-wider text-gray-600 uppercase"
|
||||
>
|
||||
<th class="px-4 py-3">Day</th>
|
||||
<th class="px-4 py-3 text-center">Status</th>
|
||||
<th class="px-4 py-3">Opening Time</th>
|
||||
<th class="px-4 py-3">Closing Time</th>
|
||||
<th class="px-4 py-3 text-right">Total Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
{#each Array(7) as _, i (i)}
|
||||
<tr>
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 rounded-full" />
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-4 text-center">
|
||||
<Skeleton class="mx-auto h-6 w-16 rounded-full" />
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</td>
|
||||
<td class="px-4 py-4">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
</td>
|
||||
<td class="px-4 py-4 text-right">
|
||||
<Skeleton class="ml-auto h-4 w-12" />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Skeleton -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
{#each Array(7) as _, i (i)}
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-10 w-10 rounded-full" />
|
||||
<Skeleton class="h-5 w-24" />
|
||||
</div>
|
||||
<Skeleton class="h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<Skeleton class="h-12 w-full" />
|
||||
<Skeleton class="h-12 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
<!-- Default Hours Modal -->
|
||||
<Modal.Root bind:open={showDefaultHoursModal}>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-lg">
|
||||
<Modal.Header>
|
||||
<Modal.Title class="text-lg font-semibold">Edit Default Working Hours</Modal.Title>
|
||||
<Modal.Description>
|
||||
Set the standard open and close times for your business.
|
||||
</Modal.Description>
|
||||
</Modal.Header>
|
||||
|
||||
<div class="px-4 pb-4">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full table-auto">
|
||||
<thead>
|
||||
<tr class="border-b text-left text-xs text-gray-500">
|
||||
<th class="py-2">Day</th>
|
||||
<th class="py-2">Open</th>
|
||||
<th class="py-2">Start</th>
|
||||
<th class="py-2">End</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each defaultHoursDraft as row (row.weekday)}
|
||||
<tr class="border-t">
|
||||
<td class="py-2 text-sm">{weekdayLabel(row.weekday)}</td>
|
||||
<td class="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={row.is_open}
|
||||
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-primary focus:ring-primary"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.start_time}
|
||||
disabled={!row.is_open}
|
||||
class="max-w-[70px] text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2">
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={row.end_time}
|
||||
disabled={!row.is_open}
|
||||
class="max-w-[70px] text-sm"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showDefaultHoursModal = false;
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onclick={() => (showSaveDefaultHoursAlert = true)} disabled={savingHours}>
|
||||
Save Defaults
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
|
||||
<!-- Save Default Hours Confirmation -->
|
||||
<AlertDialog.Root bind:open={showSaveDefaultHoursAlert}>
|
||||
<AlertDialog.Content class="z-[60]">
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>Save default hours?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
Are you sure you want to save these default hours? This will affect future bookings.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||
<AlertDialog.Action onclick={confirmSaveDefaultHours}>Continue</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -12,7 +12,8 @@
|
||||
{ href: '/portfolio', label: 'Portfolio', showWhen: 'always', width: 'w-20' },
|
||||
{ href: '/contact', label: 'Contact', showWhen: 'always', width: 'w-16' },
|
||||
{ href: '/account', label: 'My Account', showWhen: 'auth', width: 'w-24' },
|
||||
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' }
|
||||
{ href: '/admin', label: 'Admin Dashboard', showWhen: 'admin', width: 'w-28' },
|
||||
{ href: '/today', label: 'Today', showWhen: 'admin', width: 'w-28' }
|
||||
];
|
||||
|
||||
let mobileMenuOpen: boolean = false;
|
||||
@@ -42,7 +43,7 @@
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="fixed left-0 top-0 z-50 w-full border-b border-gray-200"
|
||||
class="fixed top-0 left-0 z-50 w-full border-b border-gray-200"
|
||||
class:frosty-nav={!mobileMenuOpen}
|
||||
class:bg-background={mobileMenuOpen}
|
||||
>
|
||||
@@ -54,7 +55,7 @@
|
||||
href="https://instagram.com"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="hover:text-primary text-gray-600"
|
||||
class="text-gray-600 hover:text-primary"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg
|
||||
@@ -82,7 +83,7 @@
|
||||
{#if authStore.isLoading}
|
||||
<Skeleton class={`h-4 ${link.width} rounded`} />
|
||||
{:else}
|
||||
<a href={link.href} class="hover:text-primary font-medium text-gray-800"
|
||||
<a href={link.href} class="font-medium text-gray-800 hover:text-primary"
|
||||
>{link.label}</a
|
||||
>
|
||||
{/if}
|
||||
@@ -118,8 +119,8 @@
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{#if mobileMenuOpen}
|
||||
<div class="bg-background border-b border-gray-200 md:hidden">
|
||||
<div class="space-y-1 px-2 pb-3 pt-2">
|
||||
<div class="border-b border-gray-200 bg-background md:hidden">
|
||||
<div class="space-y-1 px-2 pt-2 pb-3">
|
||||
{#each links as link}
|
||||
{#if canShow(link)}
|
||||
{#if authStore.isLoading}
|
||||
@@ -127,7 +128,7 @@
|
||||
{:else}
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
|
||||
class="block rounded px-3 py-2 text-center text-primary hover:text-gray-800"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-25 md:group-hover:saturate-100"
|
||||
class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-75 md:group-hover:saturate-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -620,10 +620,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleBooking() {
|
||||
alert('Booking submitted! (This is just a prototype - no backend connected yet)');
|
||||
}
|
||||
|
||||
const canProceedStep1 = $derived(selectedServices.length > 0);
|
||||
const canProceedStep2 = $derived(selectedDate && selectedTime);
|
||||
const canProceedStep3 = $derived(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
-- Enable pgcrypto for generating random IDs
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
|
||||
-- =======================================
|
||||
-- ENUMS
|
||||
@@ -355,6 +356,24 @@ CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
|
||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||
CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
|
||||
|
||||
create table images (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
r2_url text not null,
|
||||
tag_names text[] not null default '{}', -- for searching and filtering
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- only for autocomlpete text
|
||||
create table tags (
|
||||
id serial primary key,
|
||||
name text not null unique
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
create index idx_images_tag_names on images using gin(tag_names);
|
||||
create index idx_images_created_at on images(created_at desc);
|
||||
create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops);
|
||||
|
||||
-- =======================================
|
||||
-- GDPR COMPLIANCE FUNCTIONS
|
||||
-- =======================================
|
||||
|
||||
Reference in New Issue
Block a user