profit-planet-frontend/src/app/admin/user-management/page.tsx

522 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { 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>
)
}