From 1a3829b4d9dfbe3efd4bfa31a248b79cc8cf7f2a Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 20 Jun 2026 16:59:31 +0100 Subject: [PATCH] feat(frontend): add former name display, referral discount type, and refunds to bookings Update frontend types, stores, and components to support name history display and new API fields. - Add previousFirstName/previousLastName to User type and BookingUser type - Add referral discount_source type and refunds array to Booking type - Create nameDisplay.ts utility for rendering '(formerly ...)' labels - Create booking.ts utility for booking-related helpers - Update 25+ admin, payments, today, and account components to display former names on booking cards, modals, appointment views, and user lists Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../account/UserBookingModal.svelte | 105 +++++++++++---- .../lib/components/admin/ApprovalModal.svelte | 11 +- .../admin/BookingCreateModal.svelte | 5 +- .../lib/components/admin/BookingModal.svelte | 73 +++++++++-- .../lib/components/admin/BookingsCard.svelte | 124 ++++++++++-------- .../components/admin/EditBookingModal.svelte | 7 +- .../components/admin/EditRequestModal.svelte | 60 +++++---- .../admin/GiftCardsManagement.svelte | 118 +++++------------ .../components/admin/RescheduleModal.svelte | 3 +- .../lib/components/admin/TimeBlockers.svelte | 5 +- .../src/lib/components/admin/UserModal.svelte | 73 +++++++---- .../src/lib/components/admin/UsersCard.svelte | 82 ++++++++---- .../lib/components/admin/WalkInBooking.svelte | 73 ++++++++++- .../components/admin/WalkInCreateModal.svelte | 5 +- .../components/payments/PaymentModal.svelte | 2 +- .../payments/TillPaymentModal.svelte | 37 +++--- .../payments/UserPaymentModal.svelte | 19 ++- .../today/CurrentAppointment.svelte | 15 ++- .../components/today/PendingApprovals.svelte | 9 +- .../lib/components/today/TodayCalendar.svelte | 9 +- .../lib/components/today/TodayStats.svelte | 2 + frontend/src/lib/stores/auth.svelte.ts | 3 + frontend/src/lib/types/booking.ts | 7 +- frontend/src/lib/utils/booking.ts | 23 ++++ frontend/src/lib/utils/nameDisplay.ts | 16 +++ .../routes/admin/notifications/+page.svelte | 4 +- .../src/routes/admin/schedule/+page.svelte | 7 +- 27 files changed, 597 insertions(+), 300 deletions(-) create mode 100644 frontend/src/lib/utils/booking.ts create mode 100644 frontend/src/lib/utils/nameDisplay.ts diff --git a/frontend/src/lib/components/account/UserBookingModal.svelte b/frontend/src/lib/components/account/UserBookingModal.svelte index 715f92d..b684d0c 100644 --- a/frontend/src/lib/components/account/UserBookingModal.svelte +++ b/frontend/src/lib/components/account/UserBookingModal.svelte @@ -9,6 +9,7 @@ import { Input } from '$lib/components/ui/input'; import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte'; import EditRequestModal from '$lib/components/account/EditRequestModal.svelte'; + import { computeBalanceDue } from '$lib/utils/booking'; import type { Booking, BookingDiscount, Payment } from '$lib/types/booking'; interface Props { @@ -68,6 +69,14 @@ .reduce((sum, p) => sum + p.amount, 0) || 0 ); + let totalRefunds = $derived( + (selectedBooking?.refunds ?? []) + .filter((r) => r.status === 'completed') + .reduce((sum, r) => sum + r.amount, 0) + ); + + let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0); + let depositOutstanding = $derived( selectedBooking?.deposit_required && !selectedBooking?.deposit_paid ); @@ -279,13 +288,13 @@ } } - function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string { + function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string { if (payment.payment_method === 'online_square') return 'Online Card'; if (payment.payment_method === 'in_person_card') return 'Card Machine'; if (payment.payment_method === 'cash') return 'Cash'; if (payment.payment_method === 'giftcard') return 'Gift Card'; if (payment.payment_method === 'discount') { - const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length; + const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length; const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01); if (discountList[discountPaymentsBefore]) { const d = discountList[discountPaymentsBefore]; @@ -365,6 +374,13 @@

Appointment Details

+ {#if selectedBooking.user?.previous_first_name && selectedBooking.user?.previous_last_name} + {@const fullName = `${selectedBooking.user.first_name} ${selectedBooking.user.last_name}`} + {@const formerName = `${selectedBooking.user.previous_first_name} ${selectedBooking.user.previous_last_name}`} +
+ {fullName}, (formerly {formerName}) +
+ {/if}
Scheduled Date & Time
@@ -482,6 +498,8 @@ {#if d.discount_source === 'loyalty'} Loyalty Stamp Card (10% Off) + {:else if d.discount_source === 'referral'} + Referral Discount ({d.discount_percent}% Off) {:else if d.campaign_name} {d.campaign_name} ({d.discount_percent}% Off) {:else} @@ -505,20 +523,27 @@
- {#if Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)) > 0.01} + {#if totalRefunds > 0} +
+ Refunds + -£{totalRefunds.toFixed(2)} +
+ {/if} + + {#if balanceDue > 0.01}
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'} - £{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)} + £{balanceDue.toFixed(2)}
{/if}
- {#if selectedBooking.payments && selectedBooking.payments.length > 0} + {#if (selectedBooking.payments && selectedBooking.payments.length > 0) || (selectedBooking.refunds && selectedBooking.refunds.length > 0)}

Payment History @@ -576,6 +601,30 @@

{/each} + + {#each selectedBooking.refunds ?? [] as refund (refund.id)} +
+
+
+
+ Refund + + {refund.status} + +
+
+ {refund.reason || 'Refund processed'} +
+
+ {new SvelteDate(refund.created_at).toLocaleString()} +
+
+
+ -£{refund.amount.toFixed(2)} +
+
+
+ {/each} {/if} @@ -611,27 +660,39 @@ {#if pendingEditRequest} {@const timeChanged = pendingEditRequest.original.start_time && pendingEditRequest.proposed?.start_time && pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time} {@const servicesChanged = pendingEditRequest.proposed?.services?.length && JSON.stringify(pendingEditRequest.original.services?.map(s => s.name)) !== JSON.stringify(pendingEditRequest.proposed.services?.map(s => s.name))} -
-
- - - - -
-

Awaiting admin approval

-

- {#if timeChanged} - Reschedule requested from {new SvelteDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {new SvelteDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + {@const originalNames = (pendingEditRequest.original.services ?? []).map(s => s.name)} + {@const proposedNames = (pendingEditRequest.proposed.services ?? []).map(s => s.name)} + {@const addedServices = proposedNames.filter(n => !originalNames.includes(n))} + {@const removedServices = originalNames.filter(n => !proposedNames.includes(n))} + {#if timeChanged || servicesChanged} +

+
+ + + + +
+

Awaiting admin approval

+

+ {#if timeChanged} + Reschedule requested from {new SvelteDate(pendingEditRequest.original.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} to {new SvelteDate(pendingEditRequest.proposed.start_time!).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })} + {/if} +

+ {#if addedServices.length > 0} +

+ Services added: {addedServices.join(', ')} +

{/if} - {#if servicesChanged} - {timeChanged ? ' • ' : ''}Service change requested + {#if removedServices.length > 0} +

+ Services removed: {removedServices.join(', ')} +

{/if} - {pendingEditRequest.notes ? (timeChanged || servicesChanged ? ' — ' : '') + pendingEditRequest.notes : ''} -

-

We'll let you know once it's been reviewed

+

We'll let you know once it's been reviewed

+
-
+ {/if} {/if}
diff --git a/frontend/src/lib/components/admin/ApprovalModal.svelte b/frontend/src/lib/components/admin/ApprovalModal.svelte index e0c01b8..99a6e29 100644 --- a/frontend/src/lib/components/admin/ApprovalModal.svelte +++ b/frontend/src/lib/components/admin/ApprovalModal.svelte @@ -4,6 +4,7 @@ import { toast } from 'svelte-sonner'; import { sanitizeText } from '$lib/utils/toast-safe'; import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; @@ -24,6 +25,8 @@ full_name: string; email?: string; phone?: string; + previous_first_name?: string | null; + previous_last_name?: string | null; }; services?: Array<{ service_id: string; @@ -126,6 +129,8 @@ user?: { full_name?: string; email?: string; + previous_first_name?: string | null; + previous_last_name?: string | null; }; services?: string[]; } @@ -409,7 +414,7 @@ {#if oldest?.id === ob.id} {/if} - {ob.user?.full_name || 'Unknown'} + {formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', { @@ -493,7 +498,7 @@
-
{booking.user?.full_name || '—'}
+
{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}
@@ -649,7 +654,7 @@

Applied Discounts

{#each booking.discounts as d}

- - {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.campaign_name || 'Promo Campaign'} + - {d.discount_source === 'loyalty' ? 'Loyalty Stamp Card' : d.discount_source === 'referral' ? 'Referral Discount' : d.campaign_name || 'Promo Campaign'} ({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}

{/each} diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte index 109aa60..926906e 100644 --- a/frontend/src/lib/components/admin/BookingCreateModal.svelte +++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte @@ -4,6 +4,7 @@ import { SvelteDate } from 'svelte/reactivity'; import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; + import { formatUserName } from '$lib/utils/nameDisplay'; // UI Components import * as Modal from '$lib/components/ui/dialog'; @@ -55,7 +56,7 @@ let userQuery = $state(''); // Updated type to include account_role for filtering let users = $state< - Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string }> + Array<{ id: string; fullName: string; email?: string; phone?: string; account_role: string; previousFirstName?: string | null; previousLastName?: string | null }> >([]); let selectedUserId = $state(null); let guestName = $state(''); @@ -1041,7 +1042,7 @@ onclick={() => (selectedUserId = user.id)} >
-
{user.fullName}
+
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
{#if user.email && user.phone} {user.email} • {user.phone} diff --git a/frontend/src/lib/components/admin/BookingModal.svelte b/frontend/src/lib/components/admin/BookingModal.svelte index f58ffcc..bf85b1f 100644 --- a/frontend/src/lib/components/admin/BookingModal.svelte +++ b/frontend/src/lib/components/admin/BookingModal.svelte @@ -9,8 +9,11 @@ import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte'; import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte'; import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; + import { computeBalanceDue } from '$lib/utils/booking'; import { POLICY } from '$lib/constants/policy'; import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking'; + import type { Refund } from '$lib/types/index'; interface Props { open: boolean; @@ -34,6 +37,12 @@ .filter((p) => p.payment_method !== 'discount' && p.status === 'completed') .reduce((sum, p) => sum + p.amount, 0) ); + let totalRefunds = $derived( + (selectedBooking?.refunds ?? []) + .filter((r) => r.status === 'completed') + .reduce((sum, r) => sum + r.amount, 0) + ); + let balanceDue = $derived(selectedBooking ? computeBalanceDue(selectedBooking) : 0); let hoursUntilAppt = $derived( selectedBooking ? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60) @@ -139,7 +148,9 @@ referral_code: data.user.referral_code, referral_code_uses: data.user.referral_code_uses, created_at: data.user.created_at, - notes: data.user.notes + notes: data.user.notes, + previous_first_name: data.user.previous_first_name, + previous_last_name: data.user.previous_last_name } : undefined, @@ -174,6 +185,19 @@ created_by: p.created_by })), + // Refunds + refunds: (data.refunds || []).map((r: Refund) => ({ + id: r.id, + payment_id: r.payment_id, + booking_id: r.booking_id, + amount: r.amount, + square_refund_id: r.square_refund_id, + status: r.status, + reason: r.reason, + created_by: r.created_by, + created_at: r.created_at + })), + // Financials total_amount: data.total_amount || 0, amount_paid: data.amount_paid || 0, @@ -191,13 +215,13 @@ } } - function getPaymentName(payment: Payment, index: number, payments: Payment[], discounts: BookingDiscount[] | undefined): string { + function getPaymentName(payment: Payment, index: number, payments: Payment[] | undefined, discounts: BookingDiscount[] | undefined): string { if (payment.payment_method === 'online_square') return 'Online Card'; if (payment.payment_method === 'in_person_card') return 'Card Machine'; if (payment.payment_method === 'cash') return 'Cash'; if (payment.payment_method === 'giftcard') return 'Gift Card'; if (payment.payment_method === 'discount') { - const discountPaymentsBefore = payments.slice(0, index).filter(p => p.payment_method === 'discount').length; + const discountPaymentsBefore = (payments ?? []).slice(0, index).filter(p => p.payment_method === 'discount').length; const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01); if (discountList[discountPaymentsBefore]) { const d = discountList[discountPaymentsBefore]; @@ -380,12 +404,12 @@ {#if selectedBooking.user?.profile_pic_url} {selectedBooking.user.full_name} {/if}
-
{selectedBooking.user?.full_name || '—'}
+
{formatUserName(selectedBooking.user?.full_name || '—', selectedBooking.user?.previous_first_name, selectedBooking.user?.previous_last_name)}
{#if selectedBooking.user?.date_of_birth}
{calculateAge(selectedBooking.user.date_of_birth)} years old @@ -510,6 +534,8 @@ {#if d.discount_source === 'loyalty'} Loyalty Stamp Card (10% Off) + {:else if d.discount_source === 'referral'} + Referral Discount ({d.discount_percent}% Off) {:else if d.campaign_name} {d.campaign_name} ({d.discount_percent}% Off) {:else} @@ -533,21 +559,28 @@
+ {#if totalRefunds > 0} +
+ Refunds + -£{totalRefunds.toFixed(2)} +
+ {/if} +
Balance Due 0.01 ? 'text-red-600' : 'text-green-600'}" > - £{Math.max(0, selectedBooking.total_amount - (selectedBooking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - (selectedBooking.payments ?? []).filter(p => p.payment_method !== 'discount' && p.status === 'completed').reduce((sum, p) => sum + p.amount, 0)).toFixed(2)} + £{balanceDue.toFixed(2)}
- {#if selectedBooking.payments && selectedBooking.payments.length > 0} + {#if (selectedBooking.payments && selectedBooking.payments.length > 0) || (selectedBooking.refunds && selectedBooking.refunds.length > 0)}

Payment History @@ -614,6 +647,30 @@

{/each} + + {#each selectedBooking.refunds ?? [] as refund (refund.id)} +
+
+
+
+ Refund + + {refund.status} + +
+
+ {refund.reason || 'Refund processed'} +
+
+ {formatDateTime(refund.created_at)} +
+
+
+ -£{refund.amount.toFixed(2)} +
+
+
+ {/each}
{/if} diff --git a/frontend/src/lib/components/admin/BookingsCard.svelte b/frontend/src/lib/components/admin/BookingsCard.svelte index 5aff138..fcc7fc5 100644 --- a/frontend/src/lib/components/admin/BookingsCard.svelte +++ b/frontend/src/lib/components/admin/BookingsCard.svelte @@ -9,6 +9,7 @@ import { Input } from '$lib/components/ui/input'; import { Skeleton } from '$lib/components/ui/skeleton'; import type { Booking } from '$lib/types/booking'; + import { formatUserName } from '$lib/utils/nameDisplay'; // Props let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props(); @@ -18,18 +19,25 @@ let totalBookings = $state(0); let bookingQuery = $state(''); let loadingSearch = $state(false); + // Cursor-based pagination: cursors[i] = cursor to use when loading page i+1 + // cursors[0] is always '' (empty cursor = first page) + let cursors = $state(['']); let currentPage = $state(1); let totalPages = $state(1); + let nextCursor = $state(null); let initialLoad = $state(true); // Fetch bookings from API - async function fetchBookings(page: number = 1, search: string = '') { + async function fetchBookings(pageIdx: number = 0, search: string = '') { loadingSearch = true; try { const params = new URLSearchParams({ - page: page.toString(), per_page: '3' }); + const cursor = cursors[pageIdx]; + if (cursor) { + params.set('cursor', cursor); + } let url = '/api/admin/bookings'; if (search.trim()) { @@ -47,20 +55,28 @@ if (response.ok) { const data = await response.json(); - if (data.bookings && data.bookings.length === 0) { + if (!data.bookings || data.bookings.length === 0) { bookings = []; totalBookings = 0; totalPages = 1; currentPage = 1; + cursors = ['']; + nextCursor = null; loadingSearch = false; initialLoad = false; return; } - bookings = data.bookings as Booking[]; + bookings = (data.bookings as Booking[]) || []; totalBookings = data.total || 0; totalPages = data.totalPages ?? 1; - currentPage = data.page || 1; + currentPage = pageIdx + 1; + nextCursor = data.next_cursor ?? null; + + // Pre-store cursor for the next page so we can go forward + if (nextCursor && cursors.length <= pageIdx + 1) { + cursors = [...cursors, nextCursor]; + } } else { const text = await response.text(); toast.error('Failed to load bookings: ' + text); @@ -75,25 +91,29 @@ } function searchBookings() { + cursors = ['']; + nextCursor = null; currentPage = 1; - fetchBookings(1, bookingQuery); + fetchBookings(0, bookingQuery); } function nextPage() { - if (currentPage < totalPages) { - fetchBookings(currentPage + 1, bookingQuery); - } + if (!nextCursor || currentPage >= totalPages) return; + // currentPage is 1-indexed; next page index = currentPage + fetchBookings(currentPage, bookingQuery); } function previousPage() { - if (currentPage > 1) { - fetchBookings(currentPage - 1, bookingQuery); - } + if (currentPage <= 1) return; + // currentPage is 1-indexed; previous page index = currentPage - 2 + fetchBookings(currentPage - 2, bookingQuery); } - // Load initial bookings on mount + // Load initial bookings on mount (guarded to run once) $effect(() => { - fetchBookings(); + if (initialLoad) { + fetchBookings(0); + } }); // Format booking date/time @@ -233,49 +253,47 @@
- {#if loadingSearch} -
- -
- {:else if bookings.length === 0} + {#if bookings.length === 0 && !loadingSearch}
No bookings found.
- {:else} - {#each bookings as b (b.id)} -
-
-
- {formatBookingDateTime(b.start_time)} -
-
- - - {b.status} - - {#if b.deposit_required} - {#if b.status === 'pending'} - - Will Require Deposit - - {:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)} - - {b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} - + {:else if bookings.length > 0} +
+ {#each bookings as b (b.id)} +
+
+
+ {formatBookingDateTime(b.start_time)} +
+
+ + + {b.status} + + {#if b.deposit_required} + {#if b.status === 'pending'} + + Will Require Deposit + + {:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)} + + {b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'} + + {/if} {/if} - {/if} - • {b.user?.full_name || 'Unknown User'} - - - {formatServices(b.services)} - + • {formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)} + + - {formatServices(b.services)} + +
+
- -
- {/each} + {/each} +
{/if}
diff --git a/frontend/src/lib/components/admin/EditBookingModal.svelte b/frontend/src/lib/components/admin/EditBookingModal.svelte index 2d35e44..1667981 100644 --- a/frontend/src/lib/components/admin/EditBookingModal.svelte +++ b/frontend/src/lib/components/admin/EditBookingModal.svelte @@ -3,6 +3,7 @@ import { SvelteDate } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; import { formatDuration } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; import * as Modal from '$lib/components/ui/dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { Button } from '$lib/components/ui/button'; @@ -95,7 +96,9 @@ referral_code: data.user.referral_code, referral_code_uses: data.user.referral_code_uses, created_at: data.user.created_at, - notes: data.user.notes + notes: data.user.notes, + previous_first_name: data.user.previous_first_name, + previous_last_name: data.user.previous_last_name } : undefined, services: (data.services || []).map((s: BookingService) => ({ @@ -418,7 +421,7 @@
Name
-
{booking.user?.full_name || '—'}
+
{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}
Email
diff --git a/frontend/src/lib/components/admin/EditRequestModal.svelte b/frontend/src/lib/components/admin/EditRequestModal.svelte index 90cf519..3bfa28b 100644 --- a/frontend/src/lib/components/admin/EditRequestModal.svelte +++ b/frontend/src/lib/components/admin/EditRequestModal.svelte @@ -5,6 +5,7 @@ import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; + import { formatUserName } from '$lib/utils/nameDisplay'; interface ServiceItem { id: string; @@ -36,6 +37,8 @@ full_name: string; email: string; phone: string; + previous_first_name?: string | null; + previous_last_name?: string | null; }; } @@ -181,7 +184,7 @@ Booking Change Request - Review the requested changes to {editRequest.user.full_name}'s booking. + Review the requested changes to {formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}'s booking. @@ -194,22 +197,30 @@
Name
-
{editRequest.user.full_name}
+
{formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}
Phone
-
{editRequest.user.phone || '—'}
+
+ {#if editRequest.user.phone} + {editRequest.user.phone} + {:else}—{/if} +
Email
-
{editRequest.user.email || '—'}
+
+ {#if editRequest.user.email} + {editRequest.user.email} + {:else}—{/if} +
- + {#if isTimeChanged()}

Date & Time Change @@ -227,26 +238,23 @@ )}

- {#if isTimeChanged()} -
-
After
-
- {formatDateLine1(editRequest.proposed.start_time!)} -
-
- {formatDateLine2( - editRequest.proposed.start_time!, - getDuration(editRequest.proposed.services) - )} -
+
+
After
+
+ {formatDateLine1(editRequest.proposed.start_time!)}
- {:else} -
No change
- {/if} +
+ {formatDateLine2( + editRequest.proposed.start_time!, + getDuration(editRequest.proposed.services) + )} +
+
+ {/if} - + {#if areServicesChanged()}

Services Change @@ -314,8 +322,9 @@

+ {/if} - + {#if editRequest.proposed.notes !== editRequest.original.notes}

Booking Notes Change @@ -327,14 +336,11 @@

Proposed
- {#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes} -
{editRequest.proposed.notes}
- {:else} -
No change
- {/if} +
{editRequest.proposed.notes}
+ {/if} {#if editRequest.notes} diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index 0b2fb9e..bfad261 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -7,6 +7,7 @@ import { EmailInput } from '$lib/components/ui/email-input'; import * as Modal from '$lib/components/ui/dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; + import { formatUserName } from '$lib/utils/nameDisplay'; interface GiftCard { @@ -27,6 +28,7 @@ email: string; balance: number; updated_at: string; + /* TODO: add previousFirstName/previousLastName when backend sends them */ } interface GiftCardSummary { @@ -80,7 +82,7 @@ let generateAmount = $state(''); let generateUserQuery = $state(''); let generateUsers = $state< - Array<{ id: string; fullName: string; email?: string; phone?: string }> + Array<{ id: string; fullName: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null }> >([]); let generateLoadingUsers = $state(false); let topUpAmount = $state(''); @@ -103,11 +105,11 @@ // Page 2: Customer selection state let generateCustomerTab = $state<'current' | 'member' | 'guest'>('current'); - let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string } | null>(null); + let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null); let loadingCurrentCustomer = $state(false); // Selection from Page 2 - let selectedCustomer = $state<{ id: string; name: string; email?: string } | null>(null); + let selectedCustomer = $state<{ id: string; name: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null); let isGuestSelected = $state(false); // Page 3: Recipient email input @@ -333,7 +335,9 @@ id: appointment.user.id, name: appointment.user.full_name || appointment.user.name || 'Current Customer', email: appointment.user.email, - phone: appointment.user.phone + phone: appointment.user.phone, + previousFirstName: appointment.user.previous_first_name, + previousLastName: appointment.user.previous_last_name }; } } @@ -993,28 +997,17 @@ Actions - - {#if loading || loadingSearch} - {#each Array(3) as _, i (i)} - - - - - - - + 0 && (loading || loadingSearch) ? 'opacity-60' : ''}> + {#if sortedCards.length === 0 && !loading && !loadingSearch} + + + {activeSection === 'expired_cards' + ? 'No expired gift cards found.' + : 'No gift cards generated yet. Click "Generate Gift Card" to create one.'} + - {/each} - {:else if sortedCards.length === 0} - - - {activeSection === 'expired_cards' - ? 'No expired gift cards found.' - : 'No gift cards generated yet. Click "Generate Gift Card" to create one.'} - - - {:else} - {#each sortedCards as gc (gc.id)} + {:else if sortedCards.length > 0} + {#each sortedCards as gc (gc.id)} {formatCardCode(gc.id)}
- {#if loading || loadingSearch} - {#each Array(2) as _, i (i)} -
- - - -
- {/each} - {:else if sortedCards.length === 0} + {#if sortedCards.length === 0 && !loading && !loadingSearch}
{activeSection === 'expired_cards' ? 'No expired gift cards found.' : 'No gift cards generated yet.'}
- {:else} - {#each sortedCards as gc (gc.id)} + {:else if sortedCards.length > 0} +
+ {#each sortedCards as gc (gc.id)}
{formatCardCode(gc.id)} @@ -1204,6 +1190,7 @@
{/each} +
{/if}
@@ -1279,7 +1266,7 @@ {:else} {#each sortedBalances as ub (ub.user_id)} - {ub.name} + {ub.name} {ub.email} {formatCurrency(ub.balance)} {formatDate(ub.updated_at)} @@ -1308,7 +1295,7 @@ {#each sortedBalances as ub (ub.user_id)}
- {ub.name} + {ub.name}
@@ -1593,14 +1580,16 @@ selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name, - email: currentCustomerInfo.email + email: currentCustomerInfo.email, + previousFirstName: currentCustomerInfo.previousFirstName, + previousLastName: currentCustomerInfo.previousLastName }; isGuestSelected = false; } }} >
-
{currentCustomerInfo.name}
+
{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}
{#if currentCustomerInfo.email}
{currentCustomerInfo.email}
{/if} @@ -1641,17 +1630,12 @@
- {#if generateLoadingUsers} -
- - -
- {:else if generateUsers.length === 0} + {#if generateUsers.length === 0 && !generateLoadingUsers}
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
- {:else} -
    + {:else if generateUsers.length > 0} +
      {#each generateUsers.slice(0, 5) as user (user.id)}
    • - {/each} -
- {#if Number(cashAmount) > Number(generateAmount)}
Change due: {formatCurrency(Number(cashAmount) - Number(generateAmount))} @@ -2141,22 +2109,6 @@ />
-
- {#each [Number(topUpAmount), 10, 20, 50] as val} - - {/each} -
- {#if Number(cashAmount) > Number(topUpAmount)}
Change due: {formatCurrency(Number(cashAmount) - Number(topUpAmount))} diff --git a/frontend/src/lib/components/admin/RescheduleModal.svelte b/frontend/src/lib/components/admin/RescheduleModal.svelte index 982fbe7..62bea8f 100644 --- a/frontend/src/lib/components/admin/RescheduleModal.svelte +++ b/frontend/src/lib/components/admin/RescheduleModal.svelte @@ -5,6 +5,7 @@ import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date'; import { Checkbox } from '$lib/components/ui/checkbox'; import PolicyPopover from '$lib/components/ui/policyPopover.svelte'; + import { formatUserName } from '$lib/utils/nameDisplay'; import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; @@ -495,7 +496,7 @@ })}
- {booking.user?.full_name || 'Unknown'} · {booking.services + {formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)} · {booking.services ?.map((s) => s.service_name) .join(', ') || 'No services'} · {bookingDuration} min
diff --git a/frontend/src/lib/components/admin/TimeBlockers.svelte b/frontend/src/lib/components/admin/TimeBlockers.svelte index 8d2e87f..7bd2cee 100644 --- a/frontend/src/lib/components/admin/TimeBlockers.svelte +++ b/frontend/src/lib/components/admin/TimeBlockers.svelte @@ -4,6 +4,7 @@ import { toast } from 'svelte-sonner'; import { sanitizeText } from '$lib/utils/toast-safe'; import { formatDuration } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; @@ -33,6 +34,8 @@ full_name: string; email: string | null; phone: string | null; + previous_first_name?: string | null; + previous_last_name?: string | null; } | null; services: string[]; }; @@ -850,7 +853,7 @@ {#each overlappingBookings as booking (booking.id)}
-
{booking.user?.full_name || 'Unknown'}
+
{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', diff --git a/frontend/src/lib/components/admin/UserModal.svelte b/frontend/src/lib/components/admin/UserModal.svelte index 3a73992..4a35130 100644 --- a/frontend/src/lib/components/admin/UserModal.svelte +++ b/frontend/src/lib/components/admin/UserModal.svelte @@ -5,6 +5,7 @@ import * as Modal from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import PatchTestModal from './PatchTestModal.svelte'; + import { formatUserName } from '$lib/utils/nameDisplay'; interface Props { open: boolean; @@ -42,6 +43,8 @@ dataRetentionConsent: boolean; dataConsentUpdatedAt?: string; socialLogins?: SocialLogin[]; + previousFirstName?: string; + previousLastName?: string; }; type Booking = { @@ -84,6 +87,8 @@ let selectedUser = $state(null); let bookingUserHistory = $state([]); let totalBookings = $state(0); + let cursors = $state(['']); + let nextCursor = $state(null); let currentBookingPage = $state(1); let totalBookingPages = $state(1); let loadingBookings = $state(false); @@ -144,15 +149,18 @@ } }); - async function fetchUserBookings(page: number = 1) { + async function fetchUserBookings(pageIdx: number = 0) { if (!userId) return; loadingBookings = true; try { const params = new URLSearchParams({ - page: page.toString(), per_page: '4' }); + const cursor = cursors[pageIdx]; + if (cursor) { + params.set('cursor', cursor); + } const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, { method: 'GET', @@ -164,10 +172,27 @@ if (response.ok) { const data = await response.json(); + + if (!data.bookings || data.bookings.length === 0) { + bookingUserHistory = []; + totalBookings = 0; + totalBookingPages = 1; + currentBookingPage = 1; + cursors = ['']; + nextCursor = null; + loadingBookings = false; + return; + } + bookingUserHistory = data.bookings || []; totalBookings = data.total || 0; - currentBookingPage = data.page || 1; totalBookingPages = data.totalPages || 1; + currentBookingPage = pageIdx + 1; + nextCursor = data.next_cursor ?? null; + + if (nextCursor && cursors.length <= pageIdx + 1) { + cursors = [...cursors, nextCursor]; + } } else { const text = await response.text(); toast.error('Failed to load user bookings: ' + text); @@ -181,15 +206,13 @@ } function nextBookingPage() { - if (currentBookingPage < totalBookingPages) { - fetchUserBookings(currentBookingPage + 1); - } + if (!nextCursor || currentBookingPage >= totalBookingPages) return; + fetchUserBookings(currentBookingPage); } function previousBookingPage() { - if (currentBookingPage > 1) { - fetchUserBookings(currentBookingPage - 1); - } + if (currentBookingPage <= 1) return; + fetchUserBookings(currentBookingPage - 2); } async function fetchCustomerRelationship() { @@ -277,15 +300,15 @@ {#if selectedUser}
-
-

- Personal Information -

-
-
-
Full Name
-
{selectedUser.fullName}
-
+
+

+ Personal Information +

+
+
+
Full Name
+
{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
+
Email
@@ -371,16 +394,10 @@

Booking History ({totalBookings})

- {#if loadingBookings} -
- {#each Array(3) as _, i (i)} -
- {/each} -
- {:else if bookingUserHistory.length === 0} + {#if bookingUserHistory.length === 0 && !loadingBookings}
No bookings found
- {:else} -
+ {:else if bookingUserHistory.length > 0} +
{#each bookingUserHistory as booking (booking.id)}
@@ -619,7 +636,7 @@ { fetchUserDetails(); }} diff --git a/frontend/src/lib/components/admin/UsersCard.svelte b/frontend/src/lib/components/admin/UsersCard.svelte index d9641f3..d48ffd5 100644 --- a/frontend/src/lib/components/admin/UsersCard.svelte +++ b/frontend/src/lib/components/admin/UsersCard.svelte @@ -5,6 +5,7 @@ import { Input } from '$lib/components/ui/input'; import { Button } from '$lib/components/ui/button'; import { Skeleton } from '$lib/components/ui/skeleton'; + import { formatUserName } from '$lib/utils/nameDisplay'; interface Props { openUserModal: (userId: string) => void; @@ -17,6 +18,8 @@ fullName: string; email?: string; phone?: string; + previousFirstName?: string | null; + previousLastName?: string | null; }; type UserListResponse = { @@ -25,24 +28,32 @@ page: number; perPage: number; totalPages: number; + next_cursor?: string | null; }; let userQuery = $state(''); let users = $state([]); let totalUsers = $state(0); + // Cursor-based pagination: cursors[i] = cursor to use when loading page i+1 + // cursors[0] is always '' (empty cursor = first page) + let cursors = $state(['']); let currentPage = $state(1); let totalPages = $state(1); + let nextCursor = $state(null); let loadingSearch = $state(false); let initialLoad = $state(true); - async function fetchUsers(page: number = 1, search: string = '') { + async function fetchUsers(pageIdx: number = 0, search: string = '') { loadingSearch = true; try { const params = new URLSearchParams({ - page: page.toString(), per_page: '4' }); + const cursor = cursors[pageIdx]; + if (cursor) { + params.set('cursor', cursor); + } if (search.trim()) { params.append('q', search.trim()); @@ -58,10 +69,29 @@ if (response.ok) { const data: UserListResponse = await response.json(); - users = data.users; + + if (!data.users || data.users.length === 0) { + users = []; + totalUsers = 0; + totalPages = 1; + currentPage = 1; + cursors = ['']; + nextCursor = null; + loadingSearch = false; + initialLoad = false; + return; + } + + users = data.users || []; totalUsers = data.total; - currentPage = data.page; totalPages = data.totalPages; + currentPage = pageIdx + 1; + nextCursor = data.next_cursor ?? null; + + // Pre-store cursor for the next page so we can go forward + if (nextCursor && cursors.length <= pageIdx + 1) { + cursors = [...cursors, nextCursor]; + } } else { const text = await response.text(); toast.error('Failed to load users: ' + text); @@ -76,25 +106,29 @@ } function searchUsers() { + cursors = ['']; + nextCursor = null; currentPage = 1; - fetchUsers(1, userQuery); + fetchUsers(0, userQuery); } function nextPage() { - if (currentPage < totalPages) { - fetchUsers(currentPage + 1, userQuery); - } + if (!nextCursor || currentPage >= totalPages) return; + // currentPage is 1-indexed; next page index = currentPage + fetchUsers(currentPage, userQuery); } function previousPage() { - if (currentPage > 1) { - fetchUsers(currentPage - 1, userQuery); - } + if (currentPage <= 1) return; + // currentPage is 1-indexed; previous page index = currentPage - 2 + fetchUsers(currentPage - 2, userQuery); } - // Load initial users on mount + // Load initial users on mount (guarded to run once) $effect(() => { - fetchUsers(); + if (initialLoad) { + fetchUsers(0); + } }); @@ -142,7 +176,7 @@
- {#if initialLoad || loadingSearch} + {#if initialLoad} {#each Array(3) as _, i (i)}
@@ -154,17 +188,19 @@ {userQuery ? 'No users found matching your search.' : 'No users found.'}
{:else} - {#each users as user (user.id)} -
-
-
{user.fullName}
-
- {user.email || '—'} • {user.phone || '—'} +
+ {#each users as user (user.id)} +
+
+
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
+
+ {user.email || '—'} • {user.phone || '—'} +
+
- -
- {/each} + {/each} +
{/if}
diff --git a/frontend/src/lib/components/admin/WalkInBooking.svelte b/frontend/src/lib/components/admin/WalkInBooking.svelte index 8672b3e..f7e2696 100644 --- a/frontend/src/lib/components/admin/WalkInBooking.svelte +++ b/frontend/src/lib/components/admin/WalkInBooking.svelte @@ -8,6 +8,11 @@ import { toast } from 'svelte-sonner'; import type { AvailableHoursDay, Service } from '$lib/types/booking'; + import { + minutesToTime, + calculateMiddleWindow, + shouldApplyLunchProtection + } from '$lib/lunchProtection'; const RESERVATION_TTL = 15; @@ -28,6 +33,7 @@ let reservationCountdown = $state(''); let isReserving = $state(false); let reservedDuration = $state(0); + let reservedStartTime = $state(null); let shortestServiceMinutes = $state(null); @@ -76,6 +82,46 @@ } } + /** + * Subtract time gaps from available slots, returning remaining (split) slots. + * E.g., subtract [{10:00, 11:00}] from [{09:00, 17:00}] → [{09:00, 10:00}, {11:00, 17:00}] + */ + function subtractTimeSlots( + slots: Array<{ startTime: string; endTime: string }>, + gaps: Array<{ startTime: string; endTime: string }> + ): Array<{ startTime: string; endTime: string }> { + if (gaps.length === 0) return slots; + + let result = slots.map((s) => ({ ...s })); + + for (const gap of gaps) { + const gapStart = timeToMinutes(gap.startTime); + const gapEnd = timeToMinutes(gap.endTime); + const newResult: Array<{ startTime: string; endTime: string }> = []; + + for (const slot of result) { + const slotStart = timeToMinutes(slot.startTime); + const slotEnd = timeToMinutes(slot.endTime); + + if (gapEnd <= slotStart || gapStart >= slotEnd) { + // No overlap, keep the slot as is + newResult.push(slot); + } else { + // Overlap exists, split the slot + if (slotStart < gapStart) { + newResult.push({ startTime: slot.startTime, endTime: minutesToTime(gapStart) }); + } + if (gapEnd < slotEnd) { + newResult.push({ startTime: minutesToTime(gapEnd), endTime: slot.endTime }); + } + } + } + result = newResult; + } + + return result; + } + function getLiveRemainingMinutes(): number | null { if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null; const now = currentTime.getHours() * 60 + currentTime.getMinutes(); @@ -116,6 +162,29 @@ return; } + // --- Walk-in lunch protection --- + // Always block the first 60 minutes of the suggested lunch window from walk-in availability + const dayStartMinutesVal = Math.min(...todayData.slots.map((s) => timeToMinutes(s.startTime))); + const dayEndMinutesVal = Math.max(...todayData.slots.map((s) => timeToMinutes(s.endTime))); + const dayStartTimeVal = minutesToTime(dayStartMinutesVal); + const dayEndTimeVal = minutesToTime(dayEndMinutesVal); + + if (shouldApplyLunchProtection(dayStartTimeVal, dayEndTimeVal)) { + const { windowStart, windowEnd } = calculateMiddleWindow(dayStartTimeVal, dayEndTimeVal); + const lunchWalkerBlocker = { + startTime: minutesToTime(windowStart), + endTime: minutesToTime(windowStart + 60) + }; + + todayData.slots = subtractTimeSlots(todayData.slots, [lunchWalkerBlocker]); + + if (todayData.slots.length === 0) { + noSlotsToday = true; + return; + } + } + // --- End walk-in lunch protection --- + const currentMinutes = now.getHours() * 60 + now.getMinutes(); for (const slot of todayData.slots) { @@ -298,6 +367,7 @@ const reserved = await reserveWalkInSlot(reserveTime, reserveDuration); if (reserved) { + reservedStartTime = reserveTime; showCreateModal = true; } } @@ -308,6 +378,7 @@ reservationExpiresAt = null; reservationCountdown = ''; reservedDuration = 0; + reservedStartTime = null; if ((window as any).__walkInCountdownInterval) { clearInterval((window as any).__walkInCountdownInterval); } @@ -376,7 +447,7 @@ diff --git a/frontend/src/lib/components/admin/WalkInCreateModal.svelte b/frontend/src/lib/components/admin/WalkInCreateModal.svelte index 4c9caaf..8bb404e 100644 --- a/frontend/src/lib/components/admin/WalkInCreateModal.svelte +++ b/frontend/src/lib/components/admin/WalkInCreateModal.svelte @@ -5,6 +5,7 @@ import { SvelteDate } from 'svelte/reactivity'; import { getLocalTimeZone } from '@internationalized/date'; import { isValidUKPhone, toE164UK } from '$lib/utils/phone'; + import { formatUserName } from '$lib/utils/nameDisplay'; // UI Components import * as Modal from '$lib/components/ui/dialog'; @@ -50,7 +51,7 @@ let userType = $state<'member' | 'guest'>('member'); let userQuery = $state(''); let users = $state< - Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string }> + Array<{ id: string; full_name: string; email?: string; phone?: string; account_role: string; previous_first_name?: string | null; previous_last_name?: string | null }> >([]); let selectedUserId = $state(null); let guestName = $state(''); @@ -717,7 +718,7 @@ onclick={() => (selectedUserId = user.id)} >
-
{user.full_name}
+
{formatUserName(user.full_name, user.previous_first_name, user.previous_last_name)}
{#if user.email && user.phone} {user.email} • {user.phone} diff --git a/frontend/src/lib/components/payments/PaymentModal.svelte b/frontend/src/lib/components/payments/PaymentModal.svelte index 1f415f7..d75a571 100644 --- a/frontend/src/lib/components/payments/PaymentModal.svelte +++ b/frontend/src/lib/components/payments/PaymentModal.svelte @@ -47,7 +47,7 @@ let paymentResult = $state(null); let error = $state(null); - let stamps = $state(booking.user?.loyalty_stamps ?? 0); + let stamps = $derived(booking.user?.loyalty_stamps ?? 0); let useLoyalty = $state(false); let loyaltyEligible = $derived( diff --git a/frontend/src/lib/components/payments/TillPaymentModal.svelte b/frontend/src/lib/components/payments/TillPaymentModal.svelte index ed7cefd..11b4c96 100644 --- a/frontend/src/lib/components/payments/TillPaymentModal.svelte +++ b/frontend/src/lib/components/payments/TillPaymentModal.svelte @@ -6,6 +6,7 @@ import { Input } from '$lib/components/ui/input'; import { authStore } from '$lib/stores/auth.svelte'; import { formatCardCode } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; interface Props { amount: number; @@ -73,7 +74,7 @@ } // Selected customer info - let selectedCustomer = $state<{ id: string; name: string } | null>(null); + let selectedCustomer = $state<{ id: string; name: string; previousFirstName?: string | null; previousLastName?: string | null } | null>(null); let isGuest = $state(false); // Delivery choice — how the gift card value is given to the customer @@ -101,6 +102,8 @@ name: string; email?: string; phone?: string; + previousFirstName?: string | null; + previousLastName?: string | null; } | null>(null); // Customer Selection - tab @@ -108,7 +111,7 @@ // Customer Selection - Search let userQuery = $state(''); - let users = $state>([]); + let users = $state>([]); let loadingUsers = $state(false); let currentPage = $state(1); let totalPages = $state(1); @@ -296,7 +299,9 @@ id: appointment.user.id, name: appointment.user.full_name || appointment.user.name || 'Current Customer', email: appointment.user.email, - phone: appointment.user.phone + phone: appointment.user.phone, + previousFirstName: appointment.user.previous_first_name, + previousLastName: appointment.user.previous_last_name }; } else { toast.error('No current or next appointment found'); @@ -313,7 +318,7 @@ function selectCurrentCustomer() { if (!currentCustomerInfo) return; - selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name }; + selectedCustomer = { id: currentCustomerInfo.id, name: currentCustomerInfo.name, previousFirstName: currentCustomerInfo.previousFirstName, previousLastName: currentCustomerInfo.previousLastName }; isGuest = currentCustomerInfo.email?.endsWith('@guest.invalid') || false; delivery = (action === 'topup' || isGuest) ? 'code' : 'account'; step = 'payment-selection'; @@ -390,8 +395,8 @@ } } - function selectCustomer(user: { id: string; fullName: string; email?: string }) { - selectedCustomer = { id: user.id, name: user.fullName }; + function selectCustomer(user: { id: string; fullName: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null }) { + selectedCustomer = { id: user.id, name: user.fullName, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName }; isGuest = user.email?.endsWith('@guest.invalid') || false; delivery = (action === 'topup' || isGuest) ? 'code' : 'account'; step = 'payment-selection'; @@ -777,7 +782,7 @@ {currentCustomerInfo.name.charAt(0).toUpperCase()}
-
{currentCustomerInfo.name}
+
{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}
{#if currentCustomerInfo.email}
{currentCustomerInfo.email}
{/if} @@ -843,20 +848,14 @@
- {#if loadingUsers} -
- {#each Array(3) as _, i (i)} -
- {/each} -
- {:else if users.length === 0} + {#if users.length === 0 && !loadingUsers}
{userQuery ? 'No customers found. Try a different search.' : 'Search for a customer above to get started.'}
- {:else} -
    + {:else if users.length > 0} +
      {#each users as userItem (userItem.id)}
+ {#if partialValidationError} +

{partialValidationError}

+ {/if}
{/if} - {#if payButtonError} -

{payButtonError}

- {/if}
{/if} -
-
- {activeAppointment.user?.full_name || 'Guest'} -
-
{activeAppointment.user?.phone || '—'}
+
+
+ {formatUserName(activeAppointment.user?.full_name || 'Guest', activeAppointment.user?.previous_first_name, activeAppointment.user?.previous_last_name)} +
+
{activeAppointment.user?.phone || '—'}
{#if activeAppointment.user}
{(item.data.services ?? []).join(', ')} @@ -997,7 +1000,7 @@ {#each overlappingBookings as booking (booking.id)}
-
{booking.user?.full_name || 'Unknown'}
+
{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', { hour: 'numeric', diff --git a/frontend/src/lib/components/today/TodayStats.svelte b/frontend/src/lib/components/today/TodayStats.svelte index 85577c3..8788633 100644 --- a/frontend/src/lib/components/today/TodayStats.svelte +++ b/frontend/src/lib/components/today/TodayStats.svelte @@ -12,6 +12,8 @@ user_id: string; services: string[]; duration_minutes: number; + previous_first_name?: string | null; + previous_last_name?: string | null; }; type DayWorkingHours = { diff --git a/frontend/src/lib/stores/auth.svelte.ts b/frontend/src/lib/stores/auth.svelte.ts index 5ef46c2..d316ae4 100644 --- a/frontend/src/lib/stores/auth.svelte.ts +++ b/frontend/src/lib/stores/auth.svelte.ts @@ -21,7 +21,10 @@ export interface User { loyaltyStamps?: number; referralCode?: string; referralCodeUses?: number; + referralSavings?: number; profilePicUrl?: string; + previousFirstName?: string; + previousLastName?: string; } class AuthStore { diff --git a/frontend/src/lib/types/booking.ts b/frontend/src/lib/types/booking.ts index c70e68f..a5fb3a4 100644 --- a/frontend/src/lib/types/booking.ts +++ b/frontend/src/lib/types/booking.ts @@ -1,3 +1,5 @@ +import type { Refund } from '$lib/types/index'; + export interface Service { id: string; name: string; @@ -84,6 +86,8 @@ export interface BookingUser { referral_code_uses?: number; created_at: string; notes?: string; + previous_first_name?: string; + previous_last_name?: string; } export interface Payment { @@ -127,6 +131,7 @@ export interface Booking { user?: BookingUser; services?: BookingService[]; payments?: Payment[]; + refunds?: Refund[]; discounts?: BookingDiscount[]; total_amount: number; amount_paid: number; @@ -175,7 +180,7 @@ export interface BookingDiscount { id: string; booking_id: string; user_id: string; - discount_source: 'loyalty' | 'campaign'; + discount_source: 'loyalty' | 'campaign' | 'referral'; source_id?: string; campaign_name?: string; campaign_type?: CampaignType; diff --git a/frontend/src/lib/utils/booking.ts b/frontend/src/lib/utils/booking.ts new file mode 100644 index 0000000..63d62f0 --- /dev/null +++ b/frontend/src/lib/utils/booking.ts @@ -0,0 +1,23 @@ +/** + * Compute the balance due for a booking — total minus discounts minus + * completed non-discount payments plus completed refunds, floored at 0. + */ +export function computeBalanceDue(booking: { + total_amount: number; + discounts?: Array<{ discount_amount: number }> | null; + payments?: Array<{ payment_method: string; status: string; amount: number }> | null; + refunds?: Array<{ status: string; amount: number }> | null; +}): number { + const totalRefunds = (booking.refunds ?? []) + .filter((r) => r.status === 'completed') + .reduce((sum, r) => sum + r.amount, 0); + return Math.max( + 0, + booking.total_amount - + (booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0) - + (booking.payments ?? []) + .filter((p) => p.payment_method !== 'discount' && p.status === 'completed') + .reduce((sum, p) => sum + p.amount, 0) + + totalRefunds + ); +} diff --git a/frontend/src/lib/utils/nameDisplay.ts b/frontend/src/lib/utils/nameDisplay.ts new file mode 100644 index 0000000..6c89b7d --- /dev/null +++ b/frontend/src/lib/utils/nameDisplay.ts @@ -0,0 +1,16 @@ +export function formatUserName( + fullName: string, + previousFirstName?: string | null, + previousLastName?: string | null +): string { + if (previousFirstName && previousLastName) { + return `${fullName} (formerly ${previousFirstName} ${previousLastName})`; + } + if (previousFirstName) { + return `${fullName} (formerly ${previousFirstName})`; + } + if (previousLastName) { + return `${fullName} (formerly ${previousLastName})`; + } + return fullName; +} diff --git a/frontend/src/routes/admin/notifications/+page.svelte b/frontend/src/routes/admin/notifications/+page.svelte index 3e78aba..9a8e6aa 100644 --- a/frontend/src/routes/admin/notifications/+page.svelte +++ b/frontend/src/routes/admin/notifications/+page.svelte @@ -331,7 +331,7 @@ function getNotificationSubtitle(n: Notification): string { const parts = [formatRelative(n.created_at)]; if (n.user_name) { - parts.push(n.user_name); + parts.push(n.user_name); {/* TODO: add formerly name when previous name data is available */} } return parts.join(' — '); } @@ -427,7 +427,7 @@

{:else} -
+
{#each notifications as n (n.id)} {@const actionable = !n.acknowledged_at && diff --git a/frontend/src/routes/admin/schedule/+page.svelte b/frontend/src/routes/admin/schedule/+page.svelte index 94966be..d2e13ff 100644 --- a/frontend/src/routes/admin/schedule/+page.svelte +++ b/frontend/src/routes/admin/schedule/+page.svelte @@ -8,6 +8,7 @@ import { Skeleton } from '$lib/components/ui/skeleton'; import { Button } from '$lib/components/ui/button'; import { formatDuration } from '$lib/utils/format'; + import { formatUserName } from '$lib/utils/nameDisplay'; import { SvelteDate } from 'svelte/reactivity'; import BookingModal from '$lib/components/admin/BookingModal.svelte'; @@ -18,7 +19,7 @@ start_time: string; status: string; duration_minutes: number; - user?: { full_name: string }; + user?: { full_name: string; previous_first_name?: string | null; previous_last_name?: string | null }; services: BookingService[]; }; type TimeBlocker = { @@ -554,7 +555,7 @@ {#if !hasOverlap}
{b.user?.full_name || 'Guest'}{formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}
{#if heightPx > 36}