feat(bookings): improve admin booking wizard and user dashboard
Backend: - Enriched GetAllUserBookings response with calculated total_amount, amount_paid, and duration_minutes. - Refactored GetBookingHandler to return a flat booking object matching frontend expectations. - Added account_role to admin user list response and sorted users by booking activity. - Corrected function name oo to AdminCreateBookingForUserHandler. Frontend: - Rebuilt BookingCreateModal into a 4-step wizard supporting guest bookings, service overrides, and real-time availability checks. - Fixed account dashboard logic to correctly identify upcoming vs past bookings and sort unpaid items to the top. - Extracted booking flow into a shared BookingFlow component. - Redirected admin users from home page to /today.
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
let { open = $bindable(), bookingId }: Props = $props();
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
services: Array<{
|
||||
service_name?: string;
|
||||
service_description?: string;
|
||||
price?: number;
|
||||
duration_minutes?: number;
|
||||
}>;
|
||||
payments: Array<{
|
||||
id: string;
|
||||
payment_type: string;
|
||||
payment_method: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
created_at: string;
|
||||
invoice_number?: number;
|
||||
is_vat_applicable: boolean;
|
||||
vat_amount?: number;
|
||||
net_amount?: number;
|
||||
vat_rate?: number;
|
||||
}>;
|
||||
total_amount: number;
|
||||
amount_paid: number;
|
||||
amount_due: number;
|
||||
duration_minutes: number;
|
||||
};
|
||||
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
let totalDuration = $derived(
|
||||
selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) ||
|
||||
0
|
||||
);
|
||||
|
||||
async function fetchBookingDetails() {
|
||||
if (!bookingId) return;
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch(`/api/bookings/${bookingId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedBooking = data;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
toast.error('Failed to load booking: ' + text);
|
||||
open = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching booking:', err);
|
||||
toast.error('Network error');
|
||||
open = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => (selectedBooking = null), 200);
|
||||
} else if (bookingId && !selectedBooking) {
|
||||
fetchBookingDetails();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto md:max-w-3xl">
|
||||
<Modal.Header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<Modal.Title class="text-lg font-semibold">Booking Details</Modal.Title>
|
||||
{#if selectedBooking}
|
||||
<div class="mt-1 text-sm text-gray-500">ID: {selectedBooking.id}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedBooking}
|
||||
<!-- Logic: Only show chip if Booking is Future OR (Past AND Unpaid) -->
|
||||
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
|
||||
{@const isUnpaid = selectedBooking.amount_due > 0}
|
||||
{@const showChip = !isPastBooking || isUnpaid}
|
||||
|
||||
{#if showChip}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
||||
{isPastBooking
|
||||
? 'bg-red-100 text-red-800' // Red if past & unpaid
|
||||
: selectedBooking.status === 'confirmed' || selectedBooking.status === 'completed'
|
||||
? 'bg-emerald-100 text-emerald-800'
|
||||
: selectedBooking.status === 'pending'
|
||||
? 'bg-amber-100 text-amber-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center p-8 text-gray-500">Loading...</div>
|
||||
{:else if selectedBooking}
|
||||
<div class="space-y-6 px-4 pb-4">
|
||||
<!-- Appointment Details -->
|
||||
<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">
|
||||
Appointment Details
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
|
||||
<div class="font-medium">
|
||||
{(() => {
|
||||
const date = new SvelteDate(selectedBooking.start_time);
|
||||
const dateStr = date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Duration</div>
|
||||
<div class="font-medium">{totalDuration} minutes</div>
|
||||
</div>
|
||||
{#if selectedBooking.notes}
|
||||
<div class="md:col-span-2">
|
||||
<div class="text-xs text-gray-500">Notes</div>
|
||||
<div class="mt-1 rounded-md border border-gray-300 bg-white p-2 text-sm">
|
||||
{selectedBooking.notes}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
{#if selectedBooking.services && selectedBooking.services.length > 0}
|
||||
<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">
|
||||
Services
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.services as service, index (index)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="font-medium">{service.service_name || '—'}</div>
|
||||
{#if service.service_description}
|
||||
<div class="mt-1 text-sm text-gray-600">{service.service_description}</div>
|
||||
{/if}
|
||||
<div class="mt-2 flex items-center justify-between text-sm">
|
||||
<span class="text-gray-600">{service.duration_minutes} min</span>
|
||||
<span class="font-semibold">£{service.price?.toFixed(2) || '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Financial Summary -->
|
||||
<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">
|
||||
Financial Summary
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Total Amount</span>
|
||||
<span class="font-semibold">£{selectedBooking.total_amount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Amount Paid</span>
|
||||
<span class="font-semibold text-green-700"
|
||||
>£{selectedBooking.amount_paid.toFixed(2)}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||
<span class="font-medium text-gray-900">Amount Due</span>
|
||||
<span
|
||||
class="text-lg font-bold {selectedBooking.amount_due > 0
|
||||
? 'text-red-600'
|
||||
: 'text-green-600'}"
|
||||
>
|
||||
£{selectedBooking.amount_due.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payments -->
|
||||
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
|
||||
<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">
|
||||
Payment History
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
{#each selectedBooking.payments as payment (payment.id)}
|
||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium capitalize"
|
||||
>{payment.payment_method.replace('_', ' ')}</span
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{payment.status === 'completed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: payment.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{payment.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{payment.payment_type.charAt(0).toUpperCase() +
|
||||
payment.payment_type.slice(1)}
|
||||
</div>
|
||||
{#if payment.is_vat_applicable}
|
||||
<div class="mt-2 text-xs text-gray-600">
|
||||
<div>Net: £{payment.net_amount?.toFixed(2) || '0.00'}</div>
|
||||
{#if payment.vat_amount}
|
||||
<div>
|
||||
VAT ({(payment.vat_rate || 0) * 100}%): £{payment.vat_amount.toFixed(
|
||||
2
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-1 text-xs text-gray-400">
|
||||
{new SvelteDate(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right font-semibold">
|
||||
£{payment.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
Reference in New Issue
Block a user