From 7b0259c0db51971895912272746af72c6cdd8e8d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 20 Feb 2026 22:17:37 +0000 Subject: [PATCH] fix(admin): pagination, patch tests, and per-page limits Backend: - Fix GetAllAdminBookingsHandler and SearchAdminBookingsHandler to return totalPages in response - Auto-record patch tests when booking status progresses to "completed" - Add GET/POST /api/admin/users/{id}/patch-tests endpoints Frontend: - BookingsCard: proper pagination with 4 per page, prev/next buttons - UsersCard, BookingCreateModal, WalkInCreateModal: per_page=4 for user search - Add PatchTestModal for manual patch test entry in UserModal - Hide patch test section when user has no eligible services Database: - Add UNIQUE constraint on user_service_patch_tests(user_id, service_id) --- backend/handlers/bookings/bookings.go | 66 +++++++- backend/handlers/user/profile.go | 105 +++++++++++++ backend/main.go | 2 + .../admin/BookingCreateModal.svelte | 2 +- .../lib/components/admin/BookingsCard.svelte | 132 +++++++++------- .../components/admin/PatchTestModal.svelte | 141 ++++++++++++++++++ .../src/lib/components/admin/UserModal.svelte | 61 +++++++- .../src/lib/components/admin/UsersCard.svelte | 2 +- .../components/admin/WalkInCreateModal.svelte | 2 +- init-scripts/init-script.sql | 3 +- obsidian/Crussell/Crussell Nails.md | 14 +- 11 files changed, 454 insertions(+), 76 deletions(-) create mode 100644 frontend/src/lib/components/admin/PatchTestModal.svelte diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 0c64bc7..76a77ea 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -512,6 +512,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { return } + // Calculate total pages + var totalPages int + if req.PerPage > 0 { + totalPages = (total + req.PerPage - 1) / req.PerPage + } + if totalPages == 0 { + totalPages = 1 + } + // Get bookings rows, err := db.DB.Query(r.Context(), baseQuery, args...) if err != nil { @@ -590,10 +599,11 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } response := BookingListResponse{ - Bookings: bookings, - Page: req.Page, - PerPage: req.PerPage, - Total: total, + Bookings: bookings, + Page: req.Page, + PerPage: req.PerPage, + Total: total, + TotalPages: totalPages, } w.Header().Set("Content-Type", "application/json") @@ -1028,6 +1038,15 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { return } + // Calculate total pages + var totalPages int + if perPage > 0 { + totalPages = (total + perPage - 1) / perPage + } + if totalPages == 0 { + totalPages = 1 + } + // Get bookings rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset) if err != nil { @@ -1109,10 +1128,11 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) { } response := BookingListResponse{ - Bookings: bookings, - Page: page, - PerPage: perPage, - Total: total, + Bookings: bookings, + Page: page, + PerPage: perPage, + Total: total, + TotalPages: totalPages, } w.Header().Set("Content-Type", "application/json") @@ -1379,6 +1399,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) { return } + if req.Status == "completed" { + rows, err := db.DB.Query(r.Context(), ` + SELECT bs.service_id, s.patch_test_duration_hours + FROM booking_services bs + JOIN services s ON bs.service_id = s.id + WHERE bs.booking_id = $1 AND s.patch_test_duration_hours > 0 + `, bookingID) + if err != nil { + log.Printf("Failed to fetch services for patch test: %v", err) + } else { + defer rows.Close() + for rows.Next() { + var serviceID string + var patchTestHours int + if err := rows.Scan(&serviceID, &patchTestHours); err != nil { + log.Printf("Failed to scan service: %v", err) + continue + } + _, err := db.DB.Exec(r.Context(), ` + INSERT INTO user_service_patch_tests (user_id, service_id, last_time) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id, service_id) DO UPDATE SET last_time = NOW() + `, booking.User.ID, serviceID) + if err != nil { + log.Printf("Failed to record patch test: %v", err) + } + } + } + } + // Return updated booking w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 01b9806..7cafae3 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -553,3 +553,108 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +type ServiceForPatchTest struct { + ID string `json:"id"` + Name string `json:"name"` + PatchTestDurationHours int `json:"patchTestDurationHours"` +} + +// GET /api/admin/users/{id}/patch-tests/eligible +func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + // Debug: count services with patch test + var totalWithPatchTest int + err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM services WHERE is_active = true AND patch_test_duration_hours > 0`).Scan(&totalWithPatchTest) + if err != nil { + log.Printf("Debug: failed to count patch test services: %v", err) + } + log.Printf("Debug: userID=%s, services with patch_test=%d", userID, totalWithPatchTest) + + rows, err := db.DB.Query(r.Context(), ` + SELECT s.id, s.name, s.patch_test_duration_hours + FROM services s + WHERE s.is_active = true AND s.patch_test_duration_hours > 0 + AND s.id NOT IN ( + SELECT service_id FROM user_service_patch_tests WHERE user_id = $1 + ) + ORDER BY s.name ASC + `, userID) + if err != nil { + log.Printf("Failed to fetch eligible patch test services: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + var services []ServiceForPatchTest + for rows.Next() { + var s ServiceForPatchTest + if err := rows.Scan(&s.ID, &s.Name, &s.PatchTestDurationHours); err != nil { + log.Printf("Failed to scan service: %v", err) + continue + } + services = append(services, s) + } + + if services == nil { + services = []ServiceForPatchTest{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(services) +} + +type AddPatchTestRequest struct { + ServiceID string `json:"service_id"` +} + +// POST /api/admin/users/{id}/patch-tests +func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { + userID := chi.URLParam(r, "id") + if userID == "" { + http.Error(w, "User ID is required", http.StatusBadRequest) + return + } + + var req AddPatchTestRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + if req.ServiceID == "" { + http.Error(w, "service_id is required", http.StatusBadRequest) + return + } + + var patchTestHours int + err := db.DB.QueryRow(r.Context(), `SELECT patch_test_duration_hours FROM services WHERE id = $1 AND is_active = true AND patch_test_duration_hours > 0`, req.ServiceID).Scan(&patchTestHours) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "service not found or does not require patch test", http.StatusBadRequest) + return + } + log.Printf("Failed to verify service: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + + _, err = db.DB.Exec(r.Context(), ` + INSERT INTO user_service_patch_tests (user_id, service_id, last_time) + VALUES ($1, $2, NOW()) + ON CONFLICT (user_id, service_id) DO UPDATE SET last_time = NOW() + `, userID, req.ServiceID) + if err != nil { + log.Printf("Failed to add patch test: %v", err) + http.Error(w, "failed to add patch test", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) +} diff --git a/backend/main.go b/backend/main.go index 9f35720..66a7e0b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -178,6 +178,8 @@ func main() { r.Route("/admin/users", func(r chi.Router) { r.Get("/", user.ListAdminUsersHandler) r.Get("/{id}", user.GetAdminUserHandler) + r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) + r.Post("/{id}/patch-tests", user.AddPatchTestHandler) }) r.Route("/admin/today", func(r chi.Router) { diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 24fa094..c6b3f59 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -247,7 +247,7 @@ loadingUsers = true; try { const response = await fetch( - `/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`, + `/api/admin/users?page=1&per_page=4&q=${encodeURIComponent(userQuery)}`, { headers: { Authorization: `Bearer ${authStore.currentToken}` } } diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index 70ffb32..63e88ae 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -68,14 +68,29 @@ // State let bookings = $state([]); + let totalBookings = $state(0); let bookingQuery = $state(''); let loadingSearch = $state(false); + let currentPage = $state(1); + let totalPages = $state(1); + let initialLoad = $state(true); // Fetch bookings from API - async function fetchBookings() { + async function fetchBookings(page: number = 1, search: string = '') { loadingSearch = true; try { - const response = await fetch('/api/admin/bookings', { + const params = new URLSearchParams({ + page: page.toString(), + per_page: '4' + }); + + let url = '/api/admin/bookings'; + if (search.trim()) { + params.set('q', search.trim()); + url = '/api/admin/bookings/search'; + } + + const response = await fetch(`${url}?${params}`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -84,9 +99,14 @@ }); if (response.ok) { const data = await response.json(); - + if (data.bookings && data.bookings.length === 0) { bookings = []; + totalBookings = 0; + totalPages = 1; + currentPage = 1; + loadingSearch = false; + initialLoad = false; return; } @@ -110,6 +130,9 @@ amount_due: b.amount_due || 0, duration_minutes: b.duration_minutes || 0 })); + totalBookings = data.total || 0; + totalPages = data.totalPages || 1; + currentPage = data.page || 1; } else { const text = await response.text(); toast.error('Failed to load bookings: ' + text); @@ -119,62 +142,32 @@ toast.error('Network error loading bookings'); } finally { loadingSearch = false; + initialLoad = false; } } - // Search bookings via API - async function searchBookings() { - loadingSearch = true; + function searchBookings() { + currentPage = 1; + fetchBookings(1, bookingQuery); + } - if (!bookingQuery.trim()) { - await fetchBookings(); - loadingSearch = false; - return; - } - - try { - const response = await fetch( - `/api/admin/bookings/search?q=${encodeURIComponent(bookingQuery)}`, - { - headers: { - Authorization: `Bearer ${authStore.currentToken}` - } - } - ); - - // HTTP error (400 / 401 / 500 etc) - if (!response.ok) { - const message = await response.text(); - throw new Error(message || `Request failed (${response.status})`); - } - - const data = await response.json(); - const bookingsData = Array.isArray(data.bookings) ? data.bookings : []; - - bookings = bookingsData.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 - })); - } catch (err) { - console.error('Search bookings failed:', err); - - toast.error(err instanceof Error ? err.message : 'Unexpected error searching bookings'); - } finally { - loadingSearch = false; + function nextPage() { + if (currentPage < totalPages) { + fetchBookings(currentPage + 1, bookingQuery); } } + function previousPage() { + if (currentPage > 1) { + fetchBookings(currentPage - 1, bookingQuery); + } + } + + // Load initial bookings on mount + $effect(() => { + fetchBookings(); + }); + // Format booking date/time function formatBookingDateTime(startTime: string): string { const date = new SvelteDate(startTime); @@ -265,11 +258,6 @@ }; return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`; } - - // Fetch bookings on mount - $effect(() => { - fetchBookings(); - }); @@ -294,6 +282,10 @@ Search and manage booking history. + +
+ {totalBookings} +
@@ -306,7 +298,7 @@ if ((e as KeyboardEvent).key === 'Enter') searchBookings(); }} /> - @@ -340,6 +332,30 @@ {/each} {/if} + + {#if !initialLoad && totalPages > 1} +
+ + + Page {currentPage} of {totalPages} + + +
+ {/if}
diff --git a/frontend/src/lib/components/admin/PatchTestModal.svelte b/frontend/src/lib/components/admin/PatchTestModal.svelte new file mode 100644 index 0000000..b798017 --- /dev/null +++ b/frontend/src/lib/components/admin/PatchTestModal.svelte @@ -0,0 +1,141 @@ + + + + + + Record Patch Test + + + {#if loading} +
Loading available services...
+ {:else if eligibleServices.length === 0} +
+ No services require a patch test that {userName} hasn't already completed. +
+ {:else} +
+
+ + +

+ Only services requiring a patch test that this user hasn't completed are shown. +

+
+
+ {/if} + + + + + +
+
diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 73e2148..28f21ba 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -4,6 +4,7 @@ import { toast } from 'svelte-sonner'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; + import PatchTestModal from './PatchTestModal.svelte'; interface Props { open: boolean; @@ -68,6 +69,9 @@ let totalBookingPages = $state(1); let loadingBookings = $state(false); + let showPatchTestModal = $state(false); + let hasEligiblePatchTests = $state(false); + async function fetchUserDetails() { if (!userId) return; @@ -92,6 +96,32 @@ } } + async function fetchEligiblePatchTests() { + if (!userId) return; + try { + const response = await fetch(`/api/admin/users/${userId}/patch-tests/eligible`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + } + }); + if (response.ok) { + const services = await response.json(); + hasEligiblePatchTests = services.length > 0; + } + } catch (err) { + console.error('Error fetching eligible patch tests:', err); + } + } + + $effect(() => { + if (userId) { + fetchUserDetails(); + fetchEligiblePatchTests(); + } + }); + async function fetchUserBookings(page: number = 1) { if (!userId) return; @@ -99,7 +129,7 @@ try { const params = new URLSearchParams({ page: page.toString(), - per_page: '5' + per_page: '4' }); const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, { @@ -279,6 +309,24 @@ {/if} + {#if hasEligiblePatchTests} + +
+

+ Patch Tests +

+

+ Record patch test completion to allow this user to book services requiring one. +

+ +
+ {/if} +

@@ -454,3 +502,14 @@ + +{#if selectedUser} + { + fetchUserDetails(); + }} + /> +{/if} diff --git a/frontend/src/lib/components/admin/UsersCard.svelte b/frontend/src/lib/components/admin/UsersCard.svelte index bc7386f..d9641f3 100644 --- a/frontend/src/lib/components/admin/UsersCard.svelte +++ b/frontend/src/lib/components/admin/UsersCard.svelte @@ -41,7 +41,7 @@ try { const params = new URLSearchParams({ page: page.toString(), - per_page: '10' + per_page: '4' }); if (search.trim()) { diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index b80df1c..66354c7 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -141,7 +141,7 @@ loadingUsers = true; try { const response = await fetch( - `/api/admin/users?page=1&per_page=10&q=${encodeURIComponent(userQuery)}`, + `/api/admin/users?page=1&per_page=4&q=${encodeURIComponent(userQuery)}`, { headers: { Authorization: `Bearer ${authStore.currentToken}` } } diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index d678eac..25f0dcc 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -152,7 +152,8 @@ CREATE TABLE user_service_patch_tests ( id BIGSERIAL PRIMARY KEY, user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, service_id CHAR(12) NOT NULL REFERENCES services(id) ON DELETE CASCADE, - last_time TIMESTAMPTZ NOT NULL + last_time TIMESTAMPTZ NOT NULL, + UNIQUE (user_id, service_id) ); CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id); diff --git a/obsidian/Crussell/Crussell Nails.md b/obsidian/Crussell/Crussell Nails.md index 43fbb50..f1aca00 100644 --- a/obsidian/Crussell/Crussell Nails.md +++ b/obsidian/Crussell/Crussell Nails.md @@ -102,7 +102,7 @@ #### Admin Dashboard (`/admin`) - [x] Auth guard with role check - [x] ImageUpload component -- [x] UsersCard + UserModal +- [x] UsersCard + UserModal + PatchTestModal - [x] BookingsCard + BookingModal - [x] HolidayHours (exceptional hours management) - [x] WeeklySchedule (default hours management) @@ -120,6 +120,7 @@ - [x] Customer details form (guest or authenticated) - [x] Auth store with token refresh logic - [x] **Admin booking flows** - Call-in and walk-in use `/api/services/eligible-for/{user_id}` for user-specific eligibility +- [x] **Manual patch test entry** - Admin can record patch test completion via User Details → Patch Test modal (for 2-minute walk-in patch tests) - [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings` - [ ] Payment integration (Square placeholder) @@ -253,6 +254,8 @@ flowchart TD | POST | `/api/admin/bookings/{id}/cancel` | Cancel booking | | GET | `/api/admin/users` | List users | | GET | `/api/admin/users/{id}` | Get user details | +| GET | `/api/admin/users/{id}/patch-tests/eligible` | Get services requiring patch test that user hasn't completed | +| POST | `/api/admin/users/{id}/patch-tests` | Record patch test completion for user | | GET | `/api/admin/today/current-next` | Current and next appointment | | GET | `/api/admin/today/appointments` | Today's appointments | | GET | `/api/admin/today/pending-approvals` | Pending approval queue | @@ -443,10 +446,11 @@ src/lib/components/ │ ├── BookingModal.svelte │ ├── BookingsCard.svelte │ ├── CallInBooking.svelte -│ ├── HolidayHours.svelte -│ ├── ImageUpload.svelte -│ ├── ServicesManagement.svelte -│ ├── UserModal.svelte + │ ├── HolidayHours.svelte + │ ├── ImageUpload.svelte + │ ├── PatchTestModal.svelte + │ ├── ServicesManagement.svelte + │ ├── UserModal.svelte │ ├── UsersCard.svelte │ ├── WalkInBooking.svelte │ └── WalkInCreateModal.svelte