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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user