Files
Crussell/frontend/src/lib/components/account/UserBookingModal.svelte
T
popertots 41666b0739 fix: full reschedule implementation with real available slots
- Fetch /api/scheduling/working-hours and /api/scheduling/available-hours for reschedule month
- Generate grouped time slots using same logic as BookingFlow (available + unavailable with start-end times)
- DatePicker uses isDateUnavailable based on real availability (no slots = unavailable)
- Time slots show X - Y format (e.g. 9:30 AM - 10:00 AM) matching booking flow
- Unavailable slots shown as disabled buttons
- Today's slots respect 2-hour minimum notice buffer
- Hours fetched on first reschedule open or calendar month change
2026-05-08 22:44:18 +01:00

781 lines
27 KiB
Svelte

<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { SvelteDate } from 'svelte/reactivity';
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
import { toast } from 'svelte-sonner';
import * as Modal from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import * as Textarea from '$lib/components/ui/textarea';
import * as Label from '$lib/components/ui/label';
import DatePicker from '$lib/components/booking/DatePicker.svelte';
import type { Booking, WorkingHoursDay, AvailableHoursDay } from '$lib/types/booking';
interface Props {
open: boolean;
bookingId: string;
}
let { open = $bindable(), bookingId }: Props = $props();
let selectedBooking = $state<Booking | null>(null);
let loading = $state(false);
let showCancelConfirm = $state(false);
let cancelling = $state(false);
let showRescheduleForm = $state(false);
let rescheduleDate = $state<CalendarDate | undefined>(undefined);
let rescheduleTime = $state('');
let rescheduleNotes = $state('');
let rescheduleSubmitting = $state(false);
let rescheduleWorkingHours = $state<Record<string, { isOpen: boolean; startTime: string; endTime: string }> | null>(null);
let rescheduleAvailableHours = $state<Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> | null>(null);
let loadingRescheduleHours = $state(false);
const today = new Date();
const minDate = new CalendarDate(today.getFullYear(), today.getMonth() + 1, today.getDate());
const maxDate = new Date();
maxDate.setMonth(today.getMonth() + 6);
const maxCalendarDate = new CalendarDate(maxDate.getFullYear(), maxDate.getMonth() + 1, maxDate.getDate());
let reschedulePlaceholder = $state<CalendarDate>(minDate);
let totalDuration = $derived(
selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) || 0
);
let isFutureBooking = $derived(
selectedBooking ? new Date(selectedBooking.start_time) > new Date() : false
);
let isCancellable = $derived(
selectedBooking &&
isFutureBooking &&
['pending', 'confirmed'].includes(selectedBooking.status)
);
let hasPayments = $derived(
selectedBooking && selectedBooking.payments && selectedBooking.payments.length > 0
);
let totalPaid = $derived(
selectedBooking?.payments
?.filter((p) => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0) || 0
);
let isRescheduleValid = $derived(
rescheduleDate && rescheduleTime && rescheduleTime.length >= 4
);
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 as Booking;
} 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;
showCancelConfirm = false;
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
reschedulePlaceholder = minDate;
rescheduleWorkingHours = null;
rescheduleAvailableHours = null;
}, 200);
} else if (bookingId && !selectedBooking) {
fetchBookingDetails();
}
});
async function cancelBooking() {
if (!selectedBooking) return;
cancelling = true;
try {
const body = hasPayments ? { reason: 'client_cancelled' } : undefined;
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: body ? JSON.stringify(body) : undefined
});
if (response.ok) {
toast.success('Booking cancelled');
showCancelConfirm = false;
open = false;
} else {
const text = await response.text();
toast.error('Failed to cancel: ' + text);
}
} catch {
toast.error('Network error');
} finally {
cancelling = false;
}
}
async function fetchRescheduleHours(date: CalendarDate) {
const monthKey = `${date.year}-${String(date.month).padStart(2, '0')}`;
loadingRescheduleHours = true;
try {
const startOfMonth = new CalendarDate(date.year, date.month, 1);
const endOfMonth = new CalendarDate(date.year, date.month, date.calendar.getDaysInMonth(date));
const startStr = startOfMonth.toString();
const endStr = endOfMonth.toString();
const [whRes, ahRes] = await Promise.all([
fetch(`/api/scheduling/working-hours?start=${startStr}&end=${endStr}`),
fetch(`/api/scheduling/available-hours?start=${startStr}&end=${endStr}`)
]);
if (whRes.ok && ahRes.ok) {
const whData: WorkingHoursDay[] = await whRes.json();
const ahData: AvailableHoursDay[] = await ahRes.json();
const whMap: Record<string, { isOpen: boolean; startTime: string; endTime: string }> = {};
whData.forEach((d) => { whMap[d.date] = { isOpen: d.isOpen, startTime: d.startTime, endTime: d.endTime }; });
const ahMap: Record<string, { isOpen: boolean; slots: Array<{ startTime: string; endTime: string }> }> = {};
ahData.forEach((d) => { ahMap[d.date] = { isOpen: d.isOpen, slots: d.slots }; });
rescheduleWorkingHours = whMap;
rescheduleAvailableHours = ahMap;
}
} catch (err) {
console.error('Failed to fetch reschedule hours:', err);
} finally {
loadingRescheduleHours = false;
}
}
function isDateUnavailable(date: DateValue): boolean {
const d = date as CalendarDate;
const jsDate = d.toDate(getLocalTimeZone());
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (jsDate < todayStart) return true;
if (d.compare(minDate) < 0 || d.compare(maxCalendarDate) > 0) return true;
if (!rescheduleWorkingHours) return false;
const dateStr = d.toString();
const dayHours = rescheduleWorkingHours[dateStr];
if (!dayHours || !dayHours.isOpen) return true;
if (totalDuration === 0) return false;
const slots = generateAvailableTimeSlots(totalDuration, d);
return slots.length === 0;
}
function formatTime(time: string): string {
const parts = time.split(':').map(Number);
const hours = parts[0];
const minutes = parts.length > 1 ? parts[1] : 0;
if (hours === 12 && minutes === 0) return 'Noon';
if (hours === 0 && minutes === 0) return 'Midnight';
const period = hours >= 12 ? 'PM' : 'AM';
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, '0')} ${period}`;
}
function calculateEndTime(startTime: string, durationMinutes: number): string {
const [hours, minutes] = startTime.split(':').map(Number);
let total = hours * 60 + minutes + durationMinutes;
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(':').map(Number);
return h * 60 + m;
}
function calculatePreviousTime(time: string): string {
const [h, m] = time.split(':').map(Number);
let total = h * 60 + m - 15;
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
function generateAvailableTimeSlots(duration: number, date: CalendarDate): string[] {
if (!rescheduleWorkingHours || !rescheduleAvailableHours) return [];
const dateStr = date.toString();
const dayWH = rescheduleWorkingHours[dateStr];
const dayAH = rescheduleAvailableHours[dateStr];
if (!dayWH || !dayWH.isOpen || !dayAH || !dayAH.slots) return [];
const slots: string[] = [];
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(todayCal) === 0;
for (const slot of dayAH.slots) {
const [sh, sm] = slot.startTime.split(':').map(Number);
const [eh, em] = slot.endTime.split(':').map(Number);
let startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
startMin = Math.max(startMin, currentMin + 120);
}
for (let m = startMin; m < endMin; m += 15) {
if (m + duration <= endMin) {
slots.push(`${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`);
}
}
}
return slots;
}
function generateGroupedTimeSlots(duration: number, date: CalendarDate): Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> {
if (!rescheduleWorkingHours) return [];
const dateStr = date.toString();
const dayWH = rescheduleWorkingHours[dateStr];
if (!dayWH || !dayWH.isOpen) return [];
const grouped: Array<{ type: 'available' | 'unavailable'; startTime: string; endTime: string; isGrouped?: boolean }> = [];
const [sh, sm] = dayWH.startTime.split(':').map(Number);
const [eh, em] = dayWH.endTime.split(':').map(Number);
let startMin = sh * 60 + sm;
const endMin = eh * 60 + em;
const now = new SvelteDate();
const todayCal = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate());
const isToday = date.compare(todayCal) === 0;
if (isToday) {
const currentMin = now.getHours() * 60 + now.getMinutes();
startMin = Math.max(startMin, currentMin + 120);
}
const availableSlots = generateAvailableTimeSlots(duration, date);
let currentUnavailableStart: string | null = null;
let lastAvailableEnd: string | null = null;
for (let m = startMin; m < endMin; m += 15) {
const timeStr = `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
const isAvailable = availableSlots.includes(timeStr);
if (isAvailable) {
if (currentUnavailableStart !== null) {
const groupEnd = calculatePreviousTime(timeStr);
grouped.push({ type: 'unavailable', startTime: lastAvailableEnd || currentUnavailableStart, endTime: groupEnd, isGrouped: true });
currentUnavailableStart = null;
}
const slotEnd = calculateEndTime(timeStr, duration);
lastAvailableEnd = slotEnd;
grouped.push({ type: 'available', startTime: timeStr, endTime: slotEnd });
} else {
if (currentUnavailableStart === null) {
currentUnavailableStart = timeStr;
}
}
}
if (currentUnavailableStart !== null) {
const lastAvail = grouped.filter((s) => s.type === 'available').pop();
const lastAvailEnd = lastAvail ? timeToMinutes(lastAvail.endTime) : 0;
if (timeToMinutes(currentUnavailableStart) < endMin && lastAvailEnd < endMin) {
grouped.push({ type: 'unavailable', startTime: lastAvail ? lastAvail.endTime : currentUnavailableStart, endTime: dayWH.endTime, isGrouped: true });
}
}
return grouped;
}
async function submitReschedule() {
if (!selectedBooking || !rescheduleDate || !rescheduleTime) return;
rescheduleSubmitting = true;
try {
const [hours, minutes] = rescheduleTime.split(':').map(Number);
const bookingDate = rescheduleDate.toDate(getLocalTimeZone());
bookingDate.setHours(hours || 0, minutes || 0, 0, 0);
const body: Record<string, unknown> = {
new_start_time: bookingDate.toISOString()
};
if (rescheduleNotes.trim()) {
body.notes = rescheduleNotes.trim();
}
const response = await fetch(`/api/bookings/${selectedBooking.id}/edit-request`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${authStore.currentToken}`
},
body: JSON.stringify(body)
});
if (response.ok) {
toast.success('Reschedule request sent — we\'ll confirm shortly');
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
} else {
const text = await response.text();
toast.error(text || 'Failed to submit reschedule request');
}
} catch {
toast.error('Network error');
} finally {
rescheduleSubmitting = false;
}
}
</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}
{@const isPastBooking = new Date(selectedBooking.start_time) < new Date()}
{@const isUnpaid = selectedBooking.amount_due > 0}
{@const showChip = !isPastBooking || isUnpaid}
{@const isConfirmedOrLater = ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status)}
{#if showChip}
<div class="flex items-center gap-2">
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{isPastBooking
? 'bg-red-100 text-red-800'
: 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 selectedBooking.deposit_required}
{#if selectedBooking.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if isConfirmedOrLater}
<span
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
{selectedBooking.deposit_paid
? 'bg-green-100 text-green-800'
: 'bg-orange-100 text-orange-800'}"
>
{selectedBooking.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
</div>
{/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">
<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>
{#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}
<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">
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
<div class="flex items-center justify-between border-b border-gray-200 pb-2">
<span class="text-sm text-gray-600">Deposit Required</span>
<div class="text-right">
<div class="font-semibold">£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}</div>
<div class="text-xs">
<span
class="{selectedBooking.deposit_paid
? 'text-green-600'
: 'text-orange-600'}"
>
{selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'}
</span>
{#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline}
<span class="text-gray-500">
• Due: {new SvelteDate(selectedBooking.deposit_deadline).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric'
})} at {new SvelteDate(selectedBooking.deposit_deadline).toLocaleTimeString('en-GB', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})}
</span>
{/if}
</div>
</div>
</div>
{/if}
<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">
{isFutureBooking ? 'Estimated Subtotal' : '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>
{#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}
{#if showRescheduleForm}
<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">
Request Reschedule
</h3>
{#if loadingRescheduleHours}
<div class="flex items-center justify-center p-6">
<p class="text-sm text-gray-500">Loading available dates...</p>
</div>
{:else}
<div class="flex items-center justify-center">
<DatePicker
date={rescheduleDate}
placeholder={reschedulePlaceholder}
minValue={minDate}
maxValue={maxCalendarDate}
isDateUnavailable={isDateUnavailable}
onchange={(d) => { rescheduleDate = d; rescheduleTime = ''; }}
onPlaceholderChange={(p) => {
reschedulePlaceholder = p;
if (!rescheduleWorkingHours) fetchRescheduleHours(p);
}}
/>
</div>
{/if}
{#if rescheduleDate}
{#if loadingRescheduleHours}
<div class="flex items-center justify-center border-t p-6">
<p class="text-sm text-gray-500">Loading times...</p>
</div>
{:else}
<div class="no-scrollbar mt-2 flex max-h-40 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t pt-4">
<div class="grid justify-center gap-2 text-sm text-gray-600">
{rescheduleDate.toDate(getLocalTimeZone()).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}
</div>
{#if rescheduleWorkingHours && !rescheduleWorkingHours[rescheduleDate.toString()]?.isOpen}
<p class="text-center text-sm text-gray-500">We're closed on this day</p>
{:else}
{@const grouped = generateGroupedTimeSlots(totalDuration, rescheduleDate)}
{#if grouped.length > 0}
<div class="grid gap-2">
{#each grouped as slot (slot.startTime + slot.endTime)}
{#if slot.type === 'available'}
<Button
variant="outline"
onclick={() => { rescheduleTime = slot.startTime; }}
class="w-full hover:bg-fuchsia-50 {rescheduleTime === slot.startTime ? 'bg-fuchsia-100' : ''}"
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{:else}
<Button
variant="outline"
class="w-full cursor-not-allowed opacity-50 hover:bg-gray-100"
disabled
>
{formatTime(slot.startTime)}
<span class="text-gray-500">- {formatTime(slot.endTime)}</span>
</Button>
{/if}
{/each}
</div>
{:else}
<p class="text-center text-sm text-gray-500">No available slots</p>
{/if}
{/if}
</div>
{/if}
{/if}
<div class="mt-4 space-y-2">
<Label.Root for="reschedule-notes">Reason (optional)</Label.Root>
<Textarea.Root
id="reschedule-notes"
bind:value={rescheduleNotes}
placeholder="Tell us why you need to reschedule"
rows={2}
/>
</div>
<div class="mt-4 flex gap-2">
<Button
size="sm"
onclick={submitReschedule}
disabled={!isRescheduleValid || rescheduleSubmitting}
>
{rescheduleSubmitting ? 'Submitting...' : 'Submit Request'}
</Button>
<Button
size="sm"
variant="outline"
onclick={() => {
showRescheduleForm = false;
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
}}
>
Cancel
</Button>
</div>
</div>
{/if}
</div>
{/if}
<div class="flex flex-col gap-2 border-t px-4 py-3">
<div class="flex gap-2">
{#if isCancellable}
<Button
variant="destructive"
size="sm"
class="flex-1"
onclick={() => (showCancelConfirm = true)}
>
Cancel Booking
</Button>
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
showRescheduleForm = !showRescheduleForm;
if (!showRescheduleForm) {
rescheduleDate = undefined;
rescheduleTime = '';
rescheduleNotes = '';
} else if (!rescheduleWorkingHours) {
fetchRescheduleHours(reschedulePlaceholder);
}
}}
>
{showRescheduleForm ? 'Hide Reschedule' : 'Reschedule'}
</Button>
{/if}
</div>
<div class="flex gap-2">
{#if selectedBooking}
<Button
variant="outline"
size="sm"
class="flex-1"
onclick={() => {
if (selectedBooking) {
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
}
}}
>
Add to Calendar
</Button>
{/if}
<Button size="sm" class="flex-1" onclick={() => (open = false)}>Close</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
<Modal.Root open={showCancelConfirm} onOpenChange={(v) => (showCancelConfirm = v)}>
<Modal.Content class="max-w-sm">
<Modal.Header>
<Modal.Title>Cancel Booking</Modal.Title>
<Modal.Description>
Are you sure you want to cancel this booking?
{#if hasPayments}
<div class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p class="font-medium">Please note:</p>
<p class="mt-1">
The <span class="font-semibold">£{totalPaid.toFixed(2)}</span> already paid for this booking
will not be refunded, but will be retained as credit towards a future appointment.
</p>
</div>
{/if}
</Modal.Description>
</Modal.Header>
<Modal.Footer>
<Button variant="outline" onclick={() => (showCancelConfirm = false)}>
Keep Booking
</Button>
<Button variant="destructive" onclick={cancelBooking} disabled={cancelling}>
{cancelling ? 'Cancelling...' : 'Yes, Cancel'}
</Button>
</Modal.Footer>
</Modal.Content>
</Modal.Root>