522 lines
22 KiB
TypeScript
522 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||
import PageLayout from '../../components/PageLayout'
|
||
import UserDetailModal from '../../components/UserDetailModal'
|
||
import {
|
||
MagnifyingGlassIcon,
|
||
PencilSquareIcon,
|
||
ExclamationTriangleIcon
|
||
} from '@heroicons/react/24/outline'
|
||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||
import { AdminAPI } from '../../utils/api'
|
||
import useAuthStore from '../../store/authStore'
|
||
|
||
type UserType = 'personal' | 'company'
|
||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
||
type UserRole = 'user' | 'admin'
|
||
|
||
interface User {
|
||
id: number
|
||
email: string
|
||
user_type: UserType
|
||
role: UserRole
|
||
created_at: string
|
||
last_login_at: string | null
|
||
status: string
|
||
is_admin_verified: number
|
||
first_name?: string
|
||
last_name?: string
|
||
company_name?: string
|
||
}
|
||
|
||
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
|
||
const TYPES: UserType[] = ['personal','company']
|
||
const ROLES: UserRole[] = ['user','admin']
|
||
|
||
export default function AdminUserManagementPage() {
|
||
const { isAdmin } = useAdminUsers()
|
||
const token = useAuthStore(state => state.accessToken)
|
||
const [isClient, setIsClient] = useState(false)
|
||
|
||
// State for all users (not just pending)
|
||
const [allUsers, setAllUsers] = useState<User[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
// Handle client-side mounting
|
||
useEffect(() => {
|
||
setIsClient(true)
|
||
}, [])
|
||
|
||
// Fetch all users from backend
|
||
const fetchAllUsers = useCallback(async () => {
|
||
if (!token || !isAdmin) return
|
||
|
||
setLoading(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const response = await AdminAPI.getUserList(token)
|
||
if (response.success) {
|
||
setAllUsers(response.users || [])
|
||
} else {
|
||
throw new Error(response.message || 'Failed to fetch users')
|
||
}
|
||
} catch (err) {
|
||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
|
||
setError(errorMessage)
|
||
console.error('AdminUserManagement.fetchAllUsers error:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [token, isAdmin])
|
||
|
||
// Load users on mount
|
||
useEffect(() => {
|
||
if (isClient && isAdmin && token) {
|
||
fetchAllUsers()
|
||
}
|
||
}, [fetchAllUsers, isClient])
|
||
|
||
// Filter hooks - must be declared before conditional returns
|
||
const [search, setSearch] = useState('')
|
||
const [fType, setFType] = useState<'all'|UserType>('all')
|
||
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
|
||
const [fRole, setFRole] = useState<'all'|UserRole>('all')
|
||
const [page, setPage] = useState(1)
|
||
const PAGE_SIZE = 10
|
||
|
||
// Modal state
|
||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||
|
||
const filtered = useMemo(() => {
|
||
return allUsers.filter(u => {
|
||
const firstName = u.first_name || ''
|
||
const lastName = u.last_name || ''
|
||
const companyName = u.company_name || ''
|
||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||
|
||
// Use backend status directly for filtering
|
||
const allowedStatuses: UserStatus[] = ['pending','active','suspended','inactive','archived']
|
||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||
|
||
return (
|
||
(fType === 'all' || u.user_type === fType) &&
|
||
(fStatus === 'all' || userStatus === fStatus) &&
|
||
(fRole === 'all' || u.role === fRole) &&
|
||
(
|
||
!search.trim() ||
|
||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||
fullName.toLowerCase().includes(search.toLowerCase())
|
||
)
|
||
)
|
||
})
|
||
}, [allUsers, search, fType, fStatus, fRole])
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
||
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||
|
||
// Move stats calculation above all conditional returns to avoid hook order errors
|
||
const stats = useMemo(() => ({
|
||
total: allUsers.length,
|
||
admins: allUsers.filter(u => u.role === 'admin').length,
|
||
personal: allUsers.filter(u => u.user_type === 'personal').length,
|
||
company: allUsers.filter(u => u.user_type === 'company').length,
|
||
active: allUsers.filter(u => u.status === 'active').length,
|
||
pending: allUsers.filter(u => u.status === 'pending').length,
|
||
}), [allUsers])
|
||
|
||
// Show loading during SSR/initial client render
|
||
if (!isClient) {
|
||
return (
|
||
<PageLayout>
|
||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||
<div className="text-center">
|
||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||
<p className="text-blue-900">Loading...</p>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
)
|
||
}
|
||
|
||
// Access check (only after client-side hydration)
|
||
if (!isAdmin) {
|
||
return (
|
||
<PageLayout>
|
||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||
<div className="text-center">
|
||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
)
|
||
}
|
||
|
||
const applyFilter = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setPage(1)
|
||
}
|
||
|
||
// NEW: CSV export utilities (exports all filtered results, not only current page)
|
||
const toCsvValue = (v: unknown) => {
|
||
if (v === null || v === undefined) return '""'
|
||
const s = String(v).replace(/"/g, '""')
|
||
return `"${s}"`
|
||
}
|
||
|
||
const exportCsv = () => {
|
||
const headers = [
|
||
'ID','Email','Type','Role','Status','Admin Verified',
|
||
'First Name','Last Name','Company Name','Created At','Last Login At'
|
||
]
|
||
const rows = filtered.map(u => {
|
||
// Use backend status directly
|
||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||
return [
|
||
u.id,
|
||
u.email,
|
||
u.user_type,
|
||
u.role,
|
||
userStatus,
|
||
u.is_admin_verified === 1 ? 'yes' : 'no',
|
||
u.first_name || '',
|
||
u.last_name || '',
|
||
u.company_name || '',
|
||
new Date(u.created_at).toISOString(),
|
||
u.last_login_at ? new Date(u.last_login_at).toISOString() : ''
|
||
].map(toCsvValue).join(',')
|
||
})
|
||
const csv = [headers.join(','), ...rows].join('\r\n')
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `users_${new Date().toISOString().slice(0,10)}.csv`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
const badge = (text: string, color: 'blue'|'amber'|'green'|'gray'|'rose'|'indigo'|'purple') => {
|
||
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide'
|
||
const map: Record<string,string> = {
|
||
blue: 'bg-blue-100 text-blue-700',
|
||
amber: 'bg-amber-100 text-amber-700',
|
||
green: 'bg-green-100 text-green-700',
|
||
gray: 'bg-gray-100 text-gray-700',
|
||
rose: 'bg-rose-100 text-rose-700',
|
||
indigo: 'bg-indigo-100 text-indigo-700',
|
||
purple: 'bg-purple-100 text-purple-700'
|
||
}
|
||
return <span className={`${base} ${map[color]}`}>{text}</span>
|
||
}
|
||
|
||
const statusBadge = (s: UserStatus) =>
|
||
s==='active' ? badge('Active','green')
|
||
: s==='pending' ? badge('Pending','amber')
|
||
: s==='suspended' ? badge('Suspended','rose')
|
||
: s==='archived' ? badge('Archived','gray')
|
||
: s==='inactive' ? badge('Inactive','gray')
|
||
: badge('Unknown','gray')
|
||
|
||
const typeBadge = (t: UserType) =>
|
||
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
|
||
|
||
const roleBadge = (r: UserRole) =>
|
||
r==='admin' ? badge('Admin','indigo') : badge('User','gray')
|
||
|
||
// Action handler for opening edit modal
|
||
const onEdit = (id: string) => {
|
||
setSelectedUserId(id)
|
||
setIsDetailModalOpen(true)
|
||
}
|
||
|
||
return (
|
||
<PageLayout>
|
||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||
{/* Header */}
|
||
<header className="sticky top-0 z-10 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">
|
||
<div>
|
||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">User Management</h1>
|
||
<p className="text-lg text-blue-700 mt-2">
|
||
Manage all users, view statistics, and handle verification.
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Statistic Section + Verify Button */}
|
||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6 flex-1">
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Total Users</div>
|
||
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Admins</div>
|
||
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Personal</div>
|
||
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Company</div>
|
||
<div className="text-xl font-semibold text-purple-700">{stats.company}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Active</div>
|
||
<div className="text-xl font-semibold text-green-700">{stats.active}</div>
|
||
</div>
|
||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
||
<div className="text-xs text-gray-500">Pending</div>
|
||
<div className="text-xl font-semibold text-amber-700">{stats.pending}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="inline-flex items-center gap-2 rounded-lg bg-amber-100 hover:bg-amber-200 border border-amber-200 text-amber-800 text-base font-semibold px-5 py-3 shadow transition"
|
||
onClick={() => window.location.href = '/admin/user-verify'}
|
||
>
|
||
Go to User Verification
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Error Message */}
|
||
{error && (
|
||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||
<div>
|
||
<p className="font-semibold">Error loading users</p>
|
||
<p className="text-sm text-red-600">{error}</p>
|
||
<button
|
||
onClick={fetchAllUsers}
|
||
className="mt-2 text-sm underline hover:no-underline"
|
||
>
|
||
Try again
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter Card */}
|
||
<form
|
||
onSubmit={applyFilter}
|
||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
||
>
|
||
<h2 className="text-lg font-semibold text-blue-900">
|
||
Search & Filter Users
|
||
</h2>
|
||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||
{/* Search */}
|
||
<div className="md:col-span-2">
|
||
<label className="sr-only">Search</label>
|
||
<div className="relative">
|
||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
||
<input
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
placeholder="Email, name, company..."
|
||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* Type */}
|
||
<div>
|
||
<select
|
||
value={fType}
|
||
onChange={e => setFType(e.target.value as any)}
|
||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||
>
|
||
<option value="all">All Types</option>
|
||
<option value="personal">Personal</option>
|
||
<option value="company">Company</option>
|
||
</select>
|
||
</div>
|
||
{/* Status */}
|
||
<div>
|
||
<select
|
||
value={fStatus}
|
||
onChange={e => setFStatus(e.target.value as any)}
|
||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||
>
|
||
<option value="all">All Status</option>
|
||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
||
</select>
|
||
</div>
|
||
{/* Role */}
|
||
<div>
|
||
<select
|
||
value={fRole}
|
||
onChange={e => setFRole(e.target.value as any)}
|
||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
||
>
|
||
<option value="all">All Roles</option>
|
||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={exportCsv}
|
||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-blue-900 text-sm font-semibold px-5 py-3 shadow transition"
|
||
title="Export all filtered users to CSV"
|
||
>
|
||
Export all users as CSV
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
|
||
>
|
||
Filter
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Users Table */}
|
||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
||
<div className="text-lg font-semibold text-blue-900">
|
||
All Users
|
||
</div>
|
||
<div className="text-xs text-gray-500">
|
||
Showing {current.length} of {filtered.length} users
|
||
</div>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
||
<tr>
|
||
<th className="px-4 py-3 text-left">User</th>
|
||
<th className="px-4 py-3 text-left">Type</th>
|
||
<th className="px-4 py-3 text-left">Status</th>
|
||
<th className="px-4 py-3 text-left">Role</th>
|
||
<th className="px-4 py-3 text-left">Created</th>
|
||
<th className="px-4 py-3 text-left">Last Login</th>
|
||
<th className="px-4 py-3 text-left">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{loading ? (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-10 text-center">
|
||
<div className="flex items-center justify-center gap-2">
|
||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||
<span className="text-sm text-blue-900">Loading users...</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : current.map(u => {
|
||
const displayName = u.user_type === 'company'
|
||
? u.company_name || 'Unknown Company'
|
||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
||
|
||
const initials = u.user_type === 'company'
|
||
? (u.company_name?.[0] || 'C').toUpperCase()
|
||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
||
|
||
// Use backend status directly for display to avoid desync
|
||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
||
|
||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
|
||
|
||
return (
|
||
<tr key={u.id} className="hover:bg-blue-50">
|
||
<td className="px-4 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
||
{initials}
|
||
</div>
|
||
<div>
|
||
<div className="font-medium text-blue-900 leading-tight">
|
||
{displayName}
|
||
</div>
|
||
<div className="text-[11px] text-blue-700">
|
||
{u.email}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
||
<td className="px-4 py-4">{statusBadge(userStatus)}</td>
|
||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
||
<td className="px-4 py-4 text-blue-700 italic">
|
||
{lastLoginDate}
|
||
</td>
|
||
<td className="px-4 py-4">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => onEdit(u.id.toString())}
|
||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
||
>
|
||
<PencilSquareIcon className="h-4 w-4" /> Edit
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{current.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
||
No users match current filters.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/* Pagination */}
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
||
<div className="text-xs text-blue-700">
|
||
Page {page} of {totalPages} ({filtered.length} total users)
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
disabled={page===1}
|
||
onClick={() => setPage(p => Math.max(1,p-1))}
|
||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
‹ Previous
|
||
</button>
|
||
<button
|
||
disabled={page===totalPages}
|
||
onClick={() => setPage(p => Math.min(totalPages,p+1))}
|
||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
Next ›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
{/* User Detail Modal */}
|
||
<UserDetailModal
|
||
isOpen={isDetailModalOpen}
|
||
onClose={() => {
|
||
setIsDetailModalOpen(false)
|
||
setSelectedUserId(null)
|
||
}}
|
||
userId={selectedUserId}
|
||
onUserUpdated={fetchAllUsers}
|
||
/>
|
||
</PageLayout>
|
||
)
|
||
} |