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">
@@ -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}
<span class="text-amber-600" title="Booked first"></span>
{/if}
{ob.user?.full_name || 'Unknown'}
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
</div>
<div class="text-xs text-gray-500">
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', {
@@ -493,7 +498,7 @@
<div class="mb-4 flex items-center gap-4">
<div>
<div class="text-lg font-semibold">{booking.user?.full_name || '—'}</div>
<div class="text-lg font-semibold">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
</div>
</div>
@@ -649,7 +654,7 @@
<p class="font-medium">Applied Discounts</p>
{#each booking.discounts as d}
<p class="mt-1">
- {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)}
</p>
{/each}
@@ -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<string | null>(null);
let guestName = $state('');
@@ -1041,7 +1042,7 @@
onclick={() => (selectedUserId = user.id)}
>
<div>
<div class="text-base font-medium">{user.fullName}</div>
<div class="text-base font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -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}
<img
src={selectedBooking.user.profile_pic_url}
alt={selectedBooking.user.full_name}
alt={formatUserName(selectedBooking.user.full_name, selectedBooking.user.previous_first_name, selectedBooking.user.previous_last_name)}
class="h-16 w-16 rounded-full object-cover ring-4 ring-blue-200"
/>
{/if}
<div>
<div class="text-lg font-semibold">{selectedBooking.user?.full_name || '—'}</div>
<div class="text-lg font-semibold">{formatUserName(selectedBooking.user?.full_name || '—', selectedBooking.user?.previous_first_name, selectedBooking.user?.previous_last_name)}</div>
{#if selectedBooking.user?.date_of_birth}
<div class="text-sm text-gray-500">
{calculateAge(selectedBooking.user.date_of_birth)} years old
@@ -510,6 +534,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}
@@ -533,21 +559,28 @@
</span>
</div>
{#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}
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
<span class="font-medium text-gray-900">Balance Due</span>
<span
class="text-lg font-bold {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
class="text-lg font-bold {balanceDue > 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)}
</span>
</div>
</div>
</div>
<!-- Payments -->
{#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
@@ -614,6 +647,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">
{formatDateTime(refund.created_at)}
</div>
</div>
<div class="text-right font-semibold text-red-600">
{refund.amount.toFixed(2)}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
@@ -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<string[]>(['']);
let currentPage = $state(1);
let totalPages = $state(1);
let nextCursor = $state<string | null>(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 @@
</Button>
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if loadingSearch}
<div class="flex items-center justify-center p-4">
<Skeleton class="h-4 w-32" />
</div>
{:else if bookings.length === 0}
{#if bookings.length === 0 && !loadingSearch}
<div class="text-center text-sm text-gray-500">No bookings found.</div>
{:else}
{#each bookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">
{formatBookingDateTime(b.start_time)}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
{#if b.deposit_required}
{#if b.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{:else if bookings.length > 0}
<div class="space-y-2 {loadingSearch ? 'opacity-60' : ''}">
{#each bookings as b (b.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div class="flex-1">
<div class="font-medium">
{formatBookingDateTime(b.start_time)}
</div>
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span class={getStatusClasses(b.status)}>
<span class={getStatusDotClasses(b.status)}></span>
{b.status}
</span>
{#if b.deposit_required}
{#if b.status === 'pending'}
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
>
Will Require Deposit
</span>
{:else if ['confirmed', 'in_progress', 'completed'].includes(b.status)}
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{b.deposit_paid ? 'bg-green-100 text-green-800' : 'bg-orange-100 text-orange-800'}"
>
{b.deposit_paid ? 'Deposit Paid' : 'Deposit Due'}
</span>
{/if}
{/if}
{/if}
<span>{b.user?.full_name || 'Unknown User'}</span>
<span>
- {formatServices(b.services)}
</span>
<span>{formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)}</span>
<span>
- {formatServices(b.services)}
</span>
</div>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
</div>
{/each}
{/each}
</div>
{/if}
</div>
@@ -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 @@
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{booking.user?.full_name || '—'}</div>
<div class="font-medium">{formatUserName(booking.user?.full_name || '—', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
@@ -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 @@
<Modal.Header>
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
<Modal.Description>
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.
</Modal.Description>
</Modal.Header>
@@ -194,22 +197,30 @@
<div class="space-y-2">
<div>
<div class="text-xs text-gray-500">Name</div>
<div class="font-medium">{editRequest.user.full_name}</div>
<div class="font-medium">{formatUserName(editRequest.user.full_name, editRequest.user.previous_first_name, editRequest.user.previous_last_name)}</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Phone</div>
<div class="font-medium">{editRequest.user.phone || '—'}</div>
<div class="font-medium">
{#if editRequest.user.phone}
<a href="tel:{editRequest.user.phone}" class="text-blue-600 hover:underline">{editRequest.user.phone}</a>
{:else}{/if}
</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-all">{editRequest.user.email || '—'}</div>
<div class="font-medium break-all">
{#if editRequest.user.email}
<a href="mailto:{editRequest.user.email}" class="text-blue-600 hover:underline">{editRequest.user.email}</a>
{:else}{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Date & Time Change -->
{#if isTimeChanged()}
<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">
Date & Time Change
@@ -227,26 +238,23 @@
)}
</div>
</div>
{#if isTimeChanged()}
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
</div>
<div>
<div class="mb-1 text-xs text-gray-500">After</div>
<div class="font-medium text-emerald-700">
{formatDateLine1(editRequest.proposed.start_time!)}
</div>
{:else}
<div class="text-sm text-gray-500 italic">No change</div>
{/if}
<div class="text-sm text-gray-600">
{formatDateLine2(
editRequest.proposed.start_time!,
getDuration(editRequest.proposed.services)
)}
</div>
</div>
</div>
</div>
{/if}
<!-- Services Change -->
{#if areServicesChanged()}
<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 Change
@@ -314,8 +322,9 @@
</div>
</div>
</div>
{/if}
<!-- Notes Change -->
{#if editRequest.proposed.notes !== editRequest.original.notes}
<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">
Booking Notes Change
@@ -327,14 +336,11 @@
</div>
<div>
<div class="mb-1 text-xs text-gray-500">Proposed</div>
{#if editRequest.proposed.notes && editRequest.proposed.notes !== editRequest.original.notes}
<div class="text-sm">{editRequest.proposed.notes}</div>
{:else}
<div class="text-sm text-gray-500 italic">No change</div>
{/if}
<div class="text-sm">{editRequest.proposed.notes}</div>
</div>
</div>
</div>
{/if}
<!-- Request Notes -->
{#if editRequest.notes}
@@ -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 @@
<th class="py-3 text-center font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#if loading || loadingSearch}
{#each Array(3) as _, i (i)}
<tr class="border-b">
<td class="py-3"><Skeleton class="h-4 w-32" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-16" /></td>
<td class="py-3"><Skeleton class="h-4 w-24" /></td>
<td class="py-3"><Skeleton class="h-4 w-20" /></td>
<td class="py-3 text-center"><Skeleton class="mx-auto h-4 w-24" /></td>
<tbody class={sortedCards.length > 0 && (loading || loadingSearch) ? 'opacity-60' : ''}>
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<tr>
<td colspan="8" class="py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
</td>
</tr>
{/each}
{:else if sortedCards.length === 0}
<tr>
<td colspan="8" class="py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
</td>
</tr>
{:else}
{#each sortedCards as gc (gc.id)}
{:else if sortedCards.length > 0}
{#each sortedCards as gc (gc.id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
<td class="py-3 font-medium text-gray-600"
@@ -1101,22 +1094,15 @@
<!-- Mobile view - Cards -->
<div class="grid gap-4 md:hidden">
{#if loading || loadingSearch}
{#each Array(2) as _, i (i)}
<div class="space-y-3 rounded-lg border p-4">
<Skeleton class="h-4 w-32" />
<Skeleton class="h-4 w-full" />
<Skeleton class="h-4 w-24" />
</div>
{/each}
{:else if sortedCards.length === 0}
{#if sortedCards.length === 0 && !loading && !loadingSearch}
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
{activeSection === 'expired_cards'
? 'No expired gift cards found.'
: 'No gift cards generated yet.'}
</div>
{:else}
{#each sortedCards as gc (gc.id)}
{:else if sortedCards.length > 0}
<div class={loading || loadingSearch ? 'opacity-60' : ''}>
{#each sortedCards as gc (gc.id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-mono font-bold text-gray-900">{formatCardCode(gc.id)}</span>
@@ -1204,6 +1190,7 @@
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -1279,7 +1266,7 @@
{:else}
{#each sortedBalances as ub (ub.user_id)}
<tr class="border-b hover:bg-gray-50">
<td class="py-3 font-medium text-gray-900">{ub.name}</td>
<td class="py-3 font-medium text-gray-900">{ub.name}<!-- TODO: add formerly name when previous name data is available --></td>
<td class="py-3 text-gray-600">{ub.email}</td>
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</td>
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
@@ -1308,7 +1295,7 @@
{#each sortedBalances as ub (ub.user_id)}
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900">{ub.name}</span>
<span class="font-medium text-gray-900">{ub.name}<!-- TODO: add formerly name when previous name data is available --></span>
</div>
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
<div class="col-span-2">
@@ -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;
}
}}
>
<div>
<div class="text-sm font-semibold">{currentCustomerInfo.name}</div>
<div class="text-sm font-semibold">{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}</div>
{#if currentCustomerInfo.email}
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
{/if}
@@ -1641,17 +1630,12 @@
</div>
<div class="max-h-[220px] overflow-y-auto rounded-md border border-gray-200">
{#if generateLoadingUsers}
<div class="space-y-2 p-2">
<Skeleton class="h-10 w-full" />
<Skeleton class="h-10 w-full" />
</div>
{:else if generateUsers.length === 0}
{#if generateUsers.length === 0 && !generateLoadingUsers}
<div class="flex items-center justify-center p-8 text-xs text-gray-500">
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
</div>
{:else}
<ul class="divide-y divide-gray-200">
{:else if generateUsers.length > 0}
<ul class="divide-y divide-gray-200 {generateLoadingUsers ? 'opacity-60' : ''}">
{#each generateUsers.slice(0, 5) as user (user.id)}
<li>
<button
@@ -1660,12 +1644,12 @@
? 'bg-fuchsia-100 font-medium'
: 'hover:bg-fuchsia-50/40'}"
onclick={() => {
selectedCustomer = { id: user.id, name: user.fullName, email: user.email };
selectedCustomer = { id: user.id, name: user.fullName, email: user.email, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName };
isGuestSelected = false;
}}
>
<div>
<div class="text-sm font-semibold">{user.fullName}</div>
<div class="text-sm font-semibold">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -1736,7 +1720,7 @@
<span class="text-xs text-gray-400 font-semibold uppercase tracking-wider block">Customer</span>
<p class="mt-0.5 font-medium text-gray-900">
{#if selectedCustomer}
{selectedCustomer.name} (Member)
{formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)} (Member)
{:else if isGuestSelected}
Walk-in Guest
{/if}
@@ -1865,22 +1849,6 @@
/>
</div>
<div class="grid grid-cols-4 gap-2">
{#each [Number(generateAmount), 10, 20, 50] as val}
<button
type="button"
class="rounded border border-gray-200 py-1.5 text-center text-xs font-semibold hover:bg-gray-50"
onclick={() => (cashAmount = val.toFixed(2))}
>
{#if val === Number(generateAmount)}
Exact
{:else}
£{val}
{/if}
</button>
{/each}
</div>
{#if Number(cashAmount) > Number(generateAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold">{formatCurrency(Number(cashAmount) - Number(generateAmount))}</span>
@@ -2141,22 +2109,6 @@
/>
</div>
<div class="grid grid-cols-4 gap-2">
{#each [Number(topUpAmount), 10, 20, 50] as val}
<button
type="button"
class="rounded border border-gray-200 py-1.5 text-center text-xs font-semibold hover:bg-gray-50"
onclick={() => (cashAmount = val.toFixed(2))}
>
{#if val === Number(topUpAmount)}
Exact
{:else}
£{val}
{/if}
</button>
{/each}
</div>
{#if Number(cashAmount) > Number(topUpAmount)}
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
Change due: <span class="font-bold">{formatCurrency(Number(cashAmount) - Number(topUpAmount))}</span>
@@ -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 @@
})}
</div>
<div class="mt-1 text-sm text-gray-500">
{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
</div>
@@ -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)}
<div class="rounded-md border border-amber-200 bg-white p-3">
<div class="min-w-0">
<div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -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<AdminUserDetail | null>(null);
let bookingUserHistory = $state<Booking[]>([]);
let totalBookings = $state(0);
let cursors = $state<string[]>(['']);
let nextCursor = $state<string | null>(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}
<div class="space-y-6 px-4 pb-4">
<!-- Personal Information -->
<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">
Personal Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Full Name</div>
<div class="font-medium">{selectedUser.fullName}</div>
</div>
<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">
Personal Information
</h3>
<div class="grid gap-3 md:grid-cols-2">
<div>
<div class="text-xs text-gray-500">Full Name</div>
<div class="font-medium">{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}</div>
</div>
<div>
<div class="text-xs text-gray-500">Email</div>
<div class="font-medium break-words" title={selectedUser.email}>
@@ -371,16 +394,10 @@
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
Booking History ({totalBookings})
</h3>
{#if loadingBookings}
<div class="space-y-2">
{#each Array(3) as _, i (i)}
<div class="h-20 animate-pulse rounded-md bg-gray-200"></div>
{/each}
</div>
{:else if bookingUserHistory.length === 0}
{#if bookingUserHistory.length === 0 && !loadingBookings}
<div class="text-center text-sm text-gray-500">No bookings found</div>
{:else}
<div class="space-y-2">
{:else if bookingUserHistory.length > 0}
<div class="space-y-2 {loadingBookings ? 'opacity-60' : ''}">
{#each bookingUserHistory as booking (booking.id)}
<div class="rounded-md border border-gray-300 bg-white p-3">
<div class="flex items-start justify-between">
@@ -619,7 +636,7 @@
<PatchTestModal
bind:open={showPatchTestModal}
userId={selectedUser.id}
userName={selectedUser.fullName}
userName={formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
onPatchTestAdded={() => {
fetchUserDetails();
}}
@@ -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<UserListItem[]>([]);
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<string[]>(['']);
let currentPage = $state(1);
let totalPages = $state(1);
let nextCursor = $state<string | null>(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);
}
});
</script>
@@ -142,7 +176,7 @@
</div>
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
{#if initialLoad || loadingSearch}
{#if initialLoad}
{#each Array(3) as _, i (i)}
<div class="rounded bg-gray-50 p-2">
<Skeleton class="mb-1 h-4 w-32" />
@@ -154,17 +188,19 @@
{userQuery ? 'No users found matching your search.' : 'No users found.'}
</div>
{:else}
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{user.fullName}</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
<div class="relative {loadingSearch ? 'opacity-60' : ''}">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
<div>
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
<div class="text-xs text-gray-500">
{user.email || '—'}{user.phone || '—'}
</div>
</div>
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
</div>
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
</div>
{/each}
{/each}
</div>
{/if}
</div>
@@ -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<string>('');
let isReserving = $state(false);
let reservedDuration = $state(0);
let reservedStartTime = $state<string | null>(null);
let shortestServiceMinutes = $state<number | null>(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 @@
<WalkInCreateModal
bind:open={showCreateModal}
maxSlotDuration={reservedDuration}
availableStartTime={undefined}
availableStartTime={reservedStartTime ?? undefined}
{reservationExpiresAt}
onclose={handleModalClose}
/>
@@ -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<string | null>(null);
let guestName = $state('');
@@ -717,7 +718,7 @@
onclick={() => (selectedUserId = user.id)}
>
<div>
<div class="text-base font-medium">{user.full_name}</div>
<div class="text-base font-medium">{formatUserName(user.full_name, user.previous_first_name, user.previous_last_name)}</div>
<div class="text-xs text-gray-500">
{#if user.email && user.phone}
{user.email}{user.phone}
@@ -47,7 +47,7 @@
let paymentResult = $state<PaymentResult | null>(null);
let error = $state<string | null>(null);
let stamps = $state(booking.user?.loyalty_stamps ?? 0);
let stamps = $derived(booking.user?.loyalty_stamps ?? 0);
let useLoyalty = $state(false);
let loyaltyEligible = $derived(
@@ -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<Array<{ id: string; fullName: string; email?: string; phone?: string }>>([]);
let users = $state<Array<{ id: string; fullName: string; email?: string; phone?: string; previousFirstName?: string | null; previousLastName?: string | null }>>([]);
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()}
</div>
<div>
<div class="text-sm font-medium">{currentCustomerInfo.name}</div>
<div class="text-sm font-medium">{formatUserName(currentCustomerInfo.name, currentCustomerInfo.previousFirstName, currentCustomerInfo.previousLastName)}</div>
{#if currentCustomerInfo.email}
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
{/if}
@@ -843,20 +848,14 @@
</div>
<div class="max-h-[260px] overflow-y-auto rounded-md border border-gray-200">
{#if loadingUsers}
<div class="space-y-2 p-2">
{#each Array(3) as _, i (i)}
<div class="h-10 w-full animate-pulse rounded bg-gray-100"></div>
{/each}
</div>
{:else if users.length === 0}
{#if users.length === 0 && !loadingUsers}
<div class="flex items-center justify-center p-6 text-sm text-gray-500">
{userQuery
? 'No customers found. Try a different search.'
: 'Search for a customer above to get started.'}
</div>
{:else}
<ul class="divide-y divide-gray-200">
{:else if users.length > 0}
<ul class="divide-y divide-gray-200 {loadingUsers ? 'opacity-60' : ''}">
{#each users as userItem (userItem.id)}
<li>
<button
@@ -865,7 +864,7 @@
onclick={() => selectCustomer(userItem)}
>
<div>
<div class="text-base font-medium">{userItem.fullName}</div>
<div class="text-base font-medium">{formatUserName(userItem.fullName, userItem.previousFirstName, userItem.previousLastName)}</div>
<div class="text-xs text-gray-500">
{userItem.email}{#if userItem.email && userItem.phone}
&middot;
@@ -922,7 +921,7 @@
<Dialog.Description>
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
{#if selectedCustomer}
&mdash; {selectedCustomer.name}
&mdash; {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
{/if}.
</Dialog.Description>
</Dialog.Header>
@@ -1120,7 +1119,7 @@
<Dialog.Description>
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
{#if selectedCustomer}
&mdash; {selectedCustomer.name}
&mdash; {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
{/if}.
</Dialog.Description>
</Dialog.Header>
@@ -220,7 +220,10 @@ import { SvelteDate } from 'svelte/reactivity';
let defaultType = $derived(
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
);
let paymentType = $state<'full' | 'partial' | 'deposit'>(defaultType as 'full' | 'partial' | 'deposit');
let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
$effect(() => {
paymentType = defaultType as 'full' | 'partial' | 'deposit';
});
let pollingInterval: ReturnType<typeof setInterval> | null = null;
@@ -923,6 +926,11 @@ import { SvelteDate } from 'svelte/reactivity';
disabled={false}
/>
{/if}
<!-- Card validation error shown inline near card input -->
{#if cardValidationError}
<p class="text-sm text-red-600">{cardValidationError}</p>
{/if}
{/if}
{#if depositPolicyWarning}
@@ -965,9 +973,6 @@ import { SvelteDate } from 'svelte/reactivity';
</div>
<!-- Pay button -->
{#if payButtonError}
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
class="w-full"
@@ -1034,13 +1039,13 @@ import { SvelteDate } from 'svelte/reactivity';
disabled={status !== 'idle'}
/>
</div>
{#if partialValidationError}
<p class="mt-1 text-sm text-red-600">{partialValidationError}</p>
{/if}
</div>
{/if}
<!-- Pay button -->
{#if payButtonError}
<p class="text-sm text-red-600">{payButtonError}</p>
{/if}
<Button
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
class="w-full"
@@ -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 Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -35,6 +36,8 @@
full_name: string;
phone?: string;
profile_pic_url?: string;
previous_first_name?: string;
previous_last_name?: string;
};
services: Array<{
service_name?: string;
@@ -645,7 +648,7 @@
{#if activeAppointment.user?.profile_pic_url}
<img
src={activeAppointment.user.profile_pic_url}
alt={activeAppointment.user.full_name}
alt={formatUserName(activeAppointment.user.full_name, activeAppointment.user.previous_first_name, activeAppointment.user.previous_last_name)}
class="h-14 w-14 shrink-0 rounded-full object-cover ring-2 ring-blue-200 sm:h-16 sm:w-16 sm:ring-4 md:h-20 md:w-20"
/>
{:else}
@@ -660,11 +663,11 @@
{initials}
</div>
{/if}
<div class="min-w-0">
<div class="truncate text-lg font-semibold">
{activeAppointment.user?.full_name || 'Guest'}
</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
<div class="min-w-0">
<div class="truncate text-lg font-semibold">
{formatUserName(activeAppointment.user?.full_name || 'Guest', activeAppointment.user?.previous_first_name, activeAppointment.user?.previous_last_name)}
</div>
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
{#if activeAppointment.user}
<button
type="button"
@@ -8,6 +8,7 @@
import { Skeleton } from '$lib/components/ui/skeleton';
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
import { formatUserName } from '$lib/utils/nameDisplay';
interface Props {
openBookingModal?: (bookingId: string) => void;
@@ -47,6 +48,8 @@
full_name: string;
email: string;
phone: string;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
}
@@ -56,6 +59,8 @@
start_time: string;
user_id: string;
user_name: string; // This is a string, not an object
previous_first_name?: string | null;
previous_last_name?: string | null;
services: string[]; // Array of service names
duration_minutes: number;
created_at: string;
@@ -359,7 +364,7 @@
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="font-medium">{approval.user_name || 'Guest'}</div>
<div class="font-medium">{formatUserName(approval.user_name || 'Guest', approval.previous_first_name, approval.previous_last_name)}</div>
<div class="mt-1 text-sm text-gray-600">
{approval.services.join(', ')}
</div>
@@ -404,7 +409,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1">
<div class="flex items-center gap-2">
<span class="font-medium">{er.user?.full_name || 'Unknown'}</span>
<span class="font-medium">{formatUserName(er.user?.full_name || 'Unknown', er.user?.previous_first_name, er.user?.previous_last_name)}</span>
<span class="text-xs font-medium text-amber-600">Edit/Reschedule</span>
</div>
<div class="mt-1 text-sm text-gray-600">
@@ -3,6 +3,7 @@
import { SvelteDate } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { sanitizeText } from '$lib/utils/toast-safe';
import { formatUserName } from '$lib/utils/nameDisplay';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
@@ -35,6 +36,8 @@
user_id: string;
services: string[];
duration_minutes: number;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
type TimeBlocker = {
@@ -52,7 +55,7 @@
start_time: string;
duration_minutes: number;
status: string;
user: { id: string; full_name: string; email: string | null; phone: string | null } | null;
user: { id: string; full_name: string; email: string | null; phone: string | null; previous_first_name?: string | null; previous_last_name?: string | null } | null;
services: string[];
};
@@ -742,7 +745,7 @@
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
onclick={() => openUserModal(item.data.user_id)}
>
{item.data.user_name}
{formatUserName(item.data.user_name, item.data.previous_first_name, item.data.previous_last_name)}
</button>
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
@@ -997,7 +1000,7 @@
{#each overlappingBookings as booking (booking.id)}
<div class="rounded-md border border-amber-200 bg-white p-2">
<div class="min-w-0">
<div class="text-sm font-medium">{booking.user?.full_name || 'Unknown'}</div>
<div class="text-sm font-medium">{formatUserName(booking.user?.full_name || 'Unknown', booking.user?.previous_first_name, booking.user?.previous_last_name)}</div>
<div class="text-xs text-gray-500">
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
hour: 'numeric',
@@ -12,6 +12,8 @@
user_id: string;
services: string[];
duration_minutes: number;
previous_first_name?: string | null;
previous_last_name?: string | null;
};
type DayWorkingHours = {
+3
View File
@@ -21,7 +21,10 @@ export interface User {
loyaltyStamps?: number;
referralCode?: string;
referralCodeUses?: number;
referralSavings?: number;
profilePicUrl?: string;
previousFirstName?: string;
previousLastName?: string;
}
class AuthStore {
+6 -1
View File
@@ -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;
+23
View File
@@ -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
);
}
+16
View File
@@ -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;
}
@@ -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 @@
</p>
</div>
{:else}
<div class="space-y-3">
<div class="space-y-3 {loading ? 'opacity-60' : ''}">
{#each notifications as n (n.id)}
{@const actionable =
!n.acknowledged_at &&
@@ -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}
<button
type="button"
aria-label="View booking for {b.user?.full_name || 'Guest'}"
aria-label="View booking for {formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}"
class="absolute inset-x-0.5 z-10 cursor-pointer overflow-hidden rounded border px-1.5 py-0.5 text-left text-xs transition-shadow hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-blue-500"
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
b.status
@@ -570,7 +571,7 @@
style="background:{dotColor(b.status)}"
></div>
<span class="truncate font-medium"
>{b.user?.full_name || 'Guest'}</span
>{formatUserName(b.user?.full_name || 'Guest', b.user?.previous_first_name, b.user?.previous_last_name)}</span
>
</div>
{#if heightPx > 36}