649 lines
19 KiB
Svelte
649 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { SvelteDate, SvelteURLSearchParams } from 'svelte/reactivity';
|
|
import { toast } from 'svelte-sonner';
|
|
import { apiFetch } from '$lib/utils/api';
|
|
import * as Modal from '$lib/components/ui/dialog';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import PatchTestModal from './PatchTestModal.svelte';
|
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
|
import { parseWallClockDate } from '$lib/utils/timeSlots';
|
|
import { range } from '$lib/utils/format';
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
userId: string;
|
|
openBookingModal: (bookingId: string) => void;
|
|
}
|
|
|
|
let { open = $bindable(), userId, openBookingModal }: Props = $props();
|
|
|
|
type SocialLogin = {
|
|
provider: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AdminUserDetail = {
|
|
id: string;
|
|
email?: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
fullName: string;
|
|
phone?: string;
|
|
dateOfBirth?: string;
|
|
profilePicUrl?: string;
|
|
accountRole: string;
|
|
accountType: string;
|
|
loyaltyStamps: number;
|
|
referralCode: string;
|
|
referralCodeUses: number;
|
|
lastLoginAt?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
notes?: string;
|
|
privacyPolicyConsent: boolean;
|
|
policyConsentUpdatedAt?: string;
|
|
dataRetentionConsent: boolean;
|
|
dataConsentUpdatedAt?: string;
|
|
socialLogins?: SocialLogin[];
|
|
previousFirstName?: string;
|
|
previousLastName?: string;
|
|
};
|
|
|
|
type Booking = {
|
|
id: string;
|
|
start_time: string;
|
|
status:
|
|
| 'pending'
|
|
| 'confirmed'
|
|
| 'in_progress'
|
|
| 'completed'
|
|
| 'client_cancelled'
|
|
| 'we_cancelled'
|
|
| 'no_show'
|
|
| 'pending_release'
|
|
| 'deposit_lapsed';
|
|
deposit_required: boolean;
|
|
deposit_paid: boolean;
|
|
services: Array<{
|
|
service_name?: string;
|
|
}>;
|
|
total_amount: number;
|
|
};
|
|
|
|
type TopService = {
|
|
name: string;
|
|
count: number;
|
|
};
|
|
|
|
type CustomerRelationship = {
|
|
totalSpend: number;
|
|
totalSaved: number;
|
|
totalTips: number;
|
|
totalVisits: number;
|
|
customerFor: string;
|
|
firstVisitDate?: string;
|
|
lastVisitDate?: string;
|
|
topServices: TopService[];
|
|
};
|
|
|
|
let selectedUser = $state<AdminUserDetail | null>(null);
|
|
let bookingUserHistory = $state<Booking[]>([]);
|
|
let totalBookings = $state(0);
|
|
let cursors = $state<string[]>(['']);
|
|
let nextCursor = $state<string | null>(null);
|
|
let currentBookingPage = $state(1);
|
|
let totalBookingPages = $state(1);
|
|
let loadingBookings = $state(false);
|
|
|
|
let showPatchTestModal = $state(false);
|
|
let hasEligiblePatchTests = $state(false);
|
|
let customerRelationship = $state<CustomerRelationship | null>(null);
|
|
let loadingRelationship = $state(false);
|
|
let giftCardBalance = $state<number | null>(null);
|
|
|
|
async function fetchUserDetails() {
|
|
if (!userId) return;
|
|
|
|
try {
|
|
const response = await apiFetch(`/api/admin/users/${userId}`, {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
selectedUser = await response.json();
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to load user details: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching user details:', err);
|
|
toast.error('Network error loading user details');
|
|
}
|
|
}
|
|
|
|
async function fetchEligiblePatchTests() {
|
|
if (!userId) return;
|
|
try {
|
|
const response = await apiFetch(`/api/admin/users/${userId}/patch-tests/eligible`, {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const services = await response.json();
|
|
hasEligiblePatchTests = services.length > 0;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching eligible patch tests:', err);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (userId) {
|
|
fetchUserDetails();
|
|
fetchEligiblePatchTests();
|
|
}
|
|
});
|
|
|
|
async function fetchUserBookings(pageIdx: number = 0) {
|
|
if (!userId) return;
|
|
|
|
loadingBookings = true;
|
|
try {
|
|
const params = new SvelteURLSearchParams({
|
|
per_page: '4'
|
|
});
|
|
const cursor = cursors[pageIdx];
|
|
if (cursor) {
|
|
params.set('cursor', cursor);
|
|
}
|
|
|
|
const response = await apiFetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (!data.bookings || data.bookings.length === 0) {
|
|
bookingUserHistory = [];
|
|
totalBookings = 0;
|
|
totalBookingPages = 1;
|
|
currentBookingPage = 1;
|
|
cursors = [''];
|
|
nextCursor = null;
|
|
loadingBookings = false;
|
|
return;
|
|
}
|
|
|
|
bookingUserHistory = data.bookings || [];
|
|
totalBookings = data.total || 0;
|
|
totalBookingPages = data.totalPages || 1;
|
|
currentBookingPage = pageIdx + 1;
|
|
nextCursor = data.next_cursor ?? null;
|
|
|
|
if (nextCursor && cursors.length <= pageIdx + 1) {
|
|
cursors = [...cursors, nextCursor];
|
|
}
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to load user bookings: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching user bookings:', err);
|
|
toast.error('Network error loading user bookings');
|
|
} finally {
|
|
loadingBookings = false;
|
|
}
|
|
}
|
|
|
|
function nextBookingPage() {
|
|
if (!nextCursor || currentBookingPage >= totalBookingPages) return;
|
|
fetchUserBookings(currentBookingPage);
|
|
}
|
|
|
|
function previousBookingPage() {
|
|
if (currentBookingPage <= 1) return;
|
|
fetchUserBookings(currentBookingPage - 2);
|
|
}
|
|
|
|
async function fetchCustomerRelationship() {
|
|
if (!userId) return;
|
|
|
|
loadingRelationship = true;
|
|
try {
|
|
const response = await apiFetch(`/api/admin/users/${userId}/relationship`, {
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
customerRelationship = await response.json();
|
|
} else {
|
|
customerRelationship = null;
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching customer relationship:', err);
|
|
customerRelationship = null;
|
|
} finally {
|
|
loadingRelationship = false;
|
|
}
|
|
}
|
|
|
|
async function fetchGiftCardBalance() {
|
|
if (!userId) return;
|
|
try {
|
|
const res = await apiFetch(`/api/admin/users/${userId}/giftcard-balance`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
giftCardBalance = data.balance;
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (open && userId) {
|
|
fetchUserDetails();
|
|
fetchUserBookings();
|
|
fetchCustomerRelationship();
|
|
fetchGiftCardBalance();
|
|
}
|
|
});
|
|
|
|
function handleOpenBooking(bookingId: string) {
|
|
openBookingModal(bookingId);
|
|
}
|
|
</script>
|
|
|
|
<Modal.Root bind:open>
|
|
<Modal.Content
|
|
class="max-h-[90vh] max-w-[calc(100%-2rem)] overflow-y-auto sm:max-w-md md:max-w-3xl"
|
|
>
|
|
<Modal.Header>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<Modal.Title class="text-lg font-semibold">User Details</Modal.Title>
|
|
{#if selectedUser}
|
|
<div class="mt-1 text-sm text-gray-500">ID: {selectedUser.id}</div>
|
|
{/if}
|
|
</div>
|
|
{#if selectedUser}
|
|
<span
|
|
class="inline-flex items-center rounded-full px-3 py-1 text-sm font-medium
|
|
{selectedUser.accountRole === 'admin'
|
|
? 'bg-purple-100 text-purple-800'
|
|
: selectedUser.accountRole === 'verified_email'
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
: selectedUser.accountRole === 'unverified_email'
|
|
? 'bg-yellow-100 text-yellow-800'
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
>
|
|
{selectedUser.accountRole}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</Modal.Header>
|
|
|
|
{#if selectedUser}
|
|
<div class="space-y-6 px-4 pb-4">
|
|
<!-- Personal Information -->
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
|
Personal Information
|
|
</h3>
|
|
<div class="grid gap-3 md:grid-cols-2">
|
|
<div>
|
|
<div class="text-xs text-gray-500">Full Name</div>
|
|
<div class="font-medium">
|
|
{formatUserName(
|
|
selectedUser.fullName,
|
|
selectedUser.previousFirstName,
|
|
selectedUser.previousLastName
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Email</div>
|
|
<div class="font-medium break-words" title={selectedUser.email}>
|
|
{selectedUser.email || '—'}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Phone</div>
|
|
<div class="font-medium">{selectedUser.phone || '—'}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Date of Birth</div>
|
|
<div class="font-medium">
|
|
{#if selectedUser.dateOfBirth}
|
|
{(() => {
|
|
const dob = new SvelteDate(selectedUser.dateOfBirth);
|
|
const dateStr = dob.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
const today = new SvelteDate();
|
|
let age = today.getFullYear() - dob.getFullYear();
|
|
const m = today.getMonth() - dob.getMonth();
|
|
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
|
|
return `${dateStr} (${age})`;
|
|
})()}
|
|
{:else}
|
|
—
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">First Visit</div>
|
|
<div class="font-medium">
|
|
{#if customerRelationship?.firstVisitDate}
|
|
{new SvelteDate(customerRelationship.firstVisitDate).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
})}
|
|
{:else}
|
|
—
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Last Visit</div>
|
|
<div class="font-medium">
|
|
{#if customerRelationship?.lastVisitDate}
|
|
{new SvelteDate(customerRelationship.lastVisitDate).toLocaleDateString('en-US', {
|
|
weekday: 'short',
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
})}
|
|
{:else}
|
|
—
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{#if selectedUser.profilePicUrl}
|
|
<div class="mt-3">
|
|
<div class="text-xs text-gray-500">Profile Picture</div>
|
|
<img
|
|
src={selectedUser.profilePicUrl}
|
|
alt="Profile"
|
|
class="mt-2 h-20 w-20 rounded-lg object-cover md:h-24 md:w-24"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if selectedUser.notes}
|
|
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
|
<div class="mb-1 text-xs font-semibold text-amber-800">Staff Notes</div>
|
|
<div class="text-sm text-amber-900">{selectedUser.notes}</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Booking History -->
|
|
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
<h3 class="mb-3 text-sm font-semibold tracking-wide text-gray-600 uppercase">
|
|
Booking History ({totalBookings})
|
|
</h3>
|
|
{#if bookingUserHistory.length === 0 && !loadingBookings}
|
|
<div class="text-center text-sm text-gray-500">No bookings found</div>
|
|
{:else if bookingUserHistory.length > 0}
|
|
<div class="space-y-2 {loadingBookings ? 'opacity-60' : ''}">
|
|
{#each bookingUserHistory as booking (booking.id)}
|
|
<div class="rounded-md border border-gray-300 bg-white p-3">
|
|
<div class="flex items-start justify-between">
|
|
<div class="flex-1">
|
|
<div class="font-medium">
|
|
{(() => {
|
|
const date = parseWallClockDate(booking.start_time);
|
|
const dateStr = date.toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
day: 'numeric',
|
|
month: 'short'
|
|
});
|
|
const timeStr = date.toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
return `${dateStr} at ${timeStr}`;
|
|
})()}
|
|
</div>
|
|
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
|
<span
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
|
{booking.status === 'confirmed' || booking.status === 'in_progress'
|
|
? 'bg-emerald-100 text-emerald-800'
|
|
: booking.status === 'pending'
|
|
? 'bg-yellow-100 text-yellow-800'
|
|
: booking.status === 'completed'
|
|
? 'bg-green-100 text-green-800'
|
|
: booking.status === 'client_cancelled' ||
|
|
booking.status === 'we_cancelled' ||
|
|
booking.status === 'deposit_lapsed'
|
|
? 'bg-red-100 text-red-800'
|
|
: 'bg-gray-100 text-gray-800'}"
|
|
>
|
|
{booking.status.replace('_', ' ')}
|
|
</span>
|
|
|
|
{#if booking.deposit_required}
|
|
{#if booking.status === 'pending'}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800"
|
|
>
|
|
Will Require Deposit
|
|
</span>
|
|
{:else if (booking.status === 'confirmed' || booking.status === 'in_progress') && !booking.deposit_paid}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-orange-100 px-2 py-0.5 text-xs font-medium text-orange-800"
|
|
>
|
|
Deposit Due
|
|
</span>
|
|
{:else if booking.deposit_paid}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"
|
|
>
|
|
Deposit Paid
|
|
</span>
|
|
{/if}
|
|
{/if}
|
|
|
|
<span class="text-gray-500">
|
|
{booking.services.map((s) => s.service_name).join(', ')}
|
|
</span>
|
|
</div>
|
|
<div class="mt-1 text-sm font-semibold text-gray-900">
|
|
£{booking.total_amount.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={() => handleOpenBooking(booking.id)}>View</Button
|
|
>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if totalBookingPages > 1}
|
|
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={previousBookingPage}
|
|
disabled={currentBookingPage === 1 || loadingBookings}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span class="text-xs text-gray-600">
|
|
Page {currentBookingPage} of {totalBookingPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={nextBookingPage}
|
|
disabled={currentBookingPage === totalBookingPages || loadingBookings}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loadingRelationship}
|
|
<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">
|
|
Customer Relationship
|
|
</h3>
|
|
<div class="space-y-2">
|
|
{#each range(5) as i (i)}
|
|
<div class="h-10 animate-pulse rounded-md bg-gray-200"></div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{:else if customerRelationship}
|
|
<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">
|
|
Customer Relationship
|
|
</h3>
|
|
<div class="grid gap-3 md:grid-cols-5">
|
|
<div>
|
|
<div class="text-xs text-gray-500">Total Spend</div>
|
|
<div class="text-2xl font-bold text-emerald-600">
|
|
£{customerRelationship.totalSpend.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Total Saved</div>
|
|
<div class="text-2xl font-bold text-fuchsia-600">
|
|
£{customerRelationship.totalSaved.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Total Tips</div>
|
|
<div class="text-2xl font-bold text-amber-600">
|
|
£{customerRelationship.totalTips.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Total Visits</div>
|
|
<div class="text-2xl font-bold text-blue-600">
|
|
{customerRelationship.totalVisits}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Customer For</div>
|
|
<div class="text-2xl font-bold text-purple-600">
|
|
{customerRelationship.customerFor || '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if giftCardBalance !== null && giftCardBalance > 0}
|
|
<div class="mt-3 border-t pt-3">
|
|
<div class="text-xs text-gray-500">Gift Card Balance</div>
|
|
<div class="text-2xl font-bold text-green-600">
|
|
£{giftCardBalance.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if customerRelationship.topServices && customerRelationship.topServices.length > 0}
|
|
<div class="mt-4">
|
|
<div class="mb-2 text-xs font-semibold text-gray-600">Most Booked Services</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each customerRelationship.topServices as service (service.name)}
|
|
<span
|
|
class="inline-flex items-center rounded-full bg-blue-100 px-3 py-1 text-sm font-medium text-blue-800"
|
|
>
|
|
{service.name}
|
|
<span class="ml-1 text-xs text-blue-600">({service.count})</span>
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Loyalty & Referrals -->
|
|
<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">
|
|
Loyalty & Referrals
|
|
</h3>
|
|
<div class="grid gap-3 md:grid-cols-3">
|
|
<div>
|
|
<div class="text-xs text-gray-500">Loyalty Stamps</div>
|
|
<div class="text-2xl font-bold text-emerald-600">{selectedUser.loyaltyStamps}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Referral Code</div>
|
|
<div class="font-mono text-sm font-medium">{selectedUser.referralCode}</div>
|
|
</div>
|
|
<div>
|
|
<div class="text-xs text-gray-500">Referrals Made</div>
|
|
<div class="text-2xl font-bold text-purple-600">{selectedUser.referralCodeUses}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if hasEligiblePatchTests}
|
|
<!-- Patch Test Actions -->
|
|
<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">
|
|
Patch Tests
|
|
</h3>
|
|
<p class="mb-3 text-sm text-gray-600">
|
|
Record patch test completion to allow this user to book services requiring one.
|
|
</p>
|
|
<Button variant="outline" onclick={() => (showPatchTestModal = true)}>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="mr-2 h-4 w-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
</svg>
|
|
Record Patch Test
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<Modal.Footer class="flex items-center justify-end gap-2">
|
|
<Button onclick={() => (open = false)}>Close</Button>
|
|
</Modal.Footer>
|
|
</Modal.Content>
|
|
</Modal.Root>
|
|
|
|
{#if selectedUser}
|
|
<PatchTestModal
|
|
bind:open={showPatchTestModal}
|
|
userId={selectedUser.id}
|
|
userName={formatUserName(
|
|
selectedUser.fullName,
|
|
selectedUser.previousFirstName,
|
|
selectedUser.previousLastName
|
|
)}
|
|
onPatchTestAdded={() => {
|
|
fetchUserDetails();
|
|
}}
|
|
/>
|
|
{/if}
|