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:
@@ -9,6 +9,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
import UserPaymentModal from '$lib/components/payments/UserPaymentModal.svelte';
|
||||||
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
import EditRequestModal from '$lib/components/account/EditRequestModal.svelte';
|
||||||
|
import { computeBalanceDue } from '$lib/utils/booking';
|
||||||
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
import type { Booking, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -68,6 +69,14 @@
|
|||||||
.reduce((sum, p) => sum + p.amount, 0) || 0
|
.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(
|
let depositOutstanding = $derived(
|
||||||
selectedBooking?.deposit_required && !selectedBooking?.deposit_paid
|
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 === 'online_square') return 'Online Card';
|
||||||
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
||||||
if (payment.payment_method === 'cash') return 'Cash';
|
if (payment.payment_method === 'cash') return 'Cash';
|
||||||
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
||||||
if (payment.payment_method === 'discount') {
|
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);
|
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
||||||
if (discountList[discountPaymentsBefore]) {
|
if (discountList[discountPaymentsBefore]) {
|
||||||
const d = discountList[discountPaymentsBefore];
|
const d = discountList[discountPaymentsBefore];
|
||||||
@@ -365,6 +374,13 @@
|
|||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Appointment Details
|
Appointment Details
|
||||||
</h3>
|
</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 class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Scheduled Date & Time</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>
|
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
|
||||||
{#if d.discount_source === 'loyalty'}
|
{#if d.discount_source === 'loyalty'}
|
||||||
Loyalty Stamp Card (10% Off)
|
Loyalty Stamp Card (10% Off)
|
||||||
|
{:else if d.discount_source === 'referral'}
|
||||||
|
Referral Discount ({d.discount_percent}% Off)
|
||||||
{:else if d.campaign_name}
|
{:else if d.campaign_name}
|
||||||
{d.campaign_name} ({d.discount_percent}% Off)
|
{d.campaign_name} ({d.discount_percent}% Off)
|
||||||
{:else}
|
{:else}
|
||||||
@@ -505,20 +523,27 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-between border-t border-gray-300 pt-2">
|
||||||
<span class="font-medium text-gray-900">
|
<span class="font-medium text-gray-900">
|
||||||
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
|
{isFutureBooking ? 'Estimated Subtotal' : 'Amount Due'}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-lg font-bold text-red-600">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Payment History
|
Payment History
|
||||||
@@ -576,6 +601,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -611,6 +660,11 @@
|
|||||||
{#if pendingEditRequest}
|
{#if pendingEditRequest}
|
||||||
{@const timeChanged = pendingEditRequest.original.start_time && pendingEditRequest.proposed?.start_time && pendingEditRequest.original.start_time !== pendingEditRequest.proposed.start_time}
|
{@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))}
|
{@const servicesChanged = pendingEditRequest.proposed?.services?.length && JSON.stringify(pendingEditRequest.original.services?.map(s => s.name)) !== JSON.stringify(pendingEditRequest.proposed.services?.map(s => s.name))}
|
||||||
|
{@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="rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm">
|
||||||
<div class="flex items-start gap-2.5">
|
<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">
|
<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">
|
||||||
@@ -623,16 +677,23 @@
|
|||||||
{#if timeChanged}
|
{#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' })}
|
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}
|
||||||
{#if servicesChanged}
|
|
||||||
{timeChanged ? ' • ' : ''}Service change requested
|
|
||||||
{/if}
|
|
||||||
{pendingEditRequest.notes ? (timeChanged || servicesChanged ? ' — ' : '') + pendingEditRequest.notes : ''}
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-0.5 text-xs text-amber-500">We'll let you know once it's been reviewed</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 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}
|
||||||
|
<p class="mt-1 text-xs text-amber-500">We'll let you know once it's been reviewed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
import { formatDateTime, formatDuration, calculateAge } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
@@ -24,6 +25,8 @@
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
services?: Array<{
|
services?: Array<{
|
||||||
service_id: string;
|
service_id: string;
|
||||||
@@ -126,6 +129,8 @@
|
|||||||
user?: {
|
user?: {
|
||||||
full_name?: string;
|
full_name?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
services?: string[];
|
services?: string[];
|
||||||
}
|
}
|
||||||
@@ -409,7 +414,7 @@
|
|||||||
{#if oldest?.id === ob.id}
|
{#if oldest?.id === ob.id}
|
||||||
<span class="text-amber-600" title="Booked first">★</span>
|
<span class="text-amber-600" title="Booked first">★</span>
|
||||||
{/if}
|
{/if}
|
||||||
{ob.user?.full_name || 'Unknown'}
|
{formatUserName(ob.user?.full_name || 'Unknown', ob.user?.previous_first_name, ob.user?.previous_last_name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', {
|
{new SvelteDate(ob.start_time).toLocaleDateString('en-GB', {
|
||||||
@@ -493,7 +498,7 @@
|
|||||||
|
|
||||||
<div class="mb-4 flex items-center gap-4">
|
<div class="mb-4 flex items-center gap-4">
|
||||||
<div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -649,7 +654,7 @@
|
|||||||
<p class="font-medium">Applied Discounts</p>
|
<p class="font-medium">Applied Discounts</p>
|
||||||
{#each booking.discounts as d}
|
{#each booking.discounts as d}
|
||||||
<p class="mt-1">
|
<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)}
|
({d.discount_percent}% off): -£{d.discount_amount.toFixed(2)}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
import { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
let userQuery = $state('');
|
let userQuery = $state('');
|
||||||
// Updated type to include account_role for filtering
|
// Updated type to include account_role for filtering
|
||||||
let users = $state<
|
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 selectedUserId = $state<string | null>(null);
|
||||||
let guestName = $state('');
|
let guestName = $state('');
|
||||||
@@ -1041,7 +1042,7 @@
|
|||||||
onclick={() => (selectedUserId = user.id)}
|
onclick={() => (selectedUserId = user.id)}
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{#if user.email && user.phone}
|
{#if user.email && user.phone}
|
||||||
{user.email} • {user.phone}
|
{user.email} • {user.phone}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||||
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
|
import RescheduleModal from '$lib/components/admin/RescheduleModal.svelte';
|
||||||
import { formatDuration, formatDateTime, calculateAge } from '$lib/utils/format';
|
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 { POLICY } from '$lib/constants/policy';
|
||||||
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
|
import type { Booking, BookingService, BookingDiscount, Payment } from '$lib/types/booking';
|
||||||
|
import type { Refund } from '$lib/types/index';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -34,6 +37,12 @@
|
|||||||
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
.filter((p) => p.payment_method !== 'discount' && p.status === 'completed')
|
||||||
.reduce((sum, p) => sum + p.amount, 0)
|
.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(
|
let hoursUntilAppt = $derived(
|
||||||
selectedBooking
|
selectedBooking
|
||||||
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
? (new SvelteDate(selectedBooking.start_time).getTime() - new SvelteDate().getTime()) / (1000 * 60 * 60)
|
||||||
@@ -139,7 +148,9 @@
|
|||||||
referral_code: data.user.referral_code,
|
referral_code: data.user.referral_code,
|
||||||
referral_code_uses: data.user.referral_code_uses,
|
referral_code_uses: data.user.referral_code_uses,
|
||||||
created_at: data.user.created_at,
|
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,
|
: undefined,
|
||||||
|
|
||||||
@@ -174,6 +185,19 @@
|
|||||||
created_by: p.created_by
|
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
|
// Financials
|
||||||
total_amount: data.total_amount || 0,
|
total_amount: data.total_amount || 0,
|
||||||
amount_paid: data.amount_paid || 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 === 'online_square') return 'Online Card';
|
||||||
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
if (payment.payment_method === 'in_person_card') return 'Card Machine';
|
||||||
if (payment.payment_method === 'cash') return 'Cash';
|
if (payment.payment_method === 'cash') return 'Cash';
|
||||||
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
if (payment.payment_method === 'giftcard') return 'Gift Card';
|
||||||
if (payment.payment_method === 'discount') {
|
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);
|
const discountList = (discounts ?? []).filter(d => d.discount_amount > 0.01);
|
||||||
if (discountList[discountPaymentsBefore]) {
|
if (discountList[discountPaymentsBefore]) {
|
||||||
const d = discountList[discountPaymentsBefore];
|
const d = discountList[discountPaymentsBefore];
|
||||||
@@ -380,12 +404,12 @@
|
|||||||
{#if selectedBooking.user?.profile_pic_url}
|
{#if selectedBooking.user?.profile_pic_url}
|
||||||
<img
|
<img
|
||||||
src={selectedBooking.user.profile_pic_url}
|
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"
|
class="h-16 w-16 rounded-full object-cover ring-4 ring-blue-200"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div>
|
<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}
|
{#if selectedBooking.user?.date_of_birth}
|
||||||
<div class="text-sm text-gray-500">
|
<div class="text-sm text-gray-500">
|
||||||
{calculateAge(selectedBooking.user.date_of_birth)} years old
|
{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>
|
<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
|
||||||
{#if d.discount_source === 'loyalty'}
|
{#if d.discount_source === 'loyalty'}
|
||||||
Loyalty Stamp Card (10% Off)
|
Loyalty Stamp Card (10% Off)
|
||||||
|
{:else if d.discount_source === 'referral'}
|
||||||
|
Referral Discount ({d.discount_percent}% Off)
|
||||||
{:else if d.campaign_name}
|
{:else if d.campaign_name}
|
||||||
{d.campaign_name} ({d.discount_percent}% Off)
|
{d.campaign_name} ({d.discount_percent}% Off)
|
||||||
{:else}
|
{:else}
|
||||||
@@ -533,21 +559,28 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<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="font-medium text-gray-900">Balance Due</span>
|
||||||
<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-red-600'
|
||||||
: 'text-green-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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payments -->
|
<!-- 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">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Payment History
|
Payment History
|
||||||
@@ -614,6 +647,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import type { Booking } from '$lib/types/booking';
|
import type { Booking } from '$lib/types/booking';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props();
|
let { openBookingModal }: { openBookingModal: (bookingId: string) => void } = $props();
|
||||||
@@ -18,18 +19,25 @@
|
|||||||
let totalBookings = $state(0);
|
let totalBookings = $state(0);
|
||||||
let bookingQuery = $state('');
|
let bookingQuery = $state('');
|
||||||
let loadingSearch = $state(false);
|
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 currentPage = $state(1);
|
||||||
let totalPages = $state(1);
|
let totalPages = $state(1);
|
||||||
|
let nextCursor = $state<string | null>(null);
|
||||||
let initialLoad = $state(true);
|
let initialLoad = $state(true);
|
||||||
|
|
||||||
// Fetch bookings from API
|
// Fetch bookings from API
|
||||||
async function fetchBookings(page: number = 1, search: string = '') {
|
async function fetchBookings(pageIdx: number = 0, search: string = '') {
|
||||||
loadingSearch = true;
|
loadingSearch = true;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
|
||||||
per_page: '3'
|
per_page: '3'
|
||||||
});
|
});
|
||||||
|
const cursor = cursors[pageIdx];
|
||||||
|
if (cursor) {
|
||||||
|
params.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
let url = '/api/admin/bookings';
|
let url = '/api/admin/bookings';
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
@@ -47,20 +55,28 @@
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.bookings && data.bookings.length === 0) {
|
if (!data.bookings || data.bookings.length === 0) {
|
||||||
bookings = [];
|
bookings = [];
|
||||||
totalBookings = 0;
|
totalBookings = 0;
|
||||||
totalPages = 1;
|
totalPages = 1;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
cursors = [''];
|
||||||
|
nextCursor = null;
|
||||||
loadingSearch = false;
|
loadingSearch = false;
|
||||||
initialLoad = false;
|
initialLoad = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bookings = data.bookings as Booking[];
|
bookings = (data.bookings as Booking[]) || [];
|
||||||
totalBookings = data.total || 0;
|
totalBookings = data.total || 0;
|
||||||
totalPages = data.totalPages ?? 1;
|
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 {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to load bookings: ' + text);
|
toast.error('Failed to load bookings: ' + text);
|
||||||
@@ -75,25 +91,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchBookings() {
|
function searchBookings() {
|
||||||
|
cursors = [''];
|
||||||
|
nextCursor = null;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchBookings(1, bookingQuery);
|
fetchBookings(0, bookingQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (currentPage < totalPages) {
|
if (!nextCursor || currentPage >= totalPages) return;
|
||||||
fetchBookings(currentPage + 1, bookingQuery);
|
// currentPage is 1-indexed; next page index = currentPage
|
||||||
}
|
fetchBookings(currentPage, bookingQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function previousPage() {
|
function previousPage() {
|
||||||
if (currentPage > 1) {
|
if (currentPage <= 1) return;
|
||||||
fetchBookings(currentPage - 1, bookingQuery);
|
// 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(() => {
|
$effect(() => {
|
||||||
fetchBookings();
|
if (initialLoad) {
|
||||||
|
fetchBookings(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Format booking date/time
|
// Format booking date/time
|
||||||
@@ -233,13 +253,10 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
||||||
{#if loadingSearch}
|
{#if bookings.length === 0 && !loadingSearch}
|
||||||
<div class="flex items-center justify-center p-4">
|
|
||||||
<Skeleton class="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
{:else if bookings.length === 0}
|
|
||||||
<div class="text-center text-sm text-gray-500">No bookings found.</div>
|
<div class="text-center text-sm text-gray-500">No bookings found.</div>
|
||||||
{:else}
|
{:else if bookings.length > 0}
|
||||||
|
<div class="space-y-2 {loadingSearch ? 'opacity-60' : ''}">
|
||||||
{#each bookings as b (b.id)}
|
{#each bookings as b (b.id)}
|
||||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -267,7 +284,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<span>• {b.user?.full_name || 'Unknown User'}</span>
|
<span>• {formatUserName(b.user?.full_name || 'Unknown User', b.user?.previous_first_name, b.user?.previous_last_name)}</span>
|
||||||
<span>
|
<span>
|
||||||
- {formatServices(b.services)}
|
- {formatServices(b.services)}
|
||||||
</span>
|
</span>
|
||||||
@@ -276,6 +293,7 @@
|
|||||||
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
<Button variant="outline" onclick={() => openBookingModal(b.id)}>View</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -95,7 +96,9 @@
|
|||||||
referral_code: data.user.referral_code,
|
referral_code: data.user.referral_code,
|
||||||
referral_code_uses: data.user.referral_code_uses,
|
referral_code_uses: data.user.referral_code_uses,
|
||||||
created_at: data.user.created_at,
|
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,
|
: undefined,
|
||||||
services: (data.services || []).map((s: BookingService) => ({
|
services: (data.services || []).map((s: BookingService) => ({
|
||||||
@@ -418,7 +421,7 @@
|
|||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Name</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>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
interface ServiceItem {
|
interface ServiceItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +37,8 @@
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@
|
|||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
|
<Modal.Title class="text-lg font-semibold">Booking Change Request</Modal.Title>
|
||||||
<Modal.Description>
|
<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.Description>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
|
|
||||||
@@ -194,22 +197,30 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Name</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>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Phone</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>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date & Time Change -->
|
{#if isTimeChanged()}
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Date & Time Change
|
Date & Time Change
|
||||||
@@ -227,7 +238,6 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if isTimeChanged()}
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-xs text-gray-500">After</div>
|
<div class="mb-1 text-xs text-gray-500">After</div>
|
||||||
<div class="font-medium text-emerald-700">
|
<div class="font-medium text-emerald-700">
|
||||||
@@ -240,13 +250,11 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
</div>
|
||||||
<div class="text-sm text-gray-500 italic">No change</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Services Change -->
|
{#if areServicesChanged()}
|
||||||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Services Change
|
Services Change
|
||||||
@@ -314,8 +322,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Booking Notes Change
|
Booking Notes Change
|
||||||
@@ -327,14 +336,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-xs text-gray-500">Proposed</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>
|
<div class="text-sm">{editRequest.proposed.notes}</div>
|
||||||
{:else}
|
</div>
|
||||||
<div class="text-sm text-gray-500 italic">No change</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Request Notes -->
|
<!-- Request Notes -->
|
||||||
{#if editRequest.notes}
|
{#if editRequest.notes}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { EmailInput } from '$lib/components/ui/email-input';
|
import { EmailInput } from '$lib/components/ui/email-input';
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
|
|
||||||
interface GiftCard {
|
interface GiftCard {
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
email: string;
|
email: string;
|
||||||
balance: number;
|
balance: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
/* TODO: add previousFirstName/previousLastName when backend sends them */
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GiftCardSummary {
|
interface GiftCardSummary {
|
||||||
@@ -80,7 +82,7 @@
|
|||||||
let generateAmount = $state('');
|
let generateAmount = $state('');
|
||||||
let generateUserQuery = $state('');
|
let generateUserQuery = $state('');
|
||||||
let generateUsers = $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 generateLoadingUsers = $state(false);
|
||||||
let topUpAmount = $state('');
|
let topUpAmount = $state('');
|
||||||
@@ -103,11 +105,11 @@
|
|||||||
|
|
||||||
// Page 2: Customer selection state
|
// Page 2: Customer selection state
|
||||||
let generateCustomerTab = $state<'current' | 'member' | 'guest'>('current');
|
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);
|
let loadingCurrentCustomer = $state(false);
|
||||||
|
|
||||||
// Selection from Page 2
|
// 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);
|
let isGuestSelected = $state(false);
|
||||||
|
|
||||||
// Page 3: Recipient email input
|
// Page 3: Recipient email input
|
||||||
@@ -333,7 +335,9 @@
|
|||||||
id: appointment.user.id,
|
id: appointment.user.id,
|
||||||
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
|
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
|
||||||
email: appointment.user.email,
|
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,19 +997,8 @@
|
|||||||
<th class="py-3 text-center font-medium">Actions</th>
|
<th class="py-3 text-center font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody class={sortedCards.length > 0 && (loading || loadingSearch) ? 'opacity-60' : ''}>
|
||||||
{#if loading || loadingSearch}
|
{#if sortedCards.length === 0 && !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>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
{:else if sortedCards.length === 0}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8" class="py-8 text-center text-gray-500">
|
<td colspan="8" class="py-8 text-center text-gray-500">
|
||||||
{activeSection === 'expired_cards'
|
{activeSection === 'expired_cards'
|
||||||
@@ -1013,7 +1006,7 @@
|
|||||||
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
|
: 'No gift cards generated yet. Click "Generate Gift Card" to create one.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else if sortedCards.length > 0}
|
||||||
{#each sortedCards as gc (gc.id)}
|
{#each sortedCards as gc (gc.id)}
|
||||||
<tr class="border-b hover:bg-gray-50">
|
<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-mono font-bold text-gray-900">{formatCardCode(gc.id)}</td>
|
||||||
@@ -1101,21 +1094,14 @@
|
|||||||
|
|
||||||
<!-- Mobile view - Cards -->
|
<!-- Mobile view - Cards -->
|
||||||
<div class="grid gap-4 md:hidden">
|
<div class="grid gap-4 md:hidden">
|
||||||
{#if loading || loadingSearch}
|
{#if sortedCards.length === 0 && !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}
|
|
||||||
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
|
<div class="rounded-lg border border-dashed py-8 text-center text-gray-500">
|
||||||
{activeSection === 'expired_cards'
|
{activeSection === 'expired_cards'
|
||||||
? 'No expired gift cards found.'
|
? 'No expired gift cards found.'
|
||||||
: 'No gift cards generated yet.'}
|
: 'No gift cards generated yet.'}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if sortedCards.length > 0}
|
||||||
|
<div class={loading || loadingSearch ? 'opacity-60' : ''}>
|
||||||
{#each sortedCards as gc (gc.id)}
|
{#each sortedCards as gc (gc.id)}
|
||||||
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -1204,6 +1190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1279,7 +1266,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each sortedBalances as ub (ub.user_id)}
|
{#each sortedBalances as ub (ub.user_id)}
|
||||||
<tr class="border-b hover:bg-gray-50">
|
<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 text-gray-600">{ub.email}</td>
|
||||||
<td class="py-3 font-semibold text-primary">{formatCurrency(ub.balance)}</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>
|
<td class="py-3 text-gray-600">{formatDate(ub.updated_at)}</td>
|
||||||
@@ -1308,7 +1295,7 @@
|
|||||||
{#each sortedBalances as ub (ub.user_id)}
|
{#each sortedBalances as ub (ub.user_id)}
|
||||||
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
<div class="space-y-3 rounded-lg border p-4 hover:bg-gray-50">
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
||||||
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
|
<div class="grid grid-cols-2 gap-2 border-t border-b py-2 text-xs text-gray-600">
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
@@ -1593,14 +1580,16 @@
|
|||||||
selectedCustomer = {
|
selectedCustomer = {
|
||||||
id: currentCustomerInfo.id,
|
id: currentCustomerInfo.id,
|
||||||
name: currentCustomerInfo.name,
|
name: currentCustomerInfo.name,
|
||||||
email: currentCustomerInfo.email
|
email: currentCustomerInfo.email,
|
||||||
|
previousFirstName: currentCustomerInfo.previousFirstName,
|
||||||
|
previousLastName: currentCustomerInfo.previousLastName
|
||||||
};
|
};
|
||||||
isGuestSelected = false;
|
isGuestSelected = false;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<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}
|
{#if currentCustomerInfo.email}
|
||||||
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
|
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1641,17 +1630,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[220px] overflow-y-auto rounded-md border border-gray-200">
|
<div class="max-h-[220px] overflow-y-auto rounded-md border border-gray-200">
|
||||||
{#if generateLoadingUsers}
|
{#if generateUsers.length === 0 && !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}
|
|
||||||
<div class="flex items-center justify-center p-8 text-xs text-gray-500">
|
<div class="flex items-center justify-center p-8 text-xs text-gray-500">
|
||||||
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
|
{generateUserQuery ? 'No members found.' : 'Search for a member above.'}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if generateUsers.length > 0}
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200 {generateLoadingUsers ? 'opacity-60' : ''}">
|
||||||
{#each generateUsers.slice(0, 5) as user (user.id)}
|
{#each generateUsers.slice(0, 5) as user (user.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -1660,12 +1644,12 @@
|
|||||||
? 'bg-fuchsia-100 font-medium'
|
? 'bg-fuchsia-100 font-medium'
|
||||||
: 'hover:bg-fuchsia-50/40'}"
|
: 'hover:bg-fuchsia-50/40'}"
|
||||||
onclick={() => {
|
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;
|
isGuestSelected = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{#if user.email && user.phone}
|
{#if user.email && user.phone}
|
||||||
{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>
|
<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">
|
<p class="mt-0.5 font-medium text-gray-900">
|
||||||
{#if selectedCustomer}
|
{#if selectedCustomer}
|
||||||
{selectedCustomer.name} (Member)
|
{formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)} (Member)
|
||||||
{:else if isGuestSelected}
|
{:else if isGuestSelected}
|
||||||
Walk-in Guest
|
Walk-in Guest
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1865,22 +1849,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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)}
|
{#if Number(cashAmount) > Number(generateAmount)}
|
||||||
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
|
<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>
|
Change due: <span class="font-bold">{formatCurrency(Number(cashAmount) - Number(generateAmount))}</span>
|
||||||
@@ -2141,22 +2109,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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)}
|
{#if Number(cashAmount) > Number(topUpAmount)}
|
||||||
<div class="rounded-md bg-green-50 p-3 text-xs text-green-800">
|
<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>
|
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 { CalendarDate, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
@@ -495,7 +496,7 @@
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-500">
|
<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)
|
?.map((s) => s.service_name)
|
||||||
.join(', ') || 'No services'} · {bookingDuration} min
|
.join(', ') || 'No services'} · {bookingDuration} min
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
services: string[];
|
services: string[];
|
||||||
};
|
};
|
||||||
@@ -850,7 +853,7 @@
|
|||||||
{#each overlappingBookings as booking (booking.id)}
|
{#each overlappingBookings as booking (booking.id)}
|
||||||
<div class="rounded-md border border-amber-200 bg-white p-3">
|
<div class="rounded-md border border-amber-200 bg-white p-3">
|
||||||
<div class="min-w-0">
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import PatchTestModal from './PatchTestModal.svelte';
|
import PatchTestModal from './PatchTestModal.svelte';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -42,6 +43,8 @@
|
|||||||
dataRetentionConsent: boolean;
|
dataRetentionConsent: boolean;
|
||||||
dataConsentUpdatedAt?: string;
|
dataConsentUpdatedAt?: string;
|
||||||
socialLogins?: SocialLogin[];
|
socialLogins?: SocialLogin[];
|
||||||
|
previousFirstName?: string;
|
||||||
|
previousLastName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Booking = {
|
type Booking = {
|
||||||
@@ -84,6 +87,8 @@
|
|||||||
let selectedUser = $state<AdminUserDetail | null>(null);
|
let selectedUser = $state<AdminUserDetail | null>(null);
|
||||||
let bookingUserHistory = $state<Booking[]>([]);
|
let bookingUserHistory = $state<Booking[]>([]);
|
||||||
let totalBookings = $state(0);
|
let totalBookings = $state(0);
|
||||||
|
let cursors = $state<string[]>(['']);
|
||||||
|
let nextCursor = $state<string | null>(null);
|
||||||
let currentBookingPage = $state(1);
|
let currentBookingPage = $state(1);
|
||||||
let totalBookingPages = $state(1);
|
let totalBookingPages = $state(1);
|
||||||
let loadingBookings = $state(false);
|
let loadingBookings = $state(false);
|
||||||
@@ -144,15 +149,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function fetchUserBookings(page: number = 1) {
|
async function fetchUserBookings(pageIdx: number = 0) {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
loadingBookings = true;
|
loadingBookings = true;
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
|
||||||
per_page: '4'
|
per_page: '4'
|
||||||
});
|
});
|
||||||
|
const cursor = cursors[pageIdx];
|
||||||
|
if (cursor) {
|
||||||
|
params.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -164,10 +172,27 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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 || [];
|
bookingUserHistory = data.bookings || [];
|
||||||
totalBookings = data.total || 0;
|
totalBookings = data.total || 0;
|
||||||
currentBookingPage = data.page || 1;
|
|
||||||
totalBookingPages = data.totalPages || 1;
|
totalBookingPages = data.totalPages || 1;
|
||||||
|
currentBookingPage = pageIdx + 1;
|
||||||
|
nextCursor = data.next_cursor ?? null;
|
||||||
|
|
||||||
|
if (nextCursor && cursors.length <= pageIdx + 1) {
|
||||||
|
cursors = [...cursors, nextCursor];
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to load user bookings: ' + text);
|
toast.error('Failed to load user bookings: ' + text);
|
||||||
@@ -181,15 +206,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nextBookingPage() {
|
function nextBookingPage() {
|
||||||
if (currentBookingPage < totalBookingPages) {
|
if (!nextCursor || currentBookingPage >= totalBookingPages) return;
|
||||||
fetchUserBookings(currentBookingPage + 1);
|
fetchUserBookings(currentBookingPage);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function previousBookingPage() {
|
function previousBookingPage() {
|
||||||
if (currentBookingPage > 1) {
|
if (currentBookingPage <= 1) return;
|
||||||
fetchUserBookings(currentBookingPage - 1);
|
fetchUserBookings(currentBookingPage - 2);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCustomerRelationship() {
|
async function fetchCustomerRelationship() {
|
||||||
@@ -284,7 +307,7 @@
|
|||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Full Name</div>
|
<div class="text-xs text-gray-500">Full Name</div>
|
||||||
<div class="font-medium">{selectedUser.fullName}</div>
|
<div class="font-medium">{formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs text-gray-500">Email</div>
|
<div class="text-xs text-gray-500">Email</div>
|
||||||
@@ -371,16 +394,10 @@
|
|||||||
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
||||||
Booking History ({totalBookings})
|
Booking History ({totalBookings})
|
||||||
</h3>
|
</h3>
|
||||||
{#if loadingBookings}
|
{#if bookingUserHistory.length === 0 && !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}
|
|
||||||
<div class="text-center text-sm text-gray-500">No bookings found</div>
|
<div class="text-center text-sm text-gray-500">No bookings found</div>
|
||||||
{:else}
|
{:else if bookingUserHistory.length > 0}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2 {loadingBookings ? 'opacity-60' : ''}">
|
||||||
{#each bookingUserHistory as booking (booking.id)}
|
{#each bookingUserHistory as booking (booking.id)}
|
||||||
<div class="rounded-md border border-gray-300 bg-white p-3">
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
@@ -619,7 +636,7 @@
|
|||||||
<PatchTestModal
|
<PatchTestModal
|
||||||
bind:open={showPatchTestModal}
|
bind:open={showPatchTestModal}
|
||||||
userId={selectedUser.id}
|
userId={selectedUser.id}
|
||||||
userName={selectedUser.fullName}
|
userName={formatUserName(selectedUser.fullName, selectedUser.previousFirstName, selectedUser.previousLastName)}
|
||||||
onPatchTestAdded={() => {
|
onPatchTestAdded={() => {
|
||||||
fetchUserDetails();
|
fetchUserDetails();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
openUserModal: (userId: string) => void;
|
openUserModal: (userId: string) => void;
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
fullName: string;
|
fullName: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
previousFirstName?: string | null;
|
||||||
|
previousLastName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserListResponse = {
|
type UserListResponse = {
|
||||||
@@ -25,24 +28,32 @@
|
|||||||
page: number;
|
page: number;
|
||||||
perPage: number;
|
perPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
|
next_cursor?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let userQuery = $state('');
|
let userQuery = $state('');
|
||||||
let users = $state<UserListItem[]>([]);
|
let users = $state<UserListItem[]>([]);
|
||||||
let totalUsers = $state(0);
|
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 currentPage = $state(1);
|
||||||
let totalPages = $state(1);
|
let totalPages = $state(1);
|
||||||
|
let nextCursor = $state<string | null>(null);
|
||||||
let loadingSearch = $state(false);
|
let loadingSearch = $state(false);
|
||||||
let initialLoad = $state(true);
|
let initialLoad = $state(true);
|
||||||
|
|
||||||
async function fetchUsers(page: number = 1, search: string = '') {
|
async function fetchUsers(pageIdx: number = 0, search: string = '') {
|
||||||
loadingSearch = true;
|
loadingSearch = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
page: page.toString(),
|
|
||||||
per_page: '4'
|
per_page: '4'
|
||||||
});
|
});
|
||||||
|
const cursor = cursors[pageIdx];
|
||||||
|
if (cursor) {
|
||||||
|
params.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
params.append('q', search.trim());
|
params.append('q', search.trim());
|
||||||
@@ -58,10 +69,29 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: UserListResponse = await response.json();
|
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;
|
totalUsers = data.total;
|
||||||
currentPage = data.page;
|
|
||||||
totalPages = data.totalPages;
|
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 {
|
} else {
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
toast.error('Failed to load users: ' + text);
|
toast.error('Failed to load users: ' + text);
|
||||||
@@ -76,25 +106,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function searchUsers() {
|
function searchUsers() {
|
||||||
|
cursors = [''];
|
||||||
|
nextCursor = null;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
fetchUsers(1, userQuery);
|
fetchUsers(0, userQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (currentPage < totalPages) {
|
if (!nextCursor || currentPage >= totalPages) return;
|
||||||
fetchUsers(currentPage + 1, userQuery);
|
// currentPage is 1-indexed; next page index = currentPage
|
||||||
}
|
fetchUsers(currentPage, userQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
function previousPage() {
|
function previousPage() {
|
||||||
if (currentPage > 1) {
|
if (currentPage <= 1) return;
|
||||||
fetchUsers(currentPage - 1, userQuery);
|
// 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(() => {
|
$effect(() => {
|
||||||
fetchUsers();
|
if (initialLoad) {
|
||||||
|
fetchUsers(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -142,7 +176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
||||||
{#if initialLoad || loadingSearch}
|
{#if initialLoad}
|
||||||
{#each Array(3) as _, i (i)}
|
{#each Array(3) as _, i (i)}
|
||||||
<div class="rounded bg-gray-50 p-2">
|
<div class="rounded bg-gray-50 p-2">
|
||||||
<Skeleton class="mb-1 h-4 w-32" />
|
<Skeleton class="mb-1 h-4 w-32" />
|
||||||
@@ -154,10 +188,11 @@
|
|||||||
{userQuery ? 'No users found matching your search.' : 'No users found.'}
|
{userQuery ? 'No users found matching your search.' : 'No users found.'}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
<div class="relative {loadingSearch ? 'opacity-60' : ''}">
|
||||||
{#each users as user (user.id)}
|
{#each users as user (user.id)}
|
||||||
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">{user.fullName}</div>
|
<div class="font-medium">{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="text-xs text-gray-500">
|
||||||
{user.email || '—'} • {user.phone || '—'}
|
{user.email || '—'} • {user.phone || '—'}
|
||||||
</div>
|
</div>
|
||||||
@@ -165,6 +200,7 @@
|
|||||||
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
|
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import type { AvailableHoursDay, Service } from '$lib/types/booking';
|
import type { AvailableHoursDay, Service } from '$lib/types/booking';
|
||||||
|
import {
|
||||||
|
minutesToTime,
|
||||||
|
calculateMiddleWindow,
|
||||||
|
shouldApplyLunchProtection
|
||||||
|
} from '$lib/lunchProtection';
|
||||||
|
|
||||||
const RESERVATION_TTL = 15;
|
const RESERVATION_TTL = 15;
|
||||||
|
|
||||||
@@ -28,6 +33,7 @@
|
|||||||
let reservationCountdown = $state<string>('');
|
let reservationCountdown = $state<string>('');
|
||||||
let isReserving = $state(false);
|
let isReserving = $state(false);
|
||||||
let reservedDuration = $state(0);
|
let reservedDuration = $state(0);
|
||||||
|
let reservedStartTime = $state<string | null>(null);
|
||||||
|
|
||||||
let shortestServiceMinutes = $state<number | 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 {
|
function getLiveRemainingMinutes(): number | null {
|
||||||
if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null;
|
if (!slotInfo?.isAvailableNow || !slotInfo.slotEndMinutes) return null;
|
||||||
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
const now = currentTime.getHours() * 60 + currentTime.getMinutes();
|
||||||
@@ -116,6 +162,29 @@
|
|||||||
return;
|
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();
|
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
for (const slot of todayData.slots) {
|
for (const slot of todayData.slots) {
|
||||||
@@ -298,6 +367,7 @@
|
|||||||
|
|
||||||
const reserved = await reserveWalkInSlot(reserveTime, reserveDuration);
|
const reserved = await reserveWalkInSlot(reserveTime, reserveDuration);
|
||||||
if (reserved) {
|
if (reserved) {
|
||||||
|
reservedStartTime = reserveTime;
|
||||||
showCreateModal = true;
|
showCreateModal = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +378,7 @@
|
|||||||
reservationExpiresAt = null;
|
reservationExpiresAt = null;
|
||||||
reservationCountdown = '';
|
reservationCountdown = '';
|
||||||
reservedDuration = 0;
|
reservedDuration = 0;
|
||||||
|
reservedStartTime = null;
|
||||||
if ((window as any).__walkInCountdownInterval) {
|
if ((window as any).__walkInCountdownInterval) {
|
||||||
clearInterval((window as any).__walkInCountdownInterval);
|
clearInterval((window as any).__walkInCountdownInterval);
|
||||||
}
|
}
|
||||||
@@ -376,7 +447,7 @@
|
|||||||
<WalkInCreateModal
|
<WalkInCreateModal
|
||||||
bind:open={showCreateModal}
|
bind:open={showCreateModal}
|
||||||
maxSlotDuration={reservedDuration}
|
maxSlotDuration={reservedDuration}
|
||||||
availableStartTime={undefined}
|
availableStartTime={reservedStartTime ?? undefined}
|
||||||
{reservationExpiresAt}
|
{reservationExpiresAt}
|
||||||
onclose={handleModalClose}
|
onclose={handleModalClose}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { getLocalTimeZone } from '@internationalized/date';
|
import { getLocalTimeZone } from '@internationalized/date';
|
||||||
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
import { isValidUKPhone, toE164UK } from '$lib/utils/phone';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
// UI Components
|
// UI Components
|
||||||
import * as Modal from '$lib/components/ui/dialog';
|
import * as Modal from '$lib/components/ui/dialog';
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
let userType = $state<'member' | 'guest'>('member');
|
let userType = $state<'member' | 'guest'>('member');
|
||||||
let userQuery = $state('');
|
let userQuery = $state('');
|
||||||
let users = $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 selectedUserId = $state<string | null>(null);
|
||||||
let guestName = $state('');
|
let guestName = $state('');
|
||||||
@@ -717,7 +718,7 @@
|
|||||||
onclick={() => (selectedUserId = user.id)}
|
onclick={() => (selectedUserId = user.id)}
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{#if user.email && user.phone}
|
{#if user.email && user.phone}
|
||||||
{user.email} • {user.phone}
|
{user.email} • {user.phone}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
let paymentResult = $state<PaymentResult | null>(null);
|
let paymentResult = $state<PaymentResult | null>(null);
|
||||||
let error = $state<string | 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 useLoyalty = $state(false);
|
||||||
|
|
||||||
let loyaltyEligible = $derived(
|
let loyaltyEligible = $derived(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { authStore } from '$lib/stores/auth.svelte';
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
import { formatCardCode } from '$lib/utils/format';
|
import { formatCardCode } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -73,7 +74,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Selected customer info
|
// 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);
|
let isGuest = $state(false);
|
||||||
|
|
||||||
// Delivery choice — how the gift card value is given to the customer
|
// Delivery choice — how the gift card value is given to the customer
|
||||||
@@ -101,6 +102,8 @@
|
|||||||
name: string;
|
name: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
previousFirstName?: string | null;
|
||||||
|
previousLastName?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Customer Selection - tab
|
// Customer Selection - tab
|
||||||
@@ -108,7 +111,7 @@
|
|||||||
|
|
||||||
// Customer Selection - Search
|
// Customer Selection - Search
|
||||||
let userQuery = $state('');
|
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 loadingUsers = $state(false);
|
||||||
let currentPage = $state(1);
|
let currentPage = $state(1);
|
||||||
let totalPages = $state(1);
|
let totalPages = $state(1);
|
||||||
@@ -296,7 +299,9 @@
|
|||||||
id: appointment.user.id,
|
id: appointment.user.id,
|
||||||
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
|
name: appointment.user.full_name || appointment.user.name || 'Current Customer',
|
||||||
email: appointment.user.email,
|
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 {
|
} else {
|
||||||
toast.error('No current or next appointment found');
|
toast.error('No current or next appointment found');
|
||||||
@@ -313,7 +318,7 @@
|
|||||||
|
|
||||||
function selectCurrentCustomer() {
|
function selectCurrentCustomer() {
|
||||||
if (!currentCustomerInfo) return;
|
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;
|
isGuest = currentCustomerInfo.email?.endsWith('@guest.invalid') || false;
|
||||||
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
||||||
step = 'payment-selection';
|
step = 'payment-selection';
|
||||||
@@ -390,8 +395,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCustomer(user: { id: string; fullName: string; email?: string }) {
|
function selectCustomer(user: { id: string; fullName: string; email?: string; previousFirstName?: string | null; previousLastName?: string | null }) {
|
||||||
selectedCustomer = { id: user.id, name: user.fullName };
|
selectedCustomer = { id: user.id, name: user.fullName, previousFirstName: user.previousFirstName, previousLastName: user.previousLastName };
|
||||||
isGuest = user.email?.endsWith('@guest.invalid') || false;
|
isGuest = user.email?.endsWith('@guest.invalid') || false;
|
||||||
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
delivery = (action === 'topup' || isGuest) ? 'code' : 'account';
|
||||||
step = 'payment-selection';
|
step = 'payment-selection';
|
||||||
@@ -777,7 +782,7 @@
|
|||||||
{currentCustomerInfo.name.charAt(0).toUpperCase()}
|
{currentCustomerInfo.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<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}
|
{#if currentCustomerInfo.email}
|
||||||
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
|
<div class="text-xs text-gray-500">{currentCustomerInfo.email}</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -843,20 +848,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[260px] overflow-y-auto rounded-md border border-gray-200">
|
<div class="max-h-[260px] overflow-y-auto rounded-md border border-gray-200">
|
||||||
{#if loadingUsers}
|
{#if users.length === 0 && !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}
|
|
||||||
<div class="flex items-center justify-center p-6 text-sm text-gray-500">
|
<div class="flex items-center justify-center p-6 text-sm text-gray-500">
|
||||||
{userQuery
|
{userQuery
|
||||||
? 'No customers found. Try a different search.'
|
? 'No customers found. Try a different search.'
|
||||||
: 'Search for a customer above to get started.'}
|
: 'Search for a customer above to get started.'}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if users.length > 0}
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200 {loadingUsers ? 'opacity-60' : ''}">
|
||||||
{#each users as userItem (userItem.id)}
|
{#each users as userItem (userItem.id)}
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -865,7 +864,7 @@
|
|||||||
onclick={() => selectCustomer(userItem)}
|
onclick={() => selectCustomer(userItem)}
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{userItem.email}{#if userItem.email && userItem.phone}
|
{userItem.email}{#if userItem.email && userItem.phone}
|
||||||
·
|
·
|
||||||
@@ -922,7 +921,7 @@
|
|||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
||||||
{#if selectedCustomer}
|
{#if selectedCustomer}
|
||||||
— {selectedCustomer.name}
|
— {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
|
||||||
{/if}.
|
{/if}.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
@@ -1120,7 +1119,7 @@
|
|||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
Charge {formatCurrency(amount)} for {action === 'create' ? 'gift card' : 'topup'}
|
||||||
{#if selectedCustomer}
|
{#if selectedCustomer}
|
||||||
— {selectedCustomer.name}
|
— {formatUserName(selectedCustomer.name, selectedCustomer.previousFirstName, selectedCustomer.previousLastName)}
|
||||||
{/if}.
|
{/if}.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|||||||
@@ -220,7 +220,10 @@ import { SvelteDate } from 'svelte/reactivity';
|
|||||||
let defaultType = $derived(
|
let defaultType = $derived(
|
||||||
defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full')
|
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;
|
let pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
@@ -923,6 +926,11 @@ import { SvelteDate } from 'svelte/reactivity';
|
|||||||
disabled={false}
|
disabled={false}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card validation error shown inline near card input -->
|
||||||
|
{#if cardValidationError}
|
||||||
|
<p class="text-sm text-red-600">{cardValidationError}</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if depositPolicyWarning}
|
{#if depositPolicyWarning}
|
||||||
@@ -965,9 +973,6 @@ import { SvelteDate } from 'svelte/reactivity';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pay button -->
|
<!-- Pay button -->
|
||||||
{#if payButtonError}
|
|
||||||
<p class="text-sm text-red-600">{payButtonError}</p>
|
|
||||||
{/if}
|
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -1034,13 +1039,13 @@ import { SvelteDate } from 'svelte/reactivity';
|
|||||||
disabled={status !== 'idle'}
|
disabled={status !== 'idle'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{#if partialValidationError}
|
||||||
|
<p class="mt-1 text-sm text-red-600">{partialValidationError}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Pay button -->
|
<!-- Pay button -->
|
||||||
{#if payButtonError}
|
|
||||||
<p class="text-sm text-red-600">{payButtonError}</p>
|
|
||||||
{/if}
|
|
||||||
<Button
|
<Button
|
||||||
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
profile_pic_url?: string;
|
profile_pic_url?: string;
|
||||||
|
previous_first_name?: string;
|
||||||
|
previous_last_name?: string;
|
||||||
};
|
};
|
||||||
services: Array<{
|
services: Array<{
|
||||||
service_name?: string;
|
service_name?: string;
|
||||||
@@ -645,7 +648,7 @@
|
|||||||
{#if activeAppointment.user?.profile_pic_url}
|
{#if activeAppointment.user?.profile_pic_url}
|
||||||
<img
|
<img
|
||||||
src={activeAppointment.user.profile_pic_url}
|
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"
|
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}
|
{:else}
|
||||||
@@ -662,7 +665,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-lg font-semibold">
|
<div class="truncate text-lg font-semibold">
|
||||||
{activeAppointment.user?.full_name || 'Guest'}
|
{formatUserName(activeAppointment.user?.full_name || 'Guest', activeAppointment.user?.previous_first_name, activeAppointment.user?.previous_last_name)}
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
<div class="truncate text-sm text-gray-600">{activeAppointment.user?.phone || '—'}</div>
|
||||||
{#if activeAppointment.user}
|
{#if activeAppointment.user}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
import ApprovalModal from '$lib/components/admin/ApprovalModal.svelte';
|
||||||
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
|
import EditRequestModal from '$lib/components/admin/EditRequestModal.svelte';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
openBookingModal?: (bookingId: string) => void;
|
openBookingModal?: (bookingId: string) => void;
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
full_name: string;
|
full_name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,8 @@
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_name: string; // This is a string, not an object
|
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
|
services: string[]; // Array of service names
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -359,7 +364,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex-1">
|
<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">
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
{approval.services.join(', ')}
|
{approval.services.join(', ')}
|
||||||
</div>
|
</div>
|
||||||
@@ -404,7 +409,7 @@
|
|||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
<span class="text-xs font-medium text-amber-600">Edit/Reschedule</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-sm text-gray-600">
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { sanitizeText } from '$lib/utils/toast-safe';
|
import { sanitizeText } from '$lib/utils/toast-safe';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
@@ -35,6 +36,8 @@
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
services: string[];
|
services: string[];
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimeBlocker = {
|
type TimeBlocker = {
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
status: string;
|
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[];
|
services: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -742,7 +745,7 @@
|
|||||||
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
|
class="block max-w-full truncate font-medium hover:text-blue-600 hover:underline"
|
||||||
onclick={() => openUserModal(item.data.user_id)}
|
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>
|
</button>
|
||||||
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
<div class="flex flex-wrap items-center gap-x-1 text-sm text-gray-600">
|
||||||
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
|
<span class="truncate">{(item.data.services ?? []).join(', ')}</span>
|
||||||
@@ -997,7 +1000,7 @@
|
|||||||
{#each overlappingBookings as booking (booking.id)}
|
{#each overlappingBookings as booking (booking.id)}
|
||||||
<div class="rounded-md border border-amber-200 bg-white p-2">
|
<div class="rounded-md border border-amber-200 bg-white p-2">
|
||||||
<div class="min-w-0">
|
<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">
|
<div class="text-xs text-gray-500">
|
||||||
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
{new SvelteDate(booking.start_time).toLocaleTimeString('en-GB', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
user_id: string;
|
user_id: string;
|
||||||
services: string[];
|
services: string[];
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
|
previous_first_name?: string | null;
|
||||||
|
previous_last_name?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DayWorkingHours = {
|
type DayWorkingHours = {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export interface User {
|
|||||||
loyaltyStamps?: number;
|
loyaltyStamps?: number;
|
||||||
referralCode?: string;
|
referralCode?: string;
|
||||||
referralCodeUses?: number;
|
referralCodeUses?: number;
|
||||||
|
referralSavings?: number;
|
||||||
profilePicUrl?: string;
|
profilePicUrl?: string;
|
||||||
|
previousFirstName?: string;
|
||||||
|
previousLastName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthStore {
|
class AuthStore {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Refund } from '$lib/types/index';
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,6 +86,8 @@ export interface BookingUser {
|
|||||||
referral_code_uses?: number;
|
referral_code_uses?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
previous_first_name?: string;
|
||||||
|
previous_last_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
@@ -127,6 +131,7 @@ export interface Booking {
|
|||||||
user?: BookingUser;
|
user?: BookingUser;
|
||||||
services?: BookingService[];
|
services?: BookingService[];
|
||||||
payments?: Payment[];
|
payments?: Payment[];
|
||||||
|
refunds?: Refund[];
|
||||||
discounts?: BookingDiscount[];
|
discounts?: BookingDiscount[];
|
||||||
total_amount: number;
|
total_amount: number;
|
||||||
amount_paid: number;
|
amount_paid: number;
|
||||||
@@ -175,7 +180,7 @@ export interface BookingDiscount {
|
|||||||
id: string;
|
id: string;
|
||||||
booking_id: string;
|
booking_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
discount_source: 'loyalty' | 'campaign';
|
discount_source: 'loyalty' | 'campaign' | 'referral';
|
||||||
source_id?: string;
|
source_id?: string;
|
||||||
campaign_name?: string;
|
campaign_name?: string;
|
||||||
campaign_type?: CampaignType;
|
campaign_type?: CampaignType;
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
function getNotificationSubtitle(n: Notification): string {
|
||||||
const parts = [formatRelative(n.created_at)];
|
const parts = [formatRelative(n.created_at)];
|
||||||
if (n.user_name) {
|
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(' — ');
|
return parts.join(' — ');
|
||||||
}
|
}
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 {loading ? 'opacity-60' : ''}">
|
||||||
{#each notifications as n (n.id)}
|
{#each notifications as n (n.id)}
|
||||||
{@const actionable =
|
{@const actionable =
|
||||||
!n.acknowledged_at &&
|
!n.acknowledged_at &&
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { formatDuration } from '$lib/utils/format';
|
import { formatDuration } from '$lib/utils/format';
|
||||||
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
import BookingModal from '$lib/components/admin/BookingModal.svelte';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
start_time: string;
|
start_time: string;
|
||||||
status: string;
|
status: string;
|
||||||
duration_minutes: number;
|
duration_minutes: number;
|
||||||
user?: { full_name: string };
|
user?: { full_name: string; previous_first_name?: string | null; previous_last_name?: string | null };
|
||||||
services: BookingService[];
|
services: BookingService[];
|
||||||
};
|
};
|
||||||
type TimeBlocker = {
|
type TimeBlocker = {
|
||||||
@@ -554,7 +555,7 @@
|
|||||||
{#if !hasOverlap}
|
{#if !hasOverlap}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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(
|
style="top: {topOffset}px; height: {heightPx}px; {bookingStyle(
|
||||||
b.status
|
b.status
|
||||||
@@ -570,7 +571,7 @@
|
|||||||
style="background:{dotColor(b.status)}"
|
style="background:{dotColor(b.status)}"
|
||||||
></div>
|
></div>
|
||||||
<span class="truncate font-medium"
|
<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>
|
</div>
|
||||||
{#if heightPx > 36}
|
{#if heightPx > 36}
|
||||||
|
|||||||
Reference in New Issue
Block a user