diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 9bec776..a7d8d78 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -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(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 = { + 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; + } + } @@ -70,29 +171,27 @@ {/if} - {#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 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} -
- - {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} - + {#if showChip} +
+ + {isPastBooking ? 'Unpaid' : selectedBooking.status.replace('_', ' ')} + - {#if selectedBooking.deposit_required} {#if selectedBooking.status === 'pending'} {/if} {/if} -
- {/if} +
{/if} + {/if} @@ -121,7 +220,6 @@
Loading...
{:else if selectedBooking}
-

Appointment Details @@ -161,9 +259,6 @@

- - - {#if selectedBooking.services && selectedBooking.services.length > 0}

@@ -186,44 +281,42 @@

{/if} -

Financial Summary

-
- {#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required} - -
- Deposit Required -
-
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
-
- - {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} +
+ {#if ['confirmed', 'in_progress', 'completed'].includes(selectedBooking.status) && selectedBooking.deposit_required} +
+ Deposit Required +
+
£{selectedBooking.deposit_amount?.toFixed(2) || '0.00'}
+
+ + {selectedBooking.deposit_paid ? 'Paid' : 'Outstanding'} + + {#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} + + • 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 + })} - {#if !selectedBooking.deposit_paid && selectedBooking.deposit_deadline} - - • 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 - })} - - {/if} -
+ {/if}
- {/if} +
+ {/if}
Total Amount @@ -236,7 +329,9 @@ >
- Amount Due + + {isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} +

@@ -265,11 +359,11 @@ > {payment.status} @@ -303,23 +397,127 @@

{/if} + + {#if showRescheduleForm} +
+

+ Request Reschedule +

+
+
+ New Date & Time * + +
+
+ Reason (optional) + +
+
+ + +
+
+
+ {/if}
{/if} - - {#if selectedBooking} - - {/if} - + +
+ {#if isCancellable} + + {/if} +
+
+ {#if isCancellable} + + {/if} + {#if selectedBooking} + + {/if} + +
+
+ + + + (showCancelConfirm = v)}> + + + Cancel Booking + + Are you sure you want to cancel this booking? + {#if hasPayments} +
+

Please note:

+

+ The £{totalPaid.toFixed(2)} already paid for this booking + will not be refunded, but will be retained as credit towards a future appointment. +

+
+ {/if} +
+
+ + +
diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index fe40f3f..27846f8 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -9,12 +9,12 @@ No external dependencies. No paid services. No API keys needed. ## P0 — Critical (Fix Now) -| # | Gap | Effort | Area | Notes | -| --- | ---------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | ~~`DELETE /api/user/account` is a no-op~~ ✅ | S (1-2h) | Backend | Wired to `anonymize_user()` for registered users and `delete_guest_user()` for guests. CardDAV contact deleted best-effort. | -| 2 | ~~**WalkInCreateModal guest booking errors out**~~ ✅ | S (1-2h) | Frontend | Guest creation now fires at submit time in both walk-in and call-in flows. Phone defaults to +447700900000 if left blank. | +| # | Gap | Effort | Area | Notes | +| --- | ---------------------------------------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | ~~`DELETE /api/user/account` is a no-op~~ ✅ | S (1-2h) | Backend | Wired to `anonymize_user()` for registered users and `delete_guest_user()` for guests. CardDAV contact deleted best-effort. | +| 2 | ~~**WalkInCreateModal guest booking errors out**~~ ✅ | S (1-2h) | Frontend | Guest creation now fires at submit time in both walk-in and call-in flows. Phone defaults to +447700900000 if left blank. | | 3 | ~~**ApprovalModal decline/cancel stub**~~ ✅ | S (2-3h) | Frontend | `handleDecline()` now calls `POST /api/admin/bookings/{id}/cancel`. Backend sets status to `we_cancelled`, acknowledges pending notification, creates cancelled_booking notification. | -| 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleTakePayment()` ⚠️ blocked on Square. `handleExtend()`, `handleCancel()` — dead buttons. | +| 4 | **CurrentAppointment action stubs** | M (1d) | Frontend | `handleTakePayment()` ⚠️ blocked on Square. `handleExtend()`, `handleCancel()` — dead buttons. | ## P1 — High