Add print receipt feature to UserBookingModal with full VAT breakdown. Fix VAT rate display (remove double *100). Show VAT breakdown (net + VAT) on booking confirmed page. Add VAT info to EditBookingModal. Show incl. VAT label on BookingFlow total, schedule, and today dashboard. Add VAT note to prices page. Show booking total amount on account page. Improve VAT enable toast in BusinessSettings. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
370 lines
12 KiB
Svelte
370 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { resolve } from '$app/paths';
|
||
import { authStore } from '$lib/stores/auth.svelte';
|
||
import { ensureBusinessInfo, getBusinessInfo } from '$lib/stores/businessInfo.svelte';
|
||
import { browser } from '$app/environment';
|
||
import { toast } from 'svelte-sonner';
|
||
import { SvelteDate } from 'svelte/reactivity';
|
||
import { fly } from 'svelte/transition';
|
||
import UserBookingModal from '$lib/components/account/UserBookingModal.svelte';
|
||
import type { Booking, BookingService } from '$lib/types/booking';
|
||
import { Button } from '$lib/components/ui/button';
|
||
|
||
let pageState = $state<'loading' | 'authorized' | 'unauthorized'>('loading');
|
||
let bookings = $state<Booking[]>([]);
|
||
let loading = $state(false);
|
||
let selectedBookingId = $state<string | null>(null);
|
||
let showBookingModal = $state(false);
|
||
let businessSettings = $derived(getBusinessInfo());
|
||
|
||
$effect(() => {
|
||
if (!browser) return;
|
||
if (authStore.isLoading) {
|
||
pageState = 'loading';
|
||
return;
|
||
}
|
||
if (!authStore.isAuthenticated) {
|
||
pageState = 'unauthorized';
|
||
goto('/login', { replaceState: true });
|
||
return;
|
||
}
|
||
pageState = 'authorized';
|
||
ensureBusinessInfo();
|
||
fetchBookings();
|
||
});
|
||
|
||
async function fetchBookings() {
|
||
loading = true;
|
||
try {
|
||
const today = new SvelteDate().toISOString().split('T')[0];
|
||
const response = await fetch(`/api/bookings?start_date=${today}&per_page=50&page=1`, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${authStore.currentToken}`
|
||
}
|
||
});
|
||
if (!response.ok) {
|
||
toast.error('Failed to load bookings');
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
const now = new SvelteDate();
|
||
bookings = (data.bookings || [])
|
||
.filter((b: Booking) => {
|
||
const startTime = new SvelteDate(b.start_time);
|
||
const endTime = new SvelteDate(startTime.getTime() + (b.duration_minutes || 0) * 60000);
|
||
return endTime > now;
|
||
})
|
||
.sort(
|
||
(a: Booking, b: Booking) =>
|
||
new SvelteDate(a.start_time).getTime() - new SvelteDate(b.start_time).getTime()
|
||
);
|
||
} catch (err) {
|
||
console.error('Error fetching bookings:', err);
|
||
toast.error('Network error');
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
function openBooking(id: string) {
|
||
selectedBookingId = id;
|
||
showBookingModal = true;
|
||
}
|
||
|
||
type BookingGroup = {
|
||
label: string;
|
||
key: string;
|
||
bookings: Booking[];
|
||
};
|
||
|
||
const bookingGroups = $derived.by(() => {
|
||
const groups: BookingGroup[] = [];
|
||
let currentMonth = '';
|
||
let currentGroup: BookingGroup | null = null;
|
||
for (const booking of bookings) {
|
||
const monthKey = booking.start_time.slice(0, 7);
|
||
if (monthKey !== currentMonth) {
|
||
currentMonth = monthKey;
|
||
const d = new SvelteDate(booking.start_time);
|
||
const label = d.toLocaleDateString('en-GB', {
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
currentGroup = { label, key: monthKey, bookings: [] };
|
||
groups.push(currentGroup);
|
||
}
|
||
currentGroup!.bookings.push(booking);
|
||
}
|
||
return groups;
|
||
});
|
||
|
||
function formatTime(iso: string): string {
|
||
return new SvelteDate(iso).toLocaleTimeString('en-GB', {
|
||
hour: 'numeric',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
function formatCardDate(iso: string): string {
|
||
return new SvelteDate(iso).toLocaleDateString('en-GB', {
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
month: 'short'
|
||
});
|
||
}
|
||
|
||
function formatEndTime(iso: string, durationMinutes: number): string {
|
||
const start = new SvelteDate(iso);
|
||
const end = new SvelteDate(start.getTime() + durationMinutes * 60000);
|
||
return end.toLocaleTimeString('en-GB', { hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
const totalAppointments = $derived(bookings.length);
|
||
|
||
type StatusStyle = {
|
||
bar: string;
|
||
badge: string;
|
||
dot: string;
|
||
label: string;
|
||
};
|
||
const statusConfig: Record<string, StatusStyle> = {
|
||
pending: {
|
||
bar: 'bg-amber-500',
|
||
badge: 'bg-amber-50 text-amber-700 border-amber-200',
|
||
dot: 'bg-amber-500',
|
||
label: 'Pending'
|
||
},
|
||
confirmed: {
|
||
bar: 'bg-emerald-500',
|
||
badge: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||
dot: 'bg-emerald-500',
|
||
label: 'Confirmed'
|
||
},
|
||
in_progress: {
|
||
bar: 'bg-blue-500',
|
||
badge: 'bg-blue-50 text-blue-700 border-blue-200',
|
||
dot: 'bg-blue-500',
|
||
label: 'In Progress'
|
||
},
|
||
completed: {
|
||
bar: 'bg-gray-400',
|
||
badge: 'bg-gray-50 text-gray-600 border-gray-200',
|
||
dot: 'bg-gray-400',
|
||
label: 'Completed'
|
||
},
|
||
client_cancelled: {
|
||
bar: 'bg-red-400',
|
||
badge: 'bg-red-50 text-red-700 border-red-200',
|
||
dot: 'bg-red-500',
|
||
label: 'Cancelled'
|
||
},
|
||
we_cancelled: {
|
||
bar: 'bg-red-400',
|
||
badge: 'bg-red-50 text-red-700 border-red-200',
|
||
dot: 'bg-red-500',
|
||
label: 'Cancelled'
|
||
},
|
||
no_show: {
|
||
bar: 'bg-gray-400',
|
||
badge: 'bg-gray-50 text-gray-600 border-gray-200',
|
||
dot: 'bg-gray-400',
|
||
label: 'No Show'
|
||
}
|
||
};
|
||
|
||
function getConfig(status: string): StatusStyle {
|
||
return statusConfig[status] || statusConfig.completed;
|
||
}
|
||
</script>
|
||
|
||
{#if pageState === 'loading'}
|
||
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||
<div class="animate-pulse space-y-6">
|
||
<div class="h-8 w-44 rounded-lg bg-gray-200"></div>
|
||
<div class="h-4 w-64 rounded bg-gray-200"></div>
|
||
<div class="space-y-4 pt-2">
|
||
{#each Array(3) as _, i}
|
||
<div class="rounded-xl border bg-white p-5 shadow-sm">
|
||
<div class="mb-3 flex items-center gap-3">
|
||
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
||
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
|
||
</div>
|
||
<div class="mb-3 h-4 w-48 rounded bg-gray-200"></div>
|
||
<div class="flex items-center justify-between">
|
||
<div class="h-5 w-16 rounded bg-gray-200"></div>
|
||
<div class="h-4 w-16 rounded bg-gray-100"></div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{:else if pageState === 'unauthorized'}
|
||
<div class="mx-auto max-w-4xl px-4 py-16 text-center md:px-8">
|
||
<p class="text-gray-500">Please log in to view your schedule.</p>
|
||
</div>
|
||
|
||
{:else}
|
||
<div class="mx-auto max-w-4xl px-4 py-8 md:px-8 md:py-12">
|
||
<div class="mb-10 text-center">
|
||
<h1 class="font-['Playfair_Display'] text-4xl font-bold text-gray-900">My Schedule</h1>
|
||
<p class="mt-2 text-sm text-gray-500 md:text-base">
|
||
{totalAppointments === 0
|
||
? 'No upcoming appointments'
|
||
: `You have ${totalAppointments} upcoming visit${totalAppointments === 1 ? '' : 's'}`}
|
||
</p>
|
||
</div>
|
||
|
||
{#if loading}
|
||
<div class="space-y-6">
|
||
{#each Array(3) as _, i}
|
||
<div class="animate-pulse rounded-xl border bg-white p-5 shadow-sm">
|
||
<div class="mb-3 flex items-center gap-3">
|
||
<div class="h-4 w-24 rounded bg-gray-200"></div>
|
||
<div class="h-6 w-20 rounded-full bg-gray-100"></div>
|
||
</div>
|
||
<div class="mb-3 h-4 w-48 rounded bg-gray-200"></div>
|
||
<div class="flex items-center justify-between">
|
||
<div class="h-5 w-16 rounded bg-gray-200"></div>
|
||
<div class="h-4 w-16 rounded bg-gray-100"></div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{:else if bookings.length === 0}
|
||
<div class="rounded-xl border border-dashed border-gray-200 bg-gray-50/50 py-16 text-center">
|
||
<div class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||
<svg
|
||
class="h-8 w-8 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<h3 class="mb-1 text-lg font-semibold text-gray-900">No Upcoming Appointments</h3>
|
||
<p class="mb-6 text-sm text-gray-500">You don't have any upcoming appointments scheduled.</p>
|
||
<Button href={resolve('/book')}>Book an Appointment</Button>
|
||
</div>
|
||
|
||
{:else}
|
||
<div class="space-y-10">
|
||
{#each bookingGroups as group (group.key)}
|
||
<section>
|
||
<div class="mb-5 flex items-center gap-4">
|
||
<h2 class="text-base font-bold text-gray-700">
|
||
{group.label}
|
||
</h2>
|
||
<div class="h-px flex-1 bg-gray-200"></div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
{#each group.bookings as booking (booking.id)}
|
||
{@const cfg = getConfig(booking.status)}
|
||
{@const serviceNames = booking.services?.map((s: BookingService) => s.service_name).filter(Boolean).join(', ') || ''}
|
||
|
||
<div
|
||
in:fly={{ y: 12, duration: 300, delay: 50 }}
|
||
class="group/card relative overflow-hidden rounded-xl border bg-white shadow-sm transition-all duration-200 hover:shadow-md"
|
||
>
|
||
<div class="absolute left-0 top-0 h-full w-1 {cfg.bar}"></div>
|
||
|
||
<div class="pl-5 pr-5 pt-4 pb-4 md:pl-6 md:pr-6 md:pt-5 md:pb-5">
|
||
<div class="mb-3 flex items-start justify-between gap-3 md:mb-3">
|
||
<div class="flex items-center gap-2">
|
||
<svg
|
||
class="h-4 w-4 shrink-0 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
<span class="text-sm text-gray-500">
|
||
{formatCardDate(booking.start_time)}
|
||
</span>
|
||
<span class="text-base font-semibold text-gray-900 md:text-lg">
|
||
{formatTime(booking.start_time)}
|
||
–
|
||
{formatEndTime(booking.start_time, booking.duration_minutes || 0)}
|
||
</span>
|
||
{#if booking.duration_minutes}
|
||
<span class="text-sm text-gray-400">· {booking.duration_minutes} min</span>
|
||
{/if}
|
||
</div>
|
||
<span
|
||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium leading-none {cfg.badge}"
|
||
>
|
||
<span class="h-1.5 w-1.5 rounded-full {cfg.dot}"></span>
|
||
{cfg.label}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="mb-3 h-px bg-gray-100 md:mb-4"></div>
|
||
|
||
<div>
|
||
{#if booking.services && booking.services.length > 0}
|
||
<p class="font-medium text-gray-900">{serviceNames}</p>
|
||
{/if}
|
||
|
||
<div class="{booking.services && booking.services.length > 0 ? 'mt-3 md:mt-4' : ''} flex items-center justify-between">
|
||
{#if booking.total_amount}
|
||
<span class="text-lg font-bold text-gray-900">
|
||
£{booking.total_amount.toFixed(2)}
|
||
{#if businessSettings?.is_vat_registered}
|
||
<span class="text-xs font-normal text-gray-400"> incl. VAT</span>
|
||
{/if}
|
||
</span>
|
||
{:else}
|
||
<span></span>
|
||
{/if}
|
||
|
||
<button
|
||
onclick={() => openBooking(booking.id)}
|
||
class="inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-sm font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||
>
|
||
Details
|
||
<svg
|
||
class="h-4 w-4"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
stroke-width="2"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
d="M9 5l7 7-7 7"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<UserBookingModal bind:open={showBookingModal} bookingId={selectedBookingId || ''} />
|