353 lines
17 KiB
TypeScript
353 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
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'
|
|
|
|
type PoolUser = {
|
|
id: string
|
|
name: string
|
|
email: string
|
|
contributed: number
|
|
joinedAt: string // NEW: member since
|
|
}
|
|
|
|
export default function PoolManagePage() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const user = useAuthStore(s => s.user)
|
|
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[]>([])
|
|
|
|
// 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)
|
|
|
|
// Early return AFTER all hooks are declared to keep consistent order
|
|
if (!authChecked) return null
|
|
|
|
// Remove dummy candidate source; keep search scaffolding returning empty
|
|
async function doSearch() {
|
|
setError('')
|
|
const q = query.trim().toLowerCase()
|
|
if (q.length < 3) {
|
|
setHasSearched(false)
|
|
setCandidates([])
|
|
return
|
|
}
|
|
setHasSearched(true)
|
|
setLoading(true)
|
|
setTimeout(() => {
|
|
setCandidates([]) // no local dummy results
|
|
setLoading(false)
|
|
}, 300)
|
|
}
|
|
|
|
function addUserFromModal(u: { id: string; name: string; email: string }) {
|
|
// Append user to pool; contribution stays zero; joinedAt is now.
|
|
setUsers(prev => [{ id: u.id, name: u.name, email: u.email, contributed: 0, joinedAt: new Date().toISOString() }, ...prev])
|
|
setSearchOpen(false)
|
|
setQuery('')
|
|
setCandidates([])
|
|
setHasSearched(false)
|
|
setError('')
|
|
setLoading(false)
|
|
}
|
|
|
|
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>
|
|
<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>
|
|
</article>
|
|
))}
|
|
{users.length === 0 && (
|
|
<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(''); setCandidates([]); setHasSearched(false); 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">
|
|
<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>
|
|
<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-end bg-gray-50">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PageTransitionEffect>
|
|
)
|
|
} |