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)
This commit is contained in:
@@ -512,6 +512,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Get bookings
|
||||||
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -590,10 +599,11 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := BookingListResponse{
|
response := BookingListResponse{
|
||||||
Bookings: bookings,
|
Bookings: bookings,
|
||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
PerPage: req.PerPage,
|
PerPage: req.PerPage,
|
||||||
Total: total,
|
Total: total,
|
||||||
|
TotalPages: totalPages,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1028,6 +1038,15 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate total pages
|
||||||
|
var totalPages int
|
||||||
|
if perPage > 0 {
|
||||||
|
totalPages = (total + perPage - 1) / perPage
|
||||||
|
}
|
||||||
|
if totalPages == 0 {
|
||||||
|
totalPages = 1
|
||||||
|
}
|
||||||
|
|
||||||
// Get bookings
|
// Get bookings
|
||||||
rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset)
|
rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1109,10 +1128,11 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
response := BookingListResponse{
|
response := BookingListResponse{
|
||||||
Bookings: bookings,
|
Bookings: bookings,
|
||||||
Page: page,
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
Total: total,
|
Total: total,
|
||||||
|
TotalPages: totalPages,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -1379,6 +1399,36 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Return updated booking
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -553,3 +553,108 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ func main() {
|
|||||||
r.Route("/admin/users", func(r chi.Router) {
|
r.Route("/admin/users", func(r chi.Router) {
|
||||||
r.Get("/", user.ListAdminUsersHandler)
|
r.Get("/", user.ListAdminUsersHandler)
|
||||||
r.Get("/{id}", user.GetAdminUserHandler)
|
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) {
|
r.Route("/admin/today", func(r chi.Router) {
|
||||||
|
|||||||
@@ -247,7 +247,7 @@
|
|||||||
loadingUsers = true;
|
loadingUsers = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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}` }
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,14 +68,29 @@
|
|||||||
|
|
||||||
// State
|
// State
|
||||||
let bookings = $state<Booking[]>([]);
|
let bookings = $state<Booking[]>([]);
|
||||||
|
let totalBookings = $state(0);
|
||||||
let bookingQuery = $state('');
|
let bookingQuery = $state('');
|
||||||
let loadingSearch = $state(false);
|
let loadingSearch = $state(false);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(1);
|
||||||
|
let initialLoad = $state(true);
|
||||||
|
|
||||||
// Fetch bookings from API
|
// Fetch bookings from API
|
||||||
async function fetchBookings() {
|
async function fetchBookings(page: number = 1, search: string = '') {
|
||||||
loadingSearch = true;
|
loadingSearch = true;
|
||||||
try {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -84,9 +99,14 @@
|
|||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.bookings && data.bookings.length === 0) {
|
if (data.bookings && data.bookings.length === 0) {
|
||||||
bookings = [];
|
bookings = [];
|
||||||
|
totalBookings = 0;
|
||||||
|
totalPages = 1;
|
||||||
|
currentPage = 1;
|
||||||
|
loadingSearch = false;
|
||||||
|
initialLoad = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +130,9 @@
|
|||||||
amount_due: b.amount_due || 0,
|
amount_due: b.amount_due || 0,
|
||||||
duration_minutes: b.duration_minutes || 0
|
duration_minutes: b.duration_minutes || 0
|
||||||
}));
|
}));
|
||||||
|
totalBookings = data.total || 0;
|
||||||
|
totalPages = data.totalPages || 1;
|
||||||
|
currentPage = data.page || 1;
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to load bookings: ' + text);
|
toast.error('Failed to load bookings: ' + text);
|
||||||
@@ -119,62 +142,32 @@
|
|||||||
toast.error('Network error loading bookings');
|
toast.error('Network error loading bookings');
|
||||||
} finally {
|
} finally {
|
||||||
loadingSearch = false;
|
loadingSearch = false;
|
||||||
|
initialLoad = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search bookings via API
|
function searchBookings() {
|
||||||
async function searchBookings() {
|
currentPage = 1;
|
||||||
loadingSearch = true;
|
fetchBookings(1, bookingQuery);
|
||||||
|
}
|
||||||
|
|
||||||
if (!bookingQuery.trim()) {
|
function nextPage() {
|
||||||
await fetchBookings();
|
if (currentPage < totalPages) {
|
||||||
loadingSearch = false;
|
fetchBookings(currentPage + 1, bookingQuery);
|
||||||
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 previousPage() {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
fetchBookings(currentPage - 1, bookingQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial bookings on mount
|
||||||
|
$effect(() => {
|
||||||
|
fetchBookings();
|
||||||
|
});
|
||||||
|
|
||||||
// Format booking date/time
|
// Format booking date/time
|
||||||
function formatBookingDateTime(startTime: string): string {
|
function formatBookingDateTime(startTime: string): string {
|
||||||
const date = new SvelteDate(startTime);
|
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'}`;
|
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch bookings on mount
|
|
||||||
$effect(() => {
|
|
||||||
fetchBookings();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="h-full">
|
<Card.Root class="h-full">
|
||||||
@@ -294,6 +282,10 @@
|
|||||||
</Card.Title>
|
</Card.Title>
|
||||||
<Card.Description>Search and manage booking history.</Card.Description>
|
<Card.Description>Search and manage booking history.</Card.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
||||||
|
<span class="text-xs font-semibold">{totalBookings}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-4">
|
<Card.Content class="space-y-4">
|
||||||
@@ -306,7 +298,7 @@
|
|||||||
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
|
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button onclick={searchBookings} disabled={loadingSearch}>
|
<Button onclick={() => searchBookings()} disabled={loadingSearch}>
|
||||||
{loadingSearch ? 'Searching...' : 'Search'}
|
{loadingSearch ? 'Searching...' : 'Search'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,6 +332,30 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
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;
|
||||||
|
userName: string;
|
||||||
|
onPatchTestAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open = $bindable(), userId, userName, onPatchTestAdded }: Props = $props();
|
||||||
|
|
||||||
|
type Service = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
patchTestDurationHours: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let eligibleServices = $state<Service[]>([]);
|
||||||
|
let selectedServiceId = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
|
||||||
|
async function fetchEligibleServices() {
|
||||||
|
if (!userId) return;
|
||||||
|
loading = true;
|
||||||
|
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) {
|
||||||
|
eligibleServices = await response.json();
|
||||||
|
if (eligibleServices.length > 0) {
|
||||||
|
selectedServiceId = eligibleServices[0].id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
toast.error('Failed to load services: ' + text);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching eligible services:', err);
|
||||||
|
toast.error('Network error loading services');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addPatchTest() {
|
||||||
|
if (!selectedServiceId) {
|
||||||
|
toast.error('Please select a service');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
const loadingToast = toast.loading('Adding patch test record...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}/patch-tests`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${authStore.currentToken}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
service_id: selectedServiceId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast.success('Patch test record added successfully!', { id: loadingToast });
|
||||||
|
open = false;
|
||||||
|
onPatchTestAdded();
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
toast.error(text || 'Failed to add patch test', { id: loadingToast });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding patch test:', err);
|
||||||
|
toast.error('Network error', { id: loadingToast });
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (open && userId) {
|
||||||
|
fetchEligibleServices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal.Root bind:open>
|
||||||
|
<Modal.Content class="sm:max-w-[425px]">
|
||||||
|
<Modal.Header>
|
||||||
|
<Modal.Title>Record Patch Test</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="py-8 text-center text-gray-500">Loading available services...</div>
|
||||||
|
{:else if eligibleServices.length === 0}
|
||||||
|
<div class="py-8 text-center text-gray-500">
|
||||||
|
No services require a patch test that {userName} hasn't already completed.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<label for="service-select" class="text-sm font-medium">Select Service</label>
|
||||||
|
<select
|
||||||
|
id="service-select"
|
||||||
|
bind:value={selectedServiceId}
|
||||||
|
class="mt-1 flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#each eligibleServices as service (service.id)}
|
||||||
|
<option value={service.id}>
|
||||||
|
{service.name} ({service.patchTestDurationHours}h)
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Only services requiring a patch test that this user hasn't completed are shown.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (open = false)} disabled={submitting}>Cancel</Button>
|
||||||
|
<Button onclick={addPatchTest} disabled={submitting || eligibleServices.length === 0}>
|
||||||
|
{submitting ? 'Adding...' : 'Add Patch Test'}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import PatchTestModal from './PatchTestModal.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -68,6 +69,9 @@
|
|||||||
let totalBookingPages = $state(1);
|
let totalBookingPages = $state(1);
|
||||||
let loadingBookings = $state(false);
|
let loadingBookings = $state(false);
|
||||||
|
|
||||||
|
let showPatchTestModal = $state(false);
|
||||||
|
let hasEligiblePatchTests = $state(false);
|
||||||
|
|
||||||
async function fetchUserDetails() {
|
async function fetchUserDetails() {
|
||||||
if (!userId) return;
|
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) {
|
async function fetchUserBookings(page: number = 1) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
@@ -99,7 +129,7 @@
|
|||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
per_page: '5'
|
per_page: '4'
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
||||||
@@ -279,6 +309,24 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if hasEligiblePatchTests}
|
||||||
|
<!-- Patch Test Actions -->
|
||||||
|
<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">
|
||||||
|
Patch Tests
|
||||||
|
</h3>
|
||||||
|
<p class="mb-3 text-sm text-gray-600">
|
||||||
|
Record patch test completion to allow this user to book services requiring one.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
|
||||||
|
<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="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||||
|
</svg>
|
||||||
|
Record Patch Test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Loyalty & Referrals -->
|
<!-- Loyalty & Referrals -->
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
@@ -454,3 +502,14 @@
|
|||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal.Content>
|
</Modal.Content>
|
||||||
</Modal.Root>
|
</Modal.Root>
|
||||||
|
|
||||||
|
{#if selectedUser}
|
||||||
|
<PatchTestModal
|
||||||
|
bind:open={showPatchTestModal}
|
||||||
|
userId={selectedUser.id}
|
||||||
|
userName={selectedUser.fullName}
|
||||||
|
onPatchTestAdded={() => {
|
||||||
|
fetchUserDetails();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
page: page.toString(),
|
||||||
per_page: '10'
|
per_page: '4'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
loadingUsers = true;
|
loadingUsers = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
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}` }
|
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,7 +152,8 @@ CREATE TABLE user_service_patch_tests (
|
|||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
service_id CHAR(12) NOT NULL REFERENCES services(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);
|
CREATE INDEX idx_service_patch_tests_userid ON user_service_patch_tests(user_id);
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
#### Admin Dashboard (`/admin`)
|
#### Admin Dashboard (`/admin`)
|
||||||
- [x] Auth guard with role check
|
- [x] Auth guard with role check
|
||||||
- [x] ImageUpload component
|
- [x] ImageUpload component
|
||||||
- [x] UsersCard + UserModal
|
- [x] UsersCard + UserModal + PatchTestModal
|
||||||
- [x] BookingsCard + BookingModal
|
- [x] BookingsCard + BookingModal
|
||||||
- [x] HolidayHours (exceptional hours management)
|
- [x] HolidayHours (exceptional hours management)
|
||||||
- [x] WeeklySchedule (default hours management)
|
- [x] WeeklySchedule (default hours management)
|
||||||
@@ -120,6 +120,7 @@
|
|||||||
- [x] Customer details form (guest or authenticated)
|
- [x] Customer details form (guest or authenticated)
|
||||||
- [x] Auth store with token refresh logic
|
- [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] **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`
|
- [ ] **Customer booking submit** - `submitBooking()` only logs, needs `POST /api/bookings`
|
||||||
- [ ] Payment integration (Square placeholder)
|
- [ ] Payment integration (Square placeholder)
|
||||||
|
|
||||||
@@ -253,6 +254,8 @@ flowchart TD
|
|||||||
| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
|
| POST | `/api/admin/bookings/{id}/cancel` | Cancel booking |
|
||||||
| GET | `/api/admin/users` | List users |
|
| GET | `/api/admin/users` | List users |
|
||||||
| GET | `/api/admin/users/{id}` | Get user details |
|
| 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/current-next` | Current and next appointment |
|
||||||
| GET | `/api/admin/today/appointments` | Today's appointments |
|
| GET | `/api/admin/today/appointments` | Today's appointments |
|
||||||
| GET | `/api/admin/today/pending-approvals` | Pending approval queue |
|
| GET | `/api/admin/today/pending-approvals` | Pending approval queue |
|
||||||
@@ -443,10 +446,11 @@ src/lib/components/
|
|||||||
│ ├── BookingModal.svelte
|
│ ├── BookingModal.svelte
|
||||||
│ ├── BookingsCard.svelte
|
│ ├── BookingsCard.svelte
|
||||||
│ ├── CallInBooking.svelte
|
│ ├── CallInBooking.svelte
|
||||||
│ ├── HolidayHours.svelte
|
│ ├── HolidayHours.svelte
|
||||||
│ ├── ImageUpload.svelte
|
│ ├── ImageUpload.svelte
|
||||||
│ ├── ServicesManagement.svelte
|
│ ├── PatchTestModal.svelte
|
||||||
│ ├── UserModal.svelte
|
│ ├── ServicesManagement.svelte
|
||||||
|
│ ├── UserModal.svelte
|
||||||
│ ├── UsersCard.svelte
|
│ ├── UsersCard.svelte
|
||||||
│ ├── WalkInBooking.svelte
|
│ ├── WalkInBooking.svelte
|
||||||
│ └── WalkInCreateModal.svelte
|
│ └── WalkInCreateModal.svelte
|
||||||
|
|||||||
Reference in New Issue
Block a user