Files
Crussell/frontend/src/lib/components/admin/UsersCard.svelte
T
popertots 4af2b8dfb4
Backend CI / Tests (push) Failing after 1m41s
Backend CI / Lint & vulns (push) Failing after 2m26s
Backend CI / Race detector (push) Failing after 3m45s
style: fix prefer-const and prettier formatting issues
2026-06-25 13:48:03 +01:00

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>