Split admin dashboard, implement user and booking search
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import * as Modal from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
type Booking = {
|
||||
id: string;
|
||||
start_time: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'confirmed'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'client_cancelled'
|
||||
| 'we_cancelled'
|
||||
| 're-schedule'
|
||||
| 'no_show';
|
||||
services: Array<{
|
||||
service_name?: string;
|
||||
}>;
|
||||
total_amount: number;
|
||||
};
|
||||
|
||||
let selectedUser = $state<AdminUserDetail | null>(null);
|
||||
let bookingUserHistory = $state<Booking[]>([]);
|
||||
let totalBookings = $state(0);
|
||||
let currentBookingPage = $state(1);
|
||||
let totalBookingPages = $state(1);
|
||||
let loadingBookings = $state(false);
|
||||
|
||||
async function fetchUserDetails() {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
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 fetchUserBookings(page: number = 1) {
|
||||
if (!userId) return;
|
||||
|
||||
loadingBookings = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
per_page: '5'
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/admin/bookings/user/${userId}?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
bookingUserHistory = data.bookings || [];
|
||||
totalBookings = data.total || 0;
|
||||
currentBookingPage = data.page || 1;
|
||||
totalBookingPages = data.totalPages || 1;
|
||||
} 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 (currentBookingPage < totalBookingPages) {
|
||||
fetchUserBookings(currentBookingPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function previousBookingPage() {
|
||||
if (currentBookingPage > 1) {
|
||||
fetchUserBookings(currentBookingPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && userId) {
|
||||
fetchUserDetails();
|
||||
fetchUserBookings();
|
||||
}
|
||||
});
|
||||
|
||||
function handleOpenBooking(bookingId: string) {
|
||||
open = false;
|
||||
openBookingModal(bookingId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal.Root bind:open>
|
||||
<Modal.Content class="max-h-[90vh] max-w-sm overflow-y-auto 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">{selectedUser.fullName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Email</div>
|
||||
<div class="font-medium break-all">{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">
|
||||
{selectedUser.dateOfBirth
|
||||
? new SvelteDate(selectedUser.dateOfBirth).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
: '—'}
|
||||
</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-24 w-24 rounded-lg object-cover"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
Booking History ({totalBookings})
|
||||
</h3>
|
||||
{#if loadingBookings}
|
||||
<div class="space-y-2">
|
||||
{#each Array(3) as _, i (i)}
|
||||
<div class="h-20 animate-pulse rounded-md bg-gray-200"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if bookingUserHistory.length === 0}
|
||||
<div class="text-center text-sm text-gray-500">No bookings found</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#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 = new SvelteDate(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 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'
|
||||
? '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 === 'cancelled' ||
|
||||
booking.status === 'client_cancelled' ||
|
||||
booking.status === 'we_cancelled'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'}"
|
||||
>
|
||||
{booking.status}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Modal.Footer class="flex items-center justify-end gap-2">
|
||||
<Button onclick={() => (open = false)}>Close</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
Reference in New Issue
Block a user