feat: customer relationship view, idempotency keys, approval decline, seed payments, backlog cleanup
- #3: Wire ApprovalModal handleDecline to POST /api/admin/bookings/{id}/cancel - #25: New GET /api/admin/users/{id}/relationship endpoint with spend, tips, visits, customer-for duration, top services - #25: UserModal reorganized — Personal Info, Booking History, Customer Relationship, Loyalty, Patch Tests - #36: Idempotency keys on user and admin booking creation (UUID header, duplicate detection) - local-dev-2.sh: seed payments via PL/pgSQL for completed bookings (5 randomized scenarios) - local-dev-2.sh: shrink guest/time-blocker output, add payments to summary - Backlog: mark #3/#25/#35/#36/#49 done, plan #36/#45, remove #46/#48, update #45 with milestone campaigns - Remove notes history table, avg visits/year metric, Account Information, Privacy & Consent from UserModal
This commit is contained in:
@@ -65,6 +65,21 @@
|
||||
total_amount: number;
|
||||
};
|
||||
|
||||
type TopService = {
|
||||
name: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type CustomerRelationship = {
|
||||
totalSpend: 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);
|
||||
@@ -74,6 +89,8 @@
|
||||
|
||||
let showPatchTestModal = $state(false);
|
||||
let hasEligiblePatchTests = $state(false);
|
||||
let customerRelationship = $state<CustomerRelationship | null>(null);
|
||||
let loadingRelationship = $state(false);
|
||||
|
||||
async function fetchUserDetails() {
|
||||
if (!userId) return;
|
||||
@@ -173,10 +190,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCustomerRelationship() {
|
||||
if (!userId) return;
|
||||
|
||||
loadingRelationship = true;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/relationship`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
customerRelationship = await response.json();
|
||||
} else {
|
||||
customerRelationship = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching customer relationship:', err);
|
||||
customerRelationship = null;
|
||||
} finally {
|
||||
loadingRelationship = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && userId) {
|
||||
fetchUserDetails();
|
||||
fetchUserBookings();
|
||||
fetchCustomerRelationship();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -245,6 +289,34 @@
|
||||
: '—'}
|
||||
</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', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedUser.profilePicUrl}
|
||||
<div class="mt-3">
|
||||
@@ -256,152 +328,14 @@
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Account 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">
|
||||
Account Information
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Account Type</div>
|
||||
<div class="font-medium capitalize">{selectedUser.accountType}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Account Role</div>
|
||||
<div class="font-medium capitalize">{selectedUser.accountRole.replace('_', ' ')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Created</div>
|
||||
<div class="font-medium">
|
||||
{new SvelteDate(selectedUser.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Last Login</div>
|
||||
<div class="font-medium">
|
||||
{selectedUser.lastLoginAt
|
||||
? new SvelteDate(selectedUser.lastLoginAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedUser.socialLogins && selectedUser.socialLogins.length > 0}
|
||||
<div class="mt-3 rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<div class="mb-2 text-xs font-semibold text-blue-800">Connected Social Accounts</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each selectedUser.socialLogins as social (social.provider)}
|
||||
<span
|
||||
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-800"
|
||||
>
|
||||
{social.provider.charAt(0).toUpperCase() + social.provider.slice(1)}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{#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>
|
||||
|
||||
{#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}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- GDPR Consents -->
|
||||
<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">
|
||||
Privacy & Consent
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Privacy Policy & Terms</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{selectedUser.policyConsentUpdatedAt
|
||||
? `Updated ${new SvelteDate(selectedUser.policyConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{selectedUser.privacyPolicyConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{selectedUser.privacyPolicyConsent ? 'Accepted' : 'Declined'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-medium">Data Retention</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{selectedUser.dataConsentUpdatedAt
|
||||
? `Updated ${new SvelteDate(selectedUser.dataConsentUpdatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium
|
||||
{selectedUser.dataRetentionConsent ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}"
|
||||
>
|
||||
{selectedUser.dataRetentionConsent ? 'Accepted' : 'Declined'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff Notes -->
|
||||
{#if selectedUser.notes}
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<h3 class="mb-2 text-sm font-semibold tracking-wide text-amber-800 uppercase">
|
||||
Staff Notes
|
||||
</h3>
|
||||
<div class="text-sm text-amber-900">{selectedUser.notes}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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">
|
||||
@@ -455,7 +389,6 @@
|
||||
{booking.status.replace('_', ' ')}
|
||||
</span>
|
||||
|
||||
<!-- Deposit chip: pending = "will require deposit", confirmed+/!paid = "deposit due" -->
|
||||
{#if booking.deposit_required}
|
||||
{#if booking.status === 'pending'}
|
||||
<span
|
||||
@@ -521,6 +454,96 @@
|
||||
{/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 Array(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-3">
|
||||
<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 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 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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user