diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 72d1522..8d505ee 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -170,10 +170,11 @@ 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"` + Bookings []Booking `json:"bookings"` + 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 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 pageStr := query.Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p } - // 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 + if perPageStr := query.Get("per_page"); perPageStr != "" { + if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { + perPage = pp } - 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, + Bookings: bookings, + 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 diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go new file mode 100644 index 0000000..8e2af16 --- /dev/null +++ b/backend/handlers/portfolio/images.go @@ -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) +// } diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 3b84aba..4481534 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -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 + } +} diff --git a/backend/main.go b/backend/main.go index 81efa2d..da3f9f9 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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 --- diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte new file mode 100644 index 0000000..e514ac2 --- /dev/null +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -0,0 +1,437 @@ + + + + + +
+
+ Booking Details + {#if selectedBooking} +
ID: {selectedBooking.id}
+ {/if} +
+ {#if selectedBooking} + + {selectedBooking.status.charAt(0).toUpperCase() + selectedBooking.status.slice(1)} + + {/if} +
+
+ + {#if selectedBooking} +
+ +
+

+ Appointment Details +

+
+
+
Scheduled Date & Time
+
+ {(() => { + 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}`; + })()} +
+
+
+
Duration
+
{selectedBooking.duration_minutes} minutes
+
+
+
Created
+
+ {new SvelteDate(selectedBooking.created_at).toLocaleString()} +
+
+
+
Last Updated
+
+ {new SvelteDate(selectedBooking.updated_at).toLocaleString()} +
+
+ {#if selectedBooking.created_by} +
+
Created By
+
{selectedBooking.created_by}
+
+ {/if} +
+ {#if selectedBooking.notes} +
+
Booking Notes
+
{selectedBooking.notes}
+
+ {/if} +
+ + +
+

+ Customer Information +

+
+
+
Name
+
{selectedBooking.user?.full_name || '—'}
+
+
+
Email
+
{selectedBooking.user?.email || '—'}
+
+
+
Phone
+
{selectedBooking.user?.phone || '—'}
+
+
+
Customer ID
+
{selectedBooking.user?.id || '—'}
+
+ {#if selectedBooking.user?.loyalty_stamps !== undefined && selectedBooking.user?.loyalty_stamps !== null} +
+
Loyalty Stamps
+
{selectedBooking.user.loyalty_stamps}
+
+ {/if} + {#if selectedBooking.user?.referral_code} +
+
Referral Code
+
{selectedBooking.user.referral_code}
+
+ {/if} + {#if selectedBooking.user?.referral_code_uses !== undefined && selectedBooking.user?.referral_code_uses !== null} +
+
Referral Uses
+
{selectedBooking.user.referral_code_uses}
+
+ {/if} +
+ {#if selectedBooking.user?.notes} +
+
Customer Notes
+
{selectedBooking.user.notes}
+
+ {/if} +
+ + + {#if selectedBooking.services && selectedBooking.services.length > 0} +
+

+ Services +

+
+ {#each selectedBooking.services as service, index (index)} +
+
{service.service_name || '—'}
+ {#if service.service_description} +
{service.service_description}
+ {/if} +
+ {service.duration_minutes} min + £{service.price?.toFixed(2) || '0.00'} +
+
+ {/each} +
+
+ {/if} + + +
+

+ Financial Summary +

+
+
+ Total Amount + £{selectedBooking.total_amount.toFixed(2)} +
+
+ Amount Paid + £{selectedBooking.amount_paid.toFixed(2)} +
+
+ Amount Due + + £{selectedBooking.amount_due.toFixed(2)} + +
+
+
+ + + {#if selectedBooking.payments && selectedBooking.payments.length > 0} +
+

+ Payment History +

+
+ {#each selectedBooking.payments as payment (payment.id)} +
+
+
+
+ {payment.payment_method.replace('_', ' ')} + + {payment.status} + +
+
+ {payment.payment_type.charAt(0).toUpperCase() + + payment.payment_type.slice(1)} +
+ {#if payment.vendor_code || payment.invoice_number} +
+ {#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} +
+ {/if} + {#if payment.is_vat_applicable} +
+
Net: £{payment.net_amount?.toFixed(2) || '0.00'}
+
+ VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount?.toFixed( + 2 + ) || '0.00'} +
+
+ {/if} +
+ {new SvelteDate(payment.created_at).toLocaleString()} +
+
+
+ £{payment.amount.toFixed(2)} +
+
+
+ {/each} +
+
+ {/if} +
+ {/if} + + + + +
+
diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte new file mode 100644 index 0000000..845c2bf --- /dev/null +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -0,0 +1,351 @@ + + + + +
+
+ + + + + + + + Bookings + + Search and manage booking history. +
+
+ {bookings.length} +
+
+
+ +
+
+ { + if ((e as KeyboardEvent).key === 'Enter') searchBookings(); + }} + /> + +
+
+ {#if loadingSearch} +
+ +
+ {:else if bookings.length === 0} +
No bookings found.
+ {:else} + {#each bookings as b (b.id)} +
+
+
+ {formatBookingDateTime(b.start_time)} +
+
+ + + {b.status} + + • {b.user?.full_name || 'Unknown User'} + + - {formatServices(b.services)} + +
+
+ +
+ {/each} + {/if} +
+
+
+
diff --git a/frontend/src/lib/components/admin/HolidayHours.svelte b/frontend/src/lib/components/admin/HolidayHours.svelte new file mode 100644 index 0000000..4d63330 --- /dev/null +++ b/frontend/src/lib/components/admin/HolidayHours.svelte @@ -0,0 +1,678 @@ + + + + +
+
+ Holiday Hours + + Manage temporary schedules for holidays, closures, and special events. + +
+ +
+
+ + + {#if exceptionGroupsLoading} +
+ {#each Array(2) as _, i (i)} + + {/each} +
+ {:else} +
+ {#if exceptionGroups.length === 0} +

No exception groups found.

+ {/if} + + {#each exceptionGroups as g (g.weekStarts)} +
+
+
+
+
+
+ + + + + + +
+

{g.name}

+
+ + {g.weekStarts?.length || 0} weeks + +
+ +

{g.description}

+ +
+
Applies to weeks:
+
+ {g.weekStarts + ?.slice(0, 3) + .map((w) => + new SvelteDate(w).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short' + }) + ) + .join(', ')} + {#if (g.weekStarts?.length ?? 0) > 3} + (+{(g.weekStarts?.length ?? 0) - 3} more) + {/if} +
+
+
+ +
+ + +
+
+
+ {/each} +
+ {/if} +
+
+ + + + + + Create Exception Schedule + + Define custom working hours for holidays, closures, or special events. + + + +
+ +
+
+ + +
+ +
+ + +
+
+ + + + +
+
+

Apply to Weeks *

+

+ Select a date range to add all Mondays within that range +

+ +
+
+ + +
+
+ + +
+
+ + +
+ + {#if exceptionDraft.weekStarts.length > 0} +
+
+ Selected weeks ({exceptionDraft.weekStarts.length}): +
+
+ {#each exceptionDraft.weekStarts as week, index (week)} +
+ Week starting: {week} + +
+ {/each} +
+
+ {/if} +
+ + + + +
+

Working Hours for these Weeks *

+
+ + + + + + + + + + + {#each exceptionDraft.hours as row (row.weekday)} + + + + + + + {/each} + +
DayOpenStartEnd
{weekdayLabel(row.weekday)} + + + + + +
+
+
+
+ + + + + +
+
+ + + + + + Delete exception group? + + This action cannot be undone. This will permanently delete this exception group and all its + associated schedule rows. + + + + { + exceptionToDelete = undefined; + }} + > + Cancel + + Delete + + + + + +{#if viewingException} + + + + {viewingException.name} + + {viewingException.description || 'Holiday schedule details'} + + + +
+ +
+

Applied to Weeks

+
+ {#if viewingException.weekStarts && viewingException.weekStarts.length > 0} +
+ {#each viewingException.weekStarts as week (week)} +
+ Week of {new SvelteDate(week).toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric' + })} +
+ {/each} +
+ {:else} +

No weeks specified

+ {/if} +
+
+ + + + +
+

Working Hours

+
+ + + + + + + + + + + {#each viewingException.hours as row (row.weekday)} + + + + + + + {/each} + +
DayStatusStartEnd
{weekdayLabel(row.weekday)} + + {row.is_open ? 'Open' : 'Closed'} + + + {row.is_open ? row.start_time : '—'} + + {row.is_open ? row.end_time : '—'} +
+
+
+
+ + + + +
+
+{/if} diff --git a/frontend/src/lib/components/admin/ImageUpload.svelte b/frontend/src/lib/components/admin/ImageUpload.svelte new file mode 100644 index 0000000..118c227 --- /dev/null +++ b/frontend/src/lib/components/admin/ImageUpload.svelte @@ -0,0 +1,392 @@ + + + + +
+
+ Image Upload + Upload images for the portfolio or other uses. +
+ +
+
+ + +
+

Drop files here, or click to open the file picker

+
+
+ +
+
Selected files ({uploadFiles.length})
+
+ {#each uploadFiles as f (f.name)} +
+
{f.name} • {Math.round(f.size / 1024)}KB
+ +
+ {/each} +
+ {#if uploadResults.length > 0} +
Upload Results
+
+ {#each uploadResults as result (result.url || result.name)} +
+ {result.name}: {result.error ? `Failed: ${result.error}` : `Success: ${result.url}`} +
+ {/each} +
+ {/if} +
+ +
+ + +
+
+ {#each tags as tag (tag)} + + {tag} + + + + {/each} + + +
+ + {#if input.length && suggestions.length} +
+ {#each suggestions as s (s)} +
{ + e.preventDefault(); + selectSuggestion(s); + }} + > + {s} +
+ {/each} +
+ {/if} +
+ +

+ Add searchable tags here such as `scooby doo` or filterable categories like + `style:french` + or + `colour:green` +

+
+
+ +
+
+
diff --git a/frontend/src/lib/components/admin/ServicesManagement.svelte b/frontend/src/lib/components/admin/ServicesManagement.svelte new file mode 100644 index 0000000..ae602ad --- /dev/null +++ b/frontend/src/lib/components/admin/ServicesManagement.svelte @@ -0,0 +1,679 @@ + + + + +
+
+ Services Management + + Manage your services - add, edit, toggle availability, or delete services. + +
+ +
+
+ + + + + + +
+ {#if servicesLoading} + {#each Array(3) as _, i (i)} +
+
+ + +
+ + +
+
+ + +
+
+
+ {/each} + {:else} + {#each services as service (service.id)} +
+
+
+

{service.name}

+ + {service.is_active ? 'Active' : 'Inactive'} + +
+ + {#if service.description} +

{service.description}

+ {/if} + +
+
+ Price: £{service.price.toFixed(2)} +
+
+ Duration: + {service.duration_minutes} min +
+
+ +
+ + +
+
+
+ {/each} + {/if} +
+ + {#if !servicesLoading && services.length === 0} +
+ No services found. Click "Add Service" to create your first service. +
+ {/if} +
+
+ + + + + + Add New Service + Create a new service that customers can book. + + +
+ +
+ + + {#if serviceErrors.name} +

{serviceErrors.name}

+ {/if} +
+ + +
+ + +
+ + +
+ +
+ +
+ £ + +
+ {#if serviceErrors.price} +

{serviceErrors.price}

+ {/if} +
+ + +
+ + + {#if serviceErrors.duration_minutes} +

{serviceErrors.duration_minutes}

+ {/if} +
+
+ + +
+ +
+ + + {#if serviceErrors.patch_test_duration_hours} +

{serviceErrors.patch_test_duration_hours}

+ {/if} +

Hours required before service (0 for none)

+
+ + +
+ + + {#if serviceErrors.minimum_age_required} +

{serviceErrors.minimum_age_required}

+ {/if} +

0 for no age restriction

+
+
+
+ + + + + +
+
diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte new file mode 100644 index 0000000..73e2148 --- /dev/null +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -0,0 +1,456 @@ + + + + + +
+
+ User Details + {#if selectedUser} +
ID: {selectedUser.id}
+ {/if} +
+ {#if selectedUser} + + {selectedUser.accountRole} + + {/if} +
+
+ + {#if selectedUser} +
+ +
+

+ Personal Information +

+
+
+
Full Name
+
{selectedUser.fullName}
+
+
+
Email
+
{selectedUser.email || '—'}
+
+
+
Phone
+
{selectedUser.phone || '—'}
+
+
+
Date of Birth
+
+ {selectedUser.dateOfBirth + ? new SvelteDate(selectedUser.dateOfBirth).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + : '—'} +
+
+
+ {#if selectedUser.profilePicUrl} +
+
Profile Picture
+ Profile +
+ {/if} +
+ + +
+

+ Account Information +

+
+
+
Account Type
+
{selectedUser.accountType}
+
+
+
Account Role
+
{selectedUser.accountRole.replace('_', ' ')}
+
+
+
Created
+
+ {new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +
+
+
+
Last Login
+
+ {selectedUser.lastLoginAt + ? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + : '—'} +
+
+
+ + {#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0} +
+
Connected Social Accounts
+
+ {#each selectedUser.socialLogins as social (social.provider)} + + {social.provider.charAt(0).toUpperCase() + social.provider.slice(1)} + + {/each} +
+
+ {/if} +
+ + +
+

+ Loyalty & Referrals +

+
+
+
Loyalty Stamps
+
{selectedUser.loyaltyStamps}
+
+
+
Referral Code
+
{selectedUser.referralCode}
+
+
+
Referrals Made
+
{selectedUser.referralCodeUses}
+
+
+
+ + +
+

+ Privacy & Consent +

+
+
+
+
Privacy Policy & Terms
+
+ {selectedUser.policyConsentUpdatedAt + ? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` + : ''} +
+
+ + {selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'} + +
+
+
+
Data Retention
+
+ {selectedUser.dataConsentUpdatedAt + ? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}` + : ''} +
+
+ + {selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'} + +
+
+
+ + + {#if selectedUser.notes} +
+

+ Staff Notes +

+
{selectedUser.notes}
+
+ {/if} + + +
+

+ Booking History ({totalBookings}) +

+ {#if loadingBookings} +
+ {#each Array(3) as _, i (i)} +
+ {/each} +
+ {:else if bookingUserHistory.length === 0} +
No bookings found
+ {:else} +
+ {#each bookingUserHistory as booking (booking.id)} +
+
+
+
+ {(() => { + 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}`; + })()} +
+
+ + {booking.status} + + + {booking.services.map((s) => s.service_name).join(', ')} + +
+
+ £{booking.total_amount.toFixed(2)} +
+
+ +
+
+ {/each} +
+ + {#if totalBookingPages > 1} +
+ + + Page {currentBookingPage} of {totalBookingPages} + + +
+ {/if} + {/if} +
+
+ {/if} + + + + +
+
diff --git a/frontend/src/lib/components/admin/UsersCard.svelte b/frontend/src/lib/components/admin/UsersCard.svelte new file mode 100644 index 0000000..bc7386f --- /dev/null +++ b/frontend/src/lib/components/admin/UsersCard.svelte @@ -0,0 +1,196 @@ + + + + +
+
+ + + + + + + + Users + + Search and manage user details. +
+ +
+ {totalUsers} +
+
+
+ +
+
+ { + if ((e as KeyboardEvent).key === 'Enter') searchUsers(); + }} + /> + +
+ +
+ {#if initialLoad || loadingSearch} + {#each Array(3) as _, i (i)} +
+ + +
+ {/each} + {:else if users.length === 0} +
+ {userQuery ? 'No users found matching your search.' : 'No users found.'} +
+ {:else} + {#each users as user (user.id)} +
+
+
{user.fullName}
+
+ {user.email || '—'} • {user.phone || '—'} +
+
+ +
+ {/each} + {/if} +
+ + {#if !initialLoad && totalPages > 1} +
+ + + Page {currentPage} of {totalPages} + + +
+ {/if} +
+
+
diff --git a/frontend/src/lib/components/admin/WeeklySchedule.svelte b/frontend/src/lib/components/admin/WeeklySchedule.svelte new file mode 100644 index 0000000..1724094 --- /dev/null +++ b/frontend/src/lib/components/admin/WeeklySchedule.svelte @@ -0,0 +1,537 @@ + + + + + {#if !defaultHoursIsLoading} + +
+
+

Weekly Schedule

+

+ Your standard operating hours for each day of the week +

+
+ +
+ + + + +
+ {#each defaultHours as row (row.weekday)} +
+
+
+ + {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'} + +
+ + + {row.is_open ? 'Open' : 'Closed'} + +
+ + {#if row.is_open} +
+
+
Opening
+
{row.start_time}
+
+
+
Closing
+
{row.end_time}
+
+
+
+ Total: {calculateHours(row.start_time, row.end_time)} hours +
+ {:else} +
No hours scheduled for this day
+ {/if} +
+ {/each} +
+
+ {:else} + + + + + + +
+ {#each Array(7) as _, i (i)} +
+
+
+ + +
+ +
+
+ + +
+
+ {/each} +
+
+ {/if} +
+ + + + + + Edit Default Working Hours + + Set the standard open and close times for your business. + + + +
+
+ + + + + + + + + + + {#each defaultHoursDraft as row (row.weekday)} + + + + + + + {/each} + +
DayOpenStartEnd
{weekdayLabel(row.weekday)} + + + + + +
+
+
+ + + + + +
+
+ + + + + + Save default hours? + + Are you sure you want to save these default hours? This will affect future bookings. + + + + Cancel + Continue + + + diff --git a/frontend/src/lib/components/layout/NavBar.svelte b/frontend/src/lib/components/layout/NavBar.svelte index 28399bc..800001d 100644 --- a/frontend/src/lib/components/layout/NavBar.svelte +++ b/frontend/src/lib/components/layout/NavBar.svelte @@ -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 @@