Gift-card rolling expiry (setting-driven, was dead config): - GetGiftCardExpiryMonths(): single source of truth (business_settings gift_card_expiry_months, fallback 24) shared by payment handlers and the CleanupExpiredGiftCards job (was hardcoded 24). - expiry_date now maintained on ALL 9 gift-card write sites (buy, topup, transfer, redeem, terminal payment, refund credit, till) so the refund-time guard at refunds.go actually fires. Schema default 12->24 + migration note; test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new expired-card-rejected regression test. Frontend SvelteDate purge (docs' stated convention, wide): - All 180+ raw `new SvelteDate(...)` uses across routes/components replaced with parseWallClockDate (backend UTC ISO) or new Date (wall-clock constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal fixed. Zero SvelteDate references remain; svelte-check clean. Strict timezone/DST testing + QA fixes: - 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds), closing-hours GMT vs BST, booking date-window midnight, refund-tier elapsed-time independence, deposit-window UTC-instant, scheduling LondonDateString midnight, today AT TIME ZONE window + UTC round-trip. - today.go summary date labels fixed to London wall-clock (were showing the previous UTC day during BST) + regression test. - pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON offsets depended on deployment TZ, contradicting the documented UTC invariant) + regression test. Registered as a new *Type to avoid a data race on the shared type map (caught by -race). Admin Business Settings (setting now functional => legal floor): - gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/ Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI, with rolling-expiry semantics shown in both display and edit form. - 3 new expiry validation tests; 2 pre-existing message assertions updated. Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0 errors/warnings; production build succeeds.
332 lines
10 KiB
Svelte
332 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { apiFetch } from '$lib/utils/api';
|
|
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
|
import { toast } from 'svelte-sonner';
|
|
import { extractErrorMessage } from '$lib/utils/toast-safe';
|
|
|
|
// shadcn-svelte components
|
|
import { Button } from '$lib/components/ui/button';
|
|
import * as Card from '$lib/components/ui/card';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import type { Booking } from '$lib/types/booking';
|
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
|
|
|
// Props
|
|
const { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props();
|
|
|
|
// State
|
|
let bookings = $state<Booking[]>([]);
|
|
let totalBookings = $state(0);
|
|
let bookingQuery = $state('');
|
|
let loadingSearch = $state(false);
|
|
// Cursor-based pagination: cursors[i] = cursor to use when loading page i+1
|
|
// cursors[0] is always '' (empty cursor = first page)
|
|
let cursors = $state<string[]>(['']);
|
|
let currentPage = $state(1);
|
|
let totalPages = $state(1);
|
|
let nextCursor = $state<string | null>(null);
|
|
let initialLoad = $state(true);
|
|
|
|
// Fetch bookings from API
|
|
async function fetchBookings(pageIdx: number = 0, search: string = '') {
|
|
loadingSearch = true;
|
|
try {
|
|
const params = new SvelteURLSearchParams({
|
|
per_page: '3'
|
|
});
|
|
const cursor = cursors[pageIdx];
|
|
if (cursor) {
|
|
params.set('cursor', cursor);
|
|
}
|
|
|
|
let url = '/api/admin/bookings';
|
|
if (search.trim()) {
|
|
params.set('q', search.trim());
|
|
url = '/api/admin/bookings/search';
|
|
}
|
|
|
|
const response = await apiFetch(`${url}?${params}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (!data.bookings || data.bookings.length === 0) {
|
|
bookings = [];
|
|
totalBookings = 0;
|
|
totalPages = 1;
|
|
currentPage = 1;
|
|
cursors = [''];
|
|
nextCursor = null;
|
|
loadingSearch = false;
|
|
initialLoad = false;
|
|
return;
|
|
}
|
|
|
|
bookings = (data.bookings as Booking[]) || [];
|
|
totalBookings = data.total || 0;
|
|
totalPages = data.totalPages ?? 1;
|
|
currentPage = pageIdx + 1;
|
|
nextCursor = data.next_cursor ?? null;
|
|
|
|
// Pre-store cursor for the next page so we can go forward
|
|
if (nextCursor && cursors.length <= pageIdx + 1) {
|
|
cursors = [...cursors, nextCursor];
|
|
}
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to load bookings: ' + extractErrorMessage(text));
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching bookings:', err);
|
|
toast.error('Network error loading bookings');
|
|
} finally {
|
|
loadingSearch = false;
|
|
initialLoad = false;
|
|
}
|
|
}
|
|
|
|
function searchBookings() {
|
|
cursors = [''];
|
|
nextCursor = null;
|
|
currentPage = 1;
|
|
fetchBookings(0, bookingQuery);
|
|
}
|
|
|
|
function nextPage() {
|
|
if (!nextCursor || currentPage >= totalPages) return;
|
|
// currentPage is 1-indexed; next page index = currentPage
|
|
fetchBookings(currentPage, bookingQuery);
|
|
}
|
|
|
|
function previousPage() {
|
|
if (currentPage <= 1) return;
|
|
// currentPage is 1-indexed; previous page index = currentPage - 2
|
|
fetchBookings(currentPage - 2, bookingQuery);
|
|
}
|
|
|
|
// Load initial bookings on mount (guarded to run once)
|
|
$effect(() => {
|
|
if (initialLoad) {
|
|
fetchBookings(0);
|
|
}
|
|
});
|
|
|
|
// Format booking date/time
|
|
function formatBookingDateTime(startTime: string): string {
|
|
const date = parseWallClockDate(startTime);
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
const bookingDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
const daysDiff = Math.floor((bookingDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
const months = [
|
|
'Jan',
|
|
'Feb',
|
|
'Mar',
|
|
'Apr',
|
|
'May',
|
|
'June',
|
|
'July',
|
|
'Aug',
|
|
'Sept',
|
|
'Oct',
|
|
'Nov',
|
|
'Dec'
|
|
];
|
|
|
|
const day = days[date.getDay()];
|
|
const dateNum = date.getDate();
|
|
const month = months[date.getMonth()];
|
|
const year = date.getFullYear();
|
|
const currentYear = now.getFullYear();
|
|
const hours = date.getHours();
|
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
const ampm = hours >= 12 ? 'pm' : 'am';
|
|
const hour12 = hours % 12 || 12;
|
|
const time = `${hour12}:${minutes}${ampm}`;
|
|
|
|
if (daysDiff === 0) return `Today, ${time}`;
|
|
if (daysDiff === 1) return `Tomorrow, ${time}`;
|
|
if (daysDiff > 1 && daysDiff <= 6) return `${day}, ${time}`;
|
|
if (daysDiff < 0 && daysDiff >= -6) return `Last ${day}, ${time}`;
|
|
|
|
const suffix =
|
|
dateNum === 1 || dateNum === 21 || dateNum === 31
|
|
? 'st'
|
|
: dateNum === 2 || dateNum === 22
|
|
? 'nd'
|
|
: dateNum === 3 || dateNum === 23
|
|
? 'rd'
|
|
: 'th';
|
|
const yearStr = year !== currentYear ? ` ${year}` : '';
|
|
return `${day}, ${dateNum}${suffix} ${month}${yearStr} at ${time}`;
|
|
}
|
|
|
|
// Format services list
|
|
function formatServices(services: Booking['services']): string {
|
|
const serviceNames = services?.map((s) => s.service_name || 'Unknown Service') || [];
|
|
if (serviceNames.length === 0) return 'No services';
|
|
if (serviceNames.length === 1) return serviceNames[0];
|
|
if (serviceNames.length === 2) return serviceNames.join(' and ');
|
|
return `${serviceNames[0]} and ${serviceNames.length - 1} other${serviceNames.length - 1 > 1 ? 's' : ''}`;
|
|
}
|
|
|
|
// Get status badge classes
|
|
function getStatusClasses(status: Booking['status']): string {
|
|
const baseClasses = 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium';
|
|
const statusMap: Record<string, string> = {
|
|
confirmed: 'bg-emerald-100 text-emerald-800',
|
|
pending: 'bg-amber-100 text-amber-800',
|
|
in_progress: 'bg-blue-100 text-blue-800',
|
|
completed: 'bg-green-100 text-green-800',
|
|
client_cancelled: 'bg-red-100 text-red-800',
|
|
we_cancelled: 'bg-rose-100 text-rose-800',
|
|
no_show: 'bg-gray-100 text-gray-800',
|
|
pending_release: 'bg-orange-100 text-orange-800',
|
|
deposit_lapsed: 'bg-yellow-100 text-yellow-800'
|
|
};
|
|
return `${baseClasses} ${statusMap[status] || 'bg-gray-100 text-gray-800'}`;
|
|
}
|
|
|
|
function getStatusDotClasses(status: Booking['status']): string {
|
|
const statusMap: Record<string, string> = {
|
|
confirmed: 'bg-emerald-600',
|
|
pending: 'bg-amber-600',
|
|
in_progress: 'bg-blue-600',
|
|
completed: 'bg-green-600',
|
|
client_cancelled: 'bg-red-600',
|
|
we_cancelled: 'bg-rose-600',
|
|
no_show: 'bg-gray-600',
|
|
pending_release: 'bg-orange-600',
|
|
deposit_lapsed: 'bg-yellow-600'
|
|
};
|
|
return `mr-1 h-1.5 w-1.5 rounded-full ${statusMap[status] || 'bg-gray-600'}`;
|
|
}
|
|
</script>
|
|
|
|
<Card.Root class="h-full">
|
|
<Card.Header>
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<Card.Title class="flex items-center gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
|
<line x1="16" y1="2" x2="16" y2="6" />
|
|
<line x1="8" y1="2" x2="8" y2="6" />
|
|
<line x1="3" y1="10" x2="21" y2="10" />
|
|
</svg>
|
|
Bookings
|
|
</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">
|
|
<div>
|
|
<div class="flex gap-2">
|
|
<Input
|
|
placeholder="Search by customer name, email, phone, or service"
|
|
bind:value={bookingQuery}
|
|
onkeyup={(e) => {
|
|
if ((e as KeyboardEvent).key === 'Enter') searchBookings();
|
|
}}
|
|
/>
|
|
<Button onclick={() => searchBookings()} disabled={loadingSearch}>
|
|
{loadingSearch ? 'Searching...' : 'Search'}
|
|
</Button>
|
|
</div>
|
|
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
|
{#if bookings.length === 0 && !loadingSearch}
|
|
<div class="text-center text-sm text-gray-500">No bookings found.</div>
|
|
{:else if bookings.length > 0}
|
|
<div class="space-y-2 {loadingSearch ? 'opacity-60' : ''}">
|
|
{#each bookings as b (b.id)}
|
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
|
<div class="flex-1">
|
|
<div class="font-medium">
|
|
{formatBookingDateTime(b.start_time)}
|
|
</div>
|
|
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
|
|
<span class={getStatusClasses(b.status)}>
|
|
<span class={getStatusDotClasses(b.status)}></span>
|
|
{b.status}
|
|
</span>
|
|
{#if b.deposit_required}
|
|
{#if b.status === 'pending'}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
|
>
|
|
Will Require Deposit
|
|
</span>
|
|
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
|
{b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
|
|
>
|
|
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
<span
|
|
>• {formatUserName(
|
|
b.user?.full_name || 'Unknown User',
|
|
b.user?.previous_first_name,
|
|
b.user?.previous_last_name
|
|
)}</span
|
|
>
|
|
<span>
|
|
- {formatServices(b.services)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/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>
|