546 lines
24 KiB
TypeScript
546 lines
24 KiB
TypeScript
'use client'
|
|
|
|
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
|
import Header from '../../../components/nav/Header'
|
|
import Footer from '../../../components/Footer'
|
|
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
|
import { useRouter, useSearchParams } from 'next/navigation'
|
|
import useAuthStore from '../../../store/authStore'
|
|
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
|
import { AdminAPI } from '../../../utils/api'
|
|
|
|
type PoolUser = {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
contributed: number
|
|
joinedAt: string // NEW: member since
|
|
}
|
|
|
|
function PoolManagePageInner() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const user = useAuthStore(s => s.user)
|
|
const token = useAuthStore(s => s.accessToken)
|
|
const isAdmin =
|
|
!!user &&
|
|
(
|
|
(user as any)?.role === 'admin' ||
|
|
(user as any)?.userType === 'admin' ||
|
|
(user as any)?.isAdmin === true ||
|
|
((user as any)?.roles?.includes?.('admin'))
|
|
)
|
|
|
|
// Auth gate
|
|
const [authChecked, setAuthChecked] = React.useState(false)
|
|
React.useEffect(() => {
|
|
if (user === null) {
|
|
router.replace('/login')
|
|
return
|
|
}
|
|
if (user && !isAdmin) {
|
|
router.replace('/')
|
|
return
|
|
}
|
|
setAuthChecked(true)
|
|
}, [user, isAdmin, router])
|
|
|
|
// Read pool data from query params with fallbacks (hooks must be before any return)
|
|
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
|
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
|
const poolDescription = searchParams.get('description') ?? ''
|
|
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
|
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
|
const poolIsActive = searchParams.get('is_active') === 'true'
|
|
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
|
|
|
// Members (no dummy data)
|
|
const [users, setUsers] = React.useState<PoolUser[]>([])
|
|
const [membersLoading, setMembersLoading] = React.useState(false)
|
|
const [membersError, setMembersError] = React.useState<string>('')
|
|
|
|
// Stats (no dummy data)
|
|
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
|
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
|
|
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
|
|
|
|
// Search modal state
|
|
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
const [query, setQuery] = React.useState('')
|
|
const [loading, setLoading] = React.useState(false)
|
|
const [error, setError] = React.useState<string>('')
|
|
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
|
const [hasSearched, setHasSearched] = React.useState(false)
|
|
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
|
const [savingMembers, setSavingMembers] = React.useState(false)
|
|
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
|
const [removeError, setRemoveError] = React.useState<string>('')
|
|
|
|
async function fetchMembers() {
|
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
setMembersError('')
|
|
setMembersLoading(true)
|
|
try {
|
|
const resp = await AdminAPI.getPoolMembers(token, poolId)
|
|
const rows = Array.isArray(resp?.members) ? resp.members : []
|
|
const mapped: PoolUser[] = rows.map((row: any) => {
|
|
const name = row.company_name
|
|
? String(row.company_name)
|
|
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
|
return {
|
|
id: String(row.id),
|
|
name: name || String(row.email || '').trim() || 'Unnamed user',
|
|
email: String(row.email || '').trim(),
|
|
contributed: 0,
|
|
joinedAt: row.joined_at || new Date().toISOString()
|
|
}
|
|
})
|
|
setUsers(mapped)
|
|
} catch (e: any) {
|
|
setMembersError(e?.message || 'Failed to load pool members.')
|
|
} finally {
|
|
setMembersLoading(false)
|
|
}
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
void fetchMembers()
|
|
}, [token, poolId])
|
|
|
|
// Early return AFTER all hooks are declared to keep consistent order
|
|
if (!authChecked) return null
|
|
|
|
async function doSearch() {
|
|
setError('')
|
|
const q = query.trim().toLowerCase()
|
|
if (q.length < 3) {
|
|
setHasSearched(false)
|
|
setCandidates([])
|
|
return
|
|
}
|
|
if (!token) {
|
|
setError('Authentication required.')
|
|
setHasSearched(true)
|
|
setCandidates([])
|
|
return
|
|
}
|
|
|
|
setHasSearched(true)
|
|
setLoading(true)
|
|
try {
|
|
const resp = await AdminAPI.getUserList(token)
|
|
const list = Array.isArray(resp?.users) ? resp.users : []
|
|
|
|
const existingIds = new Set(users.map(u => String(u.id)))
|
|
|
|
const mapped: Array<{ id: string; name: string; email: string }> = list
|
|
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
|
|
.map((u: any) => {
|
|
const name = u.company_name
|
|
? String(u.company_name)
|
|
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
|
return {
|
|
id: String(u.id),
|
|
name: name || String(u.email || '').trim() || 'Unnamed user',
|
|
email: String(u.email || '').trim()
|
|
}
|
|
})
|
|
.filter((u: { id: string; name: string; email: string }) => !existingIds.has(u.id))
|
|
.filter((u: { id: string; name: string; email: string }) => {
|
|
const hay = `${u.name} ${u.email}`.toLowerCase()
|
|
return hay.includes(q)
|
|
})
|
|
|
|
setCandidates(mapped)
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Failed to search users.')
|
|
setCandidates([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function addUserFromModal(u: { id: string; name: string; email: string }) {
|
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
setSavingMembers(true)
|
|
setError('')
|
|
try {
|
|
await AdminAPI.addPoolMembers(token, poolId, [u.id])
|
|
await fetchMembers()
|
|
setSearchOpen(false)
|
|
setQuery('')
|
|
setCandidates([])
|
|
setHasSearched(false)
|
|
setSelectedCandidates(new Set())
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Failed to add user.')
|
|
} finally {
|
|
setLoading(false)
|
|
setSavingMembers(false)
|
|
}
|
|
}
|
|
|
|
function toggleCandidate(id: string) {
|
|
setSelectedCandidates(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
async function addSelectedUsers() {
|
|
if (selectedCandidates.size === 0) return
|
|
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
|
|
if (selectedList.length === 0) return
|
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
setSavingMembers(true)
|
|
setError('')
|
|
try {
|
|
const userIds = selectedList.map(u => u.id)
|
|
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
|
await fetchMembers()
|
|
setSearchOpen(false)
|
|
setQuery('')
|
|
setCandidates([])
|
|
setHasSearched(false)
|
|
setSelectedCandidates(new Set())
|
|
} catch (e: any) {
|
|
setError(e?.message || 'Failed to add users.')
|
|
} finally {
|
|
setLoading(false)
|
|
setSavingMembers(false)
|
|
}
|
|
}
|
|
|
|
async function removeMember(userId: string) {
|
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
const user = users.find(u => u.id === userId)
|
|
const label = user?.name || user?.email || 'this user'
|
|
if (!window.confirm(`Remove ${label} from this pool?`)) return
|
|
setRemoveError('')
|
|
setRemovingMemberId(userId)
|
|
try {
|
|
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
|
await fetchMembers()
|
|
} catch (e: any) {
|
|
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
|
} finally {
|
|
setRemovingMemberId(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<PageTransitionEffect>
|
|
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
|
<Header />
|
|
{/* main wrapper: avoid high z-index stacking */}
|
|
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
|
<div className="max-w-7xl mx-auto relative z-0">
|
|
{/* Header (remove sticky/z-10) */}
|
|
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
|
<UsersIcon className="h-5 w-5 text-blue-900" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
|
|
<p className="text-sm text-blue-700">
|
|
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
|
</p>
|
|
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
|
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
|
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
|
{!poolIsActive ? 'Inactive' : 'Active'}
|
|
</span>
|
|
<span>•</span>
|
|
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
|
<span>•</span>
|
|
<span className="text-gray-500">ID: {poolId}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Back to Pool Management */}
|
|
<button
|
|
onClick={() => router.push('/admin/pool-management')}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
|
|
title="Back to Pool Management"
|
|
>
|
|
← Back
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Stats (now zero until backend wired) */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
|
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-md bg-blue-900 p-2">
|
|
<BanknotesIcon className="h-5 w-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">Total in Pool</p>
|
|
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-md bg-amber-600 p-2">
|
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">This Year</p>
|
|
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
<div className="flex items-center gap-3">
|
|
<div className="rounded-md bg-green-600 p-2">
|
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-600">Current Month</p>
|
|
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unified Members card: add button + list */}
|
|
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
|
<button
|
|
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Add User
|
|
</button>
|
|
</div>
|
|
{removeError && (
|
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
{removeError}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{users.map(u => (
|
|
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
|
<UsersIcon className="h-5 w-5 text-blue-900" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
|
|
<p className="text-xs text-gray-600">{u.email}</p>
|
|
</div>
|
|
</div>
|
|
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
|
|
€ {u.contributed.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 text-xs text-gray-600">
|
|
Member since:{' '}
|
|
<span className="font-medium text-gray-900">
|
|
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
<div className="mt-4 flex justify-end">
|
|
<button
|
|
onClick={() => removeMember(u.id)}
|
|
disabled={removingMemberId === u.id}
|
|
className="px-3 py-2 text-xs font-medium rounded-lg border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
|
>
|
|
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
{membersLoading && (
|
|
<div className="col-span-full text-center text-gray-500 italic py-6">
|
|
Loading members...
|
|
</div>
|
|
)}
|
|
{membersError && !membersLoading && (
|
|
<div className="col-span-full text-center text-red-600 py-6">
|
|
{membersError}
|
|
</div>
|
|
)}
|
|
{users.length === 0 && !membersLoading && !membersError && (
|
|
<div className="col-span-full text-center text-gray-500 italic py-6">
|
|
No users in this pool yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<Footer />
|
|
|
|
{/* Search Modal (keep above with high z) */}
|
|
{searchOpen && (
|
|
<div className="fixed inset-0 z-50">
|
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
|
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
|
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
|
{/* Header */}
|
|
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
|
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
|
|
<button
|
|
onClick={() => setSearchOpen(false)}
|
|
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
|
aria-label="Close"
|
|
>
|
|
<XMarkIcon className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<form
|
|
onSubmit={e => { e.preventDefault(); void doSearch(); }}
|
|
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
|
|
>
|
|
<div className="md:col-span-3">
|
|
<div className="relative">
|
|
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<input
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder="Search name or email…"
|
|
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 md:col-span-2">
|
|
<button
|
|
type="submit"
|
|
disabled={loading || query.trim().length < 3}
|
|
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
|
>
|
|
{loading ? 'Searching…' : 'Search'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setQuery(''); setError(''); }}
|
|
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">
|
|
Min. 3 characters
|
|
</div>
|
|
|
|
{/* Results */}
|
|
<div className="px-6 py-4">
|
|
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
|
{!error && query.trim().length < 3 && (
|
|
<div className="py-8 text-sm text-gray-500 text-center">
|
|
Enter at least 3 characters and click Search.
|
|
</div>
|
|
)}
|
|
{!error && hasSearched && loading && candidates.length === 0 && (
|
|
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
<li key={i} className="animate-pulse px-4 py-3">
|
|
<div className="h-3.5 w-36 bg-gray-200 rounded" />
|
|
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{!error && hasSearched && !loading && candidates.length === 0 && (
|
|
<div className="py-8 text-sm text-gray-500 text-center">
|
|
No users match your search.
|
|
</div>
|
|
)}
|
|
{!error && candidates.length > 0 && (
|
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
|
{candidates.map(u => (
|
|
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
|
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
|
checked={selectedCandidates.has(u.id)}
|
|
onChange={() => toggleCandidate(u.id)}
|
|
/>
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<UsersIcon className="h-4 w-4 text-blue-900" />
|
|
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
|
|
</div>
|
|
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
|
</div>
|
|
</label>
|
|
<button
|
|
onClick={() => addUserFromModal(u)}
|
|
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
|
>
|
|
Add
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{loading && candidates.length > 0 && (
|
|
<div className="pointer-events-none relative">
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
|
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
|
<div className="text-xs text-gray-600">
|
|
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setSearchOpen(false)}
|
|
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
|
>
|
|
Done
|
|
</button>
|
|
<button
|
|
onClick={addSelectedUsers}
|
|
disabled={selectedCandidates.size === 0 || savingMembers}
|
|
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{savingMembers ? 'Adding…' : 'Add Selected'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageTransitionEffect>
|
|
)
|
|
}
|
|
|
|
// CHANGED: Suspense wrapper required for useSearchParams() during prerender
|
|
export default function PoolManagePage() {
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
|
<p className="text-[#4A4A4A]">Loading...</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
>
|
|
<PoolManagePageInner />
|
|
</Suspense>
|
|
)
|
|
} |