profit-planet-frontend/src/app/admin/pool-management/manage/page.tsx
seaznCode de290cd9ef feat: enhance finance management and pool management features
- Added InvoiceDetailModal to display invoice details in a modal on the Finance Management page.
- Updated invoice amount display to show gross amount instead of net amount.
- Refactored invoice selection logic to open the detail modal.
- Removed unused subscription handling in Pool Management page.
- Simplified pool management UI by removing the create pool modal and related state management.
- Enhanced pool display with visual indicators for core pools and improved styling.
- Updated member display to show share instead of contributed amount in Pool Management.
2026-03-08 16:29:01 +01:00

624 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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'
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
type PoolUser = {
id: string
name: string
email: string
share: number
joinedAt: string
}
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>('')
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
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(),
share: Number(row.share ?? 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])
// Fetch pool inflow stats
React.useEffect(() => {
if (!token || !poolId || poolId === 'pool-unknown') return
let cancelled = false
async function loadStats() {
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const res = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
})
const body = await res.json().catch(() => ({}))
if (!cancelled && res.ok && body?.success) {
setTotalAmount(Number(body.data?.total_amount ?? 0))
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
}
} catch {
// ignore — stats are non-critical
}
}
void loadStats()
return () => { cancelled = true }
}, [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) {
const user = users.find(u => u.id === userId)
const label = user?.name || user?.email || 'this user'
setRemoveConfirm({ userId, label })
}
async function confirmRemoveMember() {
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
const userId = removeConfirm.userId
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)
setRemoveConfirm(null)
}
}
const isCore = poolName === 'Core'
return (
<PageTransitionEffect>
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : '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={`backdrop-blur border-b py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0 ${
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/90 border-blue-100'
}`}>
{isCore && (
<div className="inline-flex items-center gap-1.5 self-start rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-2">
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
Core Pool 1¢ per capsule per member
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`h-10 w-10 rounded-lg border flex items-center justify-center ${
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
}`}>
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
</div>
<div>
<h1 className={`text-3xl font-extrabold tracking-tight ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{poolName}</h1>
<p className={`text-sm ${isCore ? 'text-amber-700' : '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 flex-wrap">
<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>Price/capsule (gross): {Number(poolPrice || 0).toFixed(2)}{isCore ? ' × each member' : ''}</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">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
{users.length}
</span>
</div>
<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>
)}
{membersLoading && (
<div className="text-center text-gray-500 italic py-8">Loading members...</div>
)}
{membersError && !membersLoading && (
<div className="text-center text-red-600 py-8">{membersError}</div>
)}
{users.length === 0 && !membersLoading && !membersError && (
<div className="text-center text-gray-500 italic py-8">No users in this pool yet.</div>
)}
{users.length > 0 && !membersLoading && (
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-semibold text-gray-700">Name</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700">Email</th>
<th className="px-4 py-3 text-left font-semibold text-gray-700">Member Since</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700">{isCore ? 'Total Earned' : 'Share'}</th>
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{users.map(u => (
<tr key={u.id} className="hover:bg-gray-50 transition">
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-xs font-bold text-blue-800">
{(u.name?.[0] || '?').toUpperCase()}
</div>
<span className="font-medium text-gray-900">{u.name}</span>
</div>
</td>
<td className="px-4 py-3 whitespace-nowrap text-gray-600">{u.email}</td>
<td className="px-4 py-3 whitespace-nowrap text-gray-600">
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
u.share > 0
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-gray-50 text-gray-500 border border-gray-200'
}`}>
{u.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-right">
<button
onClick={() => removeMember(u.id)}
disabled={removingMemberId === u.id}
className="px-3 py-1.5 text-xs font-medium rounded-md 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>
</td>
</tr>
))}
</tbody>
</table>
</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 max-h-[90vh] 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 overflow-y-auto min-h-0 flex-1">
{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>
)}
<ConfirmActionModal
open={Boolean(removeConfirm)}
pending={Boolean(removingMemberId)}
intent="danger"
title="Remove member from pool?"
description={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
confirmText="Remove"
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
onConfirm={confirmRemoveMember}
/>
</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>
)
}