feat: pool management fuckhead
This commit is contained in:
parent
198e41e601
commit
51c54eb905
51
src/app/admin/pool-management/hooks/addPool.ts
Normal file
51
src/app/admin/pool-management/hooks/addPool.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
|
||||||
|
export type AddPoolPayload = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
state?: 'active' | 'inactive';
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function addPool(payload: AddPoolPayload) {
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
const url = `${BASE_URL}/api/admin/pools`;
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = res.status === 201 || res.ok;
|
||||||
|
const message =
|
||||||
|
body?.message ||
|
||||||
|
(res.status === 409
|
||||||
|
? 'Pool name already exists.'
|
||||||
|
: res.status === 400
|
||||||
|
? 'Invalid request. Check name/state.'
|
||||||
|
: res.status === 401
|
||||||
|
? 'Unauthorized.'
|
||||||
|
: res.status === 403
|
||||||
|
? 'Forbidden.'
|
||||||
|
: res.status === 500
|
||||||
|
? 'Internal server error.'
|
||||||
|
: !ok
|
||||||
|
? `Request failed (${res.status}).`
|
||||||
|
: '');
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status: res.status,
|
||||||
|
body,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/app/admin/pool-management/hooks/archivePool.ts
Normal file
45
src/app/admin/pool-management/hooks/archivePool.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
|
||||||
|
export async function setPoolState(
|
||||||
|
id: string | number,
|
||||||
|
state: 'active' | 'inactive' | 'archived'
|
||||||
|
) {
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
const url = `${BASE_URL}/api/admin/pools/${id}/state`;
|
||||||
|
const res = await authFetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ state }),
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = res.ok;
|
||||||
|
const message =
|
||||||
|
body?.message ||
|
||||||
|
(res.status === 404
|
||||||
|
? 'Pool not found.'
|
||||||
|
: res.status === 400
|
||||||
|
? 'Invalid request.'
|
||||||
|
: res.status === 403
|
||||||
|
? 'Forbidden.'
|
||||||
|
: res.status === 500
|
||||||
|
? 'Server error.'
|
||||||
|
: !ok
|
||||||
|
? `Request failed (${res.status}).`
|
||||||
|
: '');
|
||||||
|
|
||||||
|
return { ok, status: res.status, body, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archivePoolById(id: string | number) {
|
||||||
|
return setPoolState(id, 'archived');
|
||||||
|
}
|
||||||
107
src/app/admin/pool-management/hooks/getlist.ts
Normal file
107
src/app/admin/pool-management/hooks/getlist.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
|
import { log } from '../../../utils/logger';
|
||||||
|
|
||||||
|
export type AdminPool = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
membersCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
archived?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminPools() {
|
||||||
|
const [pools, setPools] = useState<AdminPool[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
|
||||||
|
log("🌐 Pools: GET", url);
|
||||||
|
try {
|
||||||
|
const headers = { Accept: 'application/json' };
|
||||||
|
log("📤 Pools: Request headers:", headers);
|
||||||
|
|
||||||
|
const res = await authFetch(url, { headers });
|
||||||
|
log("📡 Pools: Response status:", res.status);
|
||||||
|
|
||||||
|
let body: any = null;
|
||||||
|
try {
|
||||||
|
body = await res.clone().json();
|
||||||
|
const preview = JSON.stringify(body).slice(0, 600);
|
||||||
|
log("📦 Pools: Response body preview:", preview);
|
||||||
|
} catch {
|
||||||
|
log("📦 Pools: Response body is not JSON or failed to parse");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
if (!cancelled) setError('Unauthorized. Please log in.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.status === 403) {
|
||||||
|
if (!cancelled) setError('Forbidden. Admin access required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!cancelled) setError('Failed to load pools.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||||
|
log("🔧 Pools: Mapping items count:", apiItems.length);
|
||||||
|
|
||||||
|
const mapped: AdminPool[] = apiItems.map(item => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: String(item.name ?? 'Unnamed Pool'),
|
||||||
|
description: String(item.description ?? ''),
|
||||||
|
membersCount: 0,
|
||||||
|
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||||
|
archived: String(item.state ?? '').toLowerCase() === 'archived',
|
||||||
|
}));
|
||||||
|
log("✅ Pools: Mapped sample:", mapped.slice(0, 3));
|
||||||
|
|
||||||
|
if (!cancelled) setPools(mapped);
|
||||||
|
} catch (e: any) {
|
||||||
|
log("❌ Pools: Network or parsing error:", e?.message || e);
|
||||||
|
if (!cancelled) setError('Network error while loading pools.');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [BASE_URL]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pools,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh: async () => {
|
||||||
|
const url = `${BASE_URL}/api/admin/pools`; // reverted to /api/admin/pools
|
||||||
|
log("🔁 Pools: Refresh GET", url);
|
||||||
|
const res = await authFetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) {
|
||||||
|
log("❌ Pools: Refresh failed status:", res.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
const apiItems: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||||
|
setPools(apiItems.map(item => ({
|
||||||
|
id: String(item.id),
|
||||||
|
name: String(item.name ?? 'Unnamed Pool'),
|
||||||
|
description: String(item.description ?? ''),
|
||||||
|
membersCount: 0,
|
||||||
|
createdAt: String(item.created_at ?? new Date().toISOString()),
|
||||||
|
archived: String(item.state ?? '').toLowerCase() === 'archived',
|
||||||
|
})));
|
||||||
|
log("✅ Pools: Refresh succeeded, items:", apiItems.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
351
src/app/admin/pool-management/manage/page.tsx
Normal file
351
src/app/admin/pool-management/manage/page.tsx
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
'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('name') ?? 'Unnamed Pool'
|
||||||
|
const poolDescription = searchParams.get('description') ?? ''
|
||||||
|
const poolState = searchParams.get('state') ?? 'active'
|
||||||
|
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 ${poolState === 'archived' ? '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 ${poolState === 'archived' ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||||
|
{poolState === 'archived' ? 'Archived' : '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>
|
||||||
|
)
|
||||||
|
}
|
||||||
342
src/app/admin/pool-management/page.tsx
Normal file
342
src/app/admin/pool-management/page.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import Header from '../../components/nav/Header'
|
||||||
|
import Footer from '../../components/Footer'
|
||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useAdminPools } from './hooks/getlist'
|
||||||
|
import useAuthStore from '../../store/authStore'
|
||||||
|
import { addPool } from './hooks/addPool'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { archivePoolById, setPoolState } from './hooks/archivePool'
|
||||||
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
|
|
||||||
|
type Pool = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
membersCount: number
|
||||||
|
createdAt: string
|
||||||
|
archived?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManagementPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Form state, dropdown, errors
|
||||||
|
const [form, setForm] = React.useState({ name: '', description: '' })
|
||||||
|
const [creating, setCreating] = React.useState(false)
|
||||||
|
const [formError, setFormError] = React.useState<string>('')
|
||||||
|
const [formSuccess, setFormSuccess] = React.useState<string>('')
|
||||||
|
const [createOpen, setCreateOpen] = React.useState(false)
|
||||||
|
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||||
|
|
||||||
|
// Initialize dropdown open state from localStorage and persist on changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
const stored = typeof window !== 'undefined' ? localStorage.getItem('admin:pools:createOpen') : null;
|
||||||
|
if (stored != null) setCreateOpen(stored === 'true');
|
||||||
|
}, []);
|
||||||
|
const setCreateOpenPersist = (val: boolean | ((v: boolean) => boolean)) => {
|
||||||
|
setCreateOpen(prev => {
|
||||||
|
const next = typeof val === 'function' ? (val as (v: boolean) => boolean)(prev) : val;
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem('admin:pools:createOpen', String(next));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Token and API URL
|
||||||
|
const token = useAuthStore.getState().accessToken
|
||||||
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
// Replace local fetch with hook
|
||||||
|
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||||
|
const [pools, setPools] = React.useState<Pool[]>([])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!loading && !error) {
|
||||||
|
setPools(initialPools)
|
||||||
|
}
|
||||||
|
}, [initialPools, loading, error])
|
||||||
|
|
||||||
|
async function handleCreatePool(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError('')
|
||||||
|
setFormSuccess('')
|
||||||
|
const name = form.name.trim()
|
||||||
|
const description = form.description.trim()
|
||||||
|
if (!name) {
|
||||||
|
setFormError('Please provide a pool name.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const res = await addPool({ name, description: description || undefined, state: 'active' })
|
||||||
|
if (res.ok && res.body?.data) {
|
||||||
|
setFormSuccess('Pool created successfully.')
|
||||||
|
setForm({ name: '', description: '' })
|
||||||
|
// Refresh list from backend to include the new pool
|
||||||
|
await refresh?.()
|
||||||
|
// Do NOT close; keep dropdown open across refresh
|
||||||
|
// setCreateOpenPersist(false) // removed to keep it open
|
||||||
|
} else {
|
||||||
|
setFormError(res.message || 'Failed to create pool.')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFormError('Network error while creating pool.')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleArchive(poolId: string) {
|
||||||
|
setArchiveError('')
|
||||||
|
const res = await archivePoolById(poolId)
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh?.()
|
||||||
|
} else {
|
||||||
|
setArchiveError(res.message || 'Failed to archive pool.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetActive(poolId: string) {
|
||||||
|
setArchiveError('')
|
||||||
|
const res = await setPoolState(poolId, 'active')
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh?.()
|
||||||
|
} else {
|
||||||
|
setArchiveError(res.message || 'Failed to activate pool.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'))
|
||||||
|
)
|
||||||
|
|
||||||
|
// NEW: block rendering until we decide access
|
||||||
|
const [authChecked, setAuthChecked] = React.useState(false)
|
||||||
|
React.useEffect(() => {
|
||||||
|
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// user exists and is admin
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
// Early return: render nothing until authorized, prevents any flash
|
||||||
|
if (!authChecked) return null
|
||||||
|
|
||||||
|
// Remove Access Denied overlay; render normal content
|
||||||
|
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">
|
||||||
|
{/* Page Header: remove sticky and high z-index */}
|
||||||
|
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
|
||||||
|
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateOpenPersist(o => !o)}
|
||||||
|
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"
|
||||||
|
aria-expanded={createOpen}
|
||||||
|
aria-controls="create-pool-section"
|
||||||
|
>
|
||||||
|
{createOpen ? 'Close' : 'Create New Pool'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Create Pool dropdown */}
|
||||||
|
<div id="create-pool-section" className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-visible mb-8 relative z-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateOpenPersist(o => !o)}
|
||||||
|
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
||||||
|
aria-expanded={createOpen}
|
||||||
|
>
|
||||||
|
<span className="text-lg font-semibold text-blue-900">Create New Pool</span>
|
||||||
|
<span className={`transition-transform ${createOpen ? 'rotate-180' : 'rotate-0'} text-gray-500`} aria-hidden="true">▼</span>
|
||||||
|
</button>
|
||||||
|
{createOpen && (
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
{/* Success/Error banners */}
|
||||||
|
{formSuccess && (
|
||||||
|
<div className="mb-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||||
|
{formSuccess}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleCreatePool} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
|
placeholder="e.g., VIP Members"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
|
rows={3}
|
||||||
|
placeholder="Short description of the pool"
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
||||||
|
{creating ? 'Creating...' : 'Create Pool'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
||||||
|
onClick={() => { setForm({ name: '', description: '' }); setFormError(''); setFormSuccess(''); }}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pools List card */}
|
||||||
|
<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">Existing Pools</h2>
|
||||||
|
<span className="text-sm text-gray-600">{pools.length} total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Show archive errors */}
|
||||||
|
{archiveError && (
|
||||||
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{archiveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-5 w-1/2 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-gray-200 rounded" />
|
||||||
|
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
||||||
|
<div className="h-8 w-full bg-gray-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{pools.map(pool => (
|
||||||
|
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
|
||||||
|
<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>
|
||||||
|
<h3 className="text-lg font-semibold text-blue-900">{pool.name}</h3>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${pool.archived ? '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 ${pool.archived ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||||
|
{pool.archived ? 'Archived' : 'Active'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Members</span>
|
||||||
|
<div className="font-medium text-gray-900">{pool.membersCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Created</span>
|
||||||
|
<div className="font-medium text-gray-900">
|
||||||
|
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: String(pool.id),
|
||||||
|
name: pool.name ?? '',
|
||||||
|
description: pool.description ?? '',
|
||||||
|
state: pool.archived ? 'archived' : 'active',
|
||||||
|
createdAt: pool.createdAt ?? '',
|
||||||
|
})
|
||||||
|
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
{pool.archived ? (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
|
||||||
|
onClick={() => handleSetActive(pool.id)}
|
||||||
|
title="Activate this pool"
|
||||||
|
>
|
||||||
|
Set Active
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
||||||
|
onClick={() => handleArchive(pool.id)}
|
||||||
|
title="Archive this pool"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{pools.length === 0 && !loading && !error && (
|
||||||
|
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||||
|
No pools found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</PageTransitionEffect>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -468,6 +468,13 @@ export default function Header() {
|
|||||||
>
|
>
|
||||||
Coffee Subscription Management
|
Coffee Subscription Management
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { router.push('/admin/pool-management'); setAdminMgmtOpen(false); }}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-[#0F1D37] hover:bg-[#F5F3EE]"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
Pool Management
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -44,14 +44,28 @@ interface CustomRequestInit extends RequestInit {
|
|||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: safe stringify body for logging
|
||||||
|
function safeBodyPreview(body: any, max = 500): string | null {
|
||||||
|
if (body == null) return null;
|
||||||
|
try {
|
||||||
|
const s = typeof body === "string" ? body : JSON.stringify(body);
|
||||||
|
return s.length > max ? s.slice(0, max) + "…" : s;
|
||||||
|
} catch {
|
||||||
|
return "[unserializable body]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Main authFetch function
|
// Main authFetch function
|
||||||
export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> {
|
export async function authFetch(input: RequestInfo | URL, init: CustomRequestInit = {}): Promise<Response> {
|
||||||
// Always get the fresh token from store at call time
|
// Always get the fresh token from store at call time
|
||||||
let accessToken = getAccessToken();
|
let accessToken = getAccessToken();
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
|
||||||
|
const method = (init.method || "GET").toUpperCase();
|
||||||
|
const bodyPreview = safeBodyPreview(init.body);
|
||||||
|
|
||||||
log("🌐 authFetch: Making API call to:", url);
|
log("🌐 authFetch: Making API call to:", url);
|
||||||
log("🔑 authFetch: Using token:", accessToken ? `${accessToken.substring(0, 20)}...` : "No token");
|
log("🔧 authFetch: Method:", method);
|
||||||
|
log("📤 authFetch: Body preview:", bodyPreview);
|
||||||
|
|
||||||
// Add Authorization header if accessToken exists
|
// Add Authorization header if accessToken exists
|
||||||
const buildHeaders = (token: string | null): Record<string, string> => ({
|
const buildHeaders = (token: string | null): Record<string, string> => ({
|
||||||
@ -63,6 +77,7 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
|||||||
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
|
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
|
||||||
log("📡 authFetch: Sending request", {
|
log("📡 authFetch: Sending request", {
|
||||||
url,
|
url,
|
||||||
|
method,
|
||||||
headers: Object.keys(hdrs),
|
headers: Object.keys(hdrs),
|
||||||
hasAuth: !!hdrs.Authorization,
|
hasAuth: !!hdrs.Authorization,
|
||||||
authPrefix: hdrs.Authorization ? `${hdrs.Authorization.substring(0, 24)}...` : null,
|
authPrefix: hdrs.Authorization ? `${hdrs.Authorization.substring(0, 24)}...` : null,
|
||||||
@ -94,6 +109,11 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
|||||||
log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null);
|
log("🔁 authFetch: Retrieved new token from store after refresh:", newToken ? `${newToken.substring(0,20)}...` : null);
|
||||||
|
|
||||||
log("🔄 authFetch: Retrying original request with refreshed token (if available)");
|
log("🔄 authFetch: Retrying original request with refreshed token (if available)");
|
||||||
|
log("📡 authFetch: Retrying request", {
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
bodyPreview,
|
||||||
|
});
|
||||||
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
|
res = await fetch(url, { ...init, headers: buildHeaders(newToken), credentials: "include" });
|
||||||
log("📡 authFetch: Retry response status:", res.status);
|
log("📡 authFetch: Retry response status:", res.status);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user