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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-20 16:59:31 +01:00
co-authored by Sisyphus
parent 1cf7083bcc
commit 1a3829b4d9
27 changed files with 597 additions and 300 deletions
@@ -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 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Appointment Details
</h3>
{#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}`}
<div class="mb-3 text-sm text-gray-500">
{fullName}, (formerly {formerName})
</div>
{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Scheduled Date & Time</div>
@@ -482,6 +498,8 @@
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
{#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 @@
</span>
</div>
{#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}
<div class="flex items-center justify-between">
<span class="text-sm text-red-600">Refunds</span>
<span class="font-semibold text-red-600">-£{totalRefunds.toFixed(2)}</span>
</div>
{/if}
{#if balanceDue > 0.01}
<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 text-red-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)}
</span>
</div>
{/if}
</div>
</div>
{#if selectedBooking.payments && selectedBooking.payments.length > 0}
{#if (selectedBooking.payments && selectedBooking.payments.length > 0) || (selectedBooking.refunds && selectedBooking.refunds.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
@@ -576,6 +601,30 @@
</div>
</div>
{/each}
{#each selectedBooking.refunds ?? [] as refund (refund.id)}
<div class="rounded-md border border-red-200 bg-red-50 p-3">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-red-700">Refund</span>
<span class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">
{refund.status}
</span>
</div>
<div class="mt-1 text-xs text-red-600">
{refund.reason || 'Refund processed'}
</div>
<div class="mt-1 text-xs text-gray-400">
{new SvelteDate(refund.created_at).toLocaleString()}
</div>
</div>
<div class="text-right font-semibold text-red-600">
-£{refund.amount.toFixed(2)}
</div>
</div>
</div>
{/each}
</div>
</div>
{/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))}
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
<div class="flex items-start gap-2.5">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div class="text-amber-900">
<p class="font-medium">Awaiting admin approval</p>
<p class="mt-0.5 text-amber-700">
{#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}
<div class="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
<div class="flex items-start gap-2.5">
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 16v-4M12 8h.01" />
<circle cx="12" cy="12" r="10" />
</svg>
<div class="text-amber-900">
<p class="font-medium">Awaiting admin approval</p>
<p class="mt-0.5 text-amber-700">
{#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}
</p>
{#if addedServices.length > 0}
<p class="mt-1 text-amber-700">
<span class="font-medium text-green-700">Services added:</span> {addedServices.join(', ')}
</p>
{/if}
{#if servicesChanged}
{timeChanged ? ' • ' : ''}Service change requested
{#if removedServices.length > 0}
<p class="mt-0.5 text-amber-700">
<span class="font-medium text-red-600">Services removed:</span> {removedServices.join(', ')}
</p>
{/if}
{pendingEditRequest.notes ? (timeChanged || servicesChanged ? ' — ' : '') + pendingEditRequest.notes : ''}
</p>
<p class="mt-0.5 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
<p class="mt-1 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
</div>
</div>
</div>
</div>
{/if}
{/if}
<div class="flex gap-2">