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:
2026-02-20 22:17:37 +00:00
parent 5a4cd29b44
commit 7b0259c0db
11 changed files with 454 additions and 76 deletions
@@ -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}` }
}
@@ -68,14 +68,29 @@
// State
let bookings = $state<Booking[]>([]);
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();
});
</script>
<Card.Root class="h-full">
@@ -294,6 +282,10 @@
</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">{totalBookings}</span>
</div>
</div>
</Card.Header>
<Card.Content class="space-y-4">
@@ -306,7 +298,7 @@
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
}}
/>
<Button onclick={searchBookings} disabled={loadingSearch}>
<Button onclick={() => searchBookings()} disabled={loadingSearch}>
{loadingSearch ? 'Searching...' : 'Search'}
</Button>
</div>
@@ -340,6 +332,30 @@
{/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,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 * 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}
</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 -->
<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">
@@ -454,3 +502,14 @@
</Modal.Footer>
</Modal.Content>
</Modal.Root>
{#if selectedUser}
<PatchTestModal
bind:open={showPatchTestModal}
userId={selectedUser.id}
userName={selectedUser.fullName}
onPatchTestAdded={() => {
fetchUserDetails();
}}
/>
{/if}
@@ -41,7 +41,7 @@
try {
const params = new URLSearchParams({
page: page.toString(),
per_page: '10'
per_page: '4'
});
if (search.trim()) {
@@ -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}` }
}