235 lines
6.1 KiB
Svelte
235 lines
6.1 KiB
Svelte
<script lang="ts">
|
|
import { authStore } from '$lib/stores/auth.svelte';
|
|
import { toast } from 'svelte-sonner';
|
|
import * as Card from '$lib/components/ui/card';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
import { formatUserName } from '$lib/utils/nameDisplay';
|
|
|
|
interface Props {
|
|
openUserModal: (userId: string) => void;
|
|
}
|
|
|
|
const { openUserModal }: Props = $props();
|
|
|
|
type UserListItem = {
|
|
id: string;
|
|
fullName: string;
|
|
email?: string;
|
|
phone?: string;
|
|
previousFirstName?: string | null;
|
|
previousLastName?: string | null;
|
|
};
|
|
|
|
type UserListResponse = {
|
|
users: UserListItem[];
|
|
total: number;
|
|
page: number;
|
|
perPage: number;
|
|
totalPages: number;
|
|
next_cursor?: string | null;
|
|
};
|
|
|
|
let userQuery = $state('');
|
|
let users = $state<UserListItem[]>([]);
|
|
let totalUsers = $state(0);
|
|
// Cursor-based pagination: cursors[i] = cursor to use when loading page i+1
|
|
// cursors[0] is always '' (empty cursor = first page)
|
|
let cursors = $state<string[]>(['']);
|
|
let currentPage = $state(1);
|
|
let totalPages = $state(1);
|
|
let nextCursor = $state<string | null>(null);
|
|
let loadingSearch = $state(false);
|
|
let initialLoad = $state(true);
|
|
|
|
async function fetchUsers(pageIdx: number = 0, search: string = '') {
|
|
loadingSearch = true;
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
per_page: '4'
|
|
});
|
|
const cursor = cursors[pageIdx];
|
|
if (cursor) {
|
|
params.set('cursor', cursor);
|
|
}
|
|
|
|
if (search.trim()) {
|
|
params.append('q', search.trim());
|
|
}
|
|
|
|
const response = await fetch(`/api/admin/users?${params}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${authStore.currentToken}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data: UserListResponse = await response.json();
|
|
|
|
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;
|
|
totalPages = data.totalPages;
|
|
currentPage = pageIdx + 1;
|
|
nextCursor = data.next_cursor ?? null;
|
|
|
|
// Pre-store cursor for the next page so we can go forward
|
|
if (nextCursor && cursors.length <= pageIdx + 1) {
|
|
cursors = [...cursors, nextCursor];
|
|
}
|
|
} else {
|
|
const text = await response.text();
|
|
toast.error('Failed to load users: ' + text);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching users:', err);
|
|
toast.error('Network error loading users');
|
|
} finally {
|
|
loadingSearch = false;
|
|
initialLoad = false;
|
|
}
|
|
}
|
|
|
|
function searchUsers() {
|
|
cursors = [''];
|
|
nextCursor = null;
|
|
currentPage = 1;
|
|
fetchUsers(0, userQuery);
|
|
}
|
|
|
|
function nextPage() {
|
|
if (!nextCursor || currentPage >= totalPages) return;
|
|
// currentPage is 1-indexed; next page index = currentPage
|
|
fetchUsers(currentPage, userQuery);
|
|
}
|
|
|
|
function previousPage() {
|
|
if (currentPage <= 1) return;
|
|
// currentPage is 1-indexed; previous page index = currentPage - 2
|
|
fetchUsers(currentPage - 2, userQuery);
|
|
}
|
|
|
|
// Load initial users on mount (guarded to run once)
|
|
$effect(() => {
|
|
if (initialLoad) {
|
|
fetchUsers(0);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<Card.Root class="h-full">
|
|
<Card.Header>
|
|
<div class="flex items-start justify-between">
|
|
<div>
|
|
<Card.Title class="flex items-center gap-2">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
Users
|
|
</Card.Title>
|
|
<Card.Description>Search and manage user details.</Card.Description>
|
|
</div>
|
|
|
|
<div class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium">
|
|
<span class="text-xs font-semibold">{totalUsers}</span>
|
|
</div>
|
|
</div>
|
|
</Card.Header>
|
|
<Card.Content class="space-y-4">
|
|
<div>
|
|
<div class="flex gap-2">
|
|
<Input
|
|
placeholder="Name, email or phone"
|
|
bind:value={userQuery}
|
|
onkeyup={(e) => {
|
|
if ((e as KeyboardEvent).key === 'Enter') searchUsers();
|
|
}}
|
|
/>
|
|
<Button onclick={searchUsers} disabled={loadingSearch}>
|
|
{loadingSearch ? 'Searching...' : 'Search'}
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="mt-3 max-h-60 space-y-2 overflow-y-auto">
|
|
{#if initialLoad}
|
|
{#each Array(3) as _, i (i)}
|
|
<div class="rounded bg-gray-50 p-2">
|
|
<Skeleton class="mb-1 h-4 w-32" />
|
|
<Skeleton class="h-3 w-48" />
|
|
</div>
|
|
{/each}
|
|
{:else if users.length === 0}
|
|
<div class="py-8 text-center text-sm text-gray-500">
|
|
{userQuery ? 'No users found matching your search.' : 'No users found.'}
|
|
</div>
|
|
{:else}
|
|
<div class="relative {loadingSearch ? 'opacity-60' : ''}">
|
|
{#each users as user (user.id)}
|
|
<div class="flex items-center justify-between rounded bg-gray-50 p-2">
|
|
<div>
|
|
<div class="font-medium">
|
|
{formatUserName(user.fullName, user.previousFirstName, user.previousLastName)}
|
|
</div>
|
|
<div class="text-xs text-gray-500">
|
|
{user.email || '—'} • {user.phone || '—'}
|
|
</div>
|
|
</div>
|
|
<Button variant="outline" onclick={() => openUserModal(user.id)}>View</Button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !initialLoad && totalPages > 1}
|
|
<div class="mt-3 flex items-center justify-between border-t pt-3 text-sm">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={previousPage}
|
|
disabled={currentPage === 1 || loadingSearch}
|
|
>
|
|
Previous
|
|
</Button>
|
|
<span class="text-xs text-gray-600">
|
|
Page {currentPage} of {totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={nextPage}
|
|
disabled={currentPage === totalPages || loadingSearch}
|
|
>
|
|
Next
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Card.Content>
|
|
</Card.Root>
|