- 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.
624 lines
29 KiB
TypeScript
624 lines
29 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'
|
||
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>
|
||
)
|
||
} |