feat: user booking cancel/reschedule, estimated subtotal wording
- Add Cancel Booking button with confirmation dialog (future bookings only, pending/confirmed status)
- If payments exist, dialog shows warning: amount paid retained as credit towards future appointment
- Add Reschedule button with inline form (date/time picker + optional notes)
- Reschedule submits to POST /api/bookings/{id}/edit-request
- Change 'Amount Due' to 'Estimated Subtotal' for future bookings
- Footer: Cancel (left) | Reschedule, Add to Calendar, Close (right)
- Mobile-first: flex-wrap layout for footer buttons
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Textarea from '$lib/components/ui/textarea';
|
||||
import * as Label from '$lib/components/ui/label';
|
||||
import type { Booking } from '$lib/types/booking';
|
||||
|
||||
interface Props {
|
||||
@@ -16,9 +19,36 @@
|
||||
let selectedBooking = $state<Booking | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
let showCancelConfirm = $state(false);
|
||||
let cancelling = $state(false);
|
||||
|
||||
let showRescheduleForm = $state(false);
|
||||
let rescheduleTime = $state('');
|
||||
let rescheduleNotes = $state('');
|
||||
let rescheduleSubmitting = $state(false);
|
||||
|
||||
let totalDuration = $derived(
|
||||
selectedBooking?.services?.reduce((sum, service) => sum + (service.duration_minutes || 0), 0) ||
|
||||
0
|
||||
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
|
||||
);
|
||||
|
||||
async function fetchBookingDetails() {
|
||||
@@ -52,11 +82,82 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => (selectedBooking = null), 200);
|
||||
setTimeout(() => {
|
||||
selectedBooking = null;
|
||||
showCancelConfirm = false;
|
||||
showRescheduleForm = false;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
}, 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 submitReschedule() {
|
||||
if (!selectedBooking || !rescheduleTime) return;
|
||||
rescheduleSubmitting = true;
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
new_start_time: new Date(rescheduleTime).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;
|
||||
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>
|
||||
@@ -70,29 +171,27 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selectedBooking}
|
||||
<!-- Booking Status Badge -->
|
||||
{@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 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 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>
|
||||
|
||||
<!-- Deposit Status Badge -->
|
||||
{#if selectedBooking.deposit_required}
|
||||
{#if selectedBooking.status === 'pending'}
|
||||
<span
|
||||
@@ -111,9 +210,9 @@
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Modal.Header>
|
||||
|
||||
@@ -121,7 +220,6 @@
|
||||
<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
|
||||
@@ -161,9 +259,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deposit Info (removed - now in Financial Summary for confirmed+ bookings) -->
|
||||
|
||||
<!-- 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">
|
||||
@@ -186,44 +281,42 @@
|
||||
</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">
|
||||
{#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required}
|
||||
<!-- Deposit Info -->
|
||||
<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'}
|
||||
<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 !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>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-gray-600">Total Amount</span>
|
||||
@@ -236,7 +329,9 @@
|
||||
>
|
||||
</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="font-medium text-gray-900">
|
||||
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
|
||||
</span>
|
||||
<span
|
||||
class="text-lg font-bold {selectedBooking.amount_due > 0
|
||||
? 'text-red-600'
|
||||
@@ -248,7 +343,6 @@
|
||||
</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">
|
||||
@@ -265,11 +359,11 @@
|
||||
>
|
||||
<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 === '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>
|
||||
@@ -303,23 +397,127 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRescheduleForm}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-amber-700 uppercase">
|
||||
Request Reschedule
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-2">
|
||||
<Label.Root for="reschedule-time">New Date & Time *</Label.Root>
|
||||
<Input
|
||||
id="reschedule-time"
|
||||
type="datetime-local"
|
||||
bind:value={rescheduleTime}
|
||||
/>
|
||||
</div>
|
||||
<div class="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="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={submitReschedule}
|
||||
disabled={!rescheduleTime || rescheduleSubmitting}
|
||||
>
|
||||
{rescheduleSubmitting ? 'Submitting...' : 'Submit Request'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
showRescheduleForm = false;
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
{#if selectedBooking}
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
if (selectedBooking) {
|
||||
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add to Calendar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
<Modal.Footer class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex gap-2">
|
||||
{#if isCancellable}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={() => (showCancelConfirm = true)}
|
||||
>
|
||||
Cancel Booking
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if isCancellable}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
showRescheduleForm = !showRescheduleForm;
|
||||
if (!showRescheduleForm) {
|
||||
rescheduleTime = '';
|
||||
rescheduleNotes = '';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showRescheduleForm ? 'Hide Reschedule' : 'Reschedule'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if selectedBooking}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
if (selectedBooking) {
|
||||
window.open(`/api/bookings/${selectedBooking.id}/calendar`, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add to Calendar
|
||||
</Button>
|
||||
{/if}
|
||||
<Button size="sm" onclick={() => (open = false)}>Close</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user