396 lines
18 KiB
TypeScript
396 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import { useMemo, useState, useEffect } from 'react'
|
||
import PageLayout from '../../components/PageLayout'
|
||
import UserDetailModal from '../../components/UserDetailModal'
|
||
import {
|
||
MagnifyingGlassIcon,
|
||
ExclamationTriangleIcon,
|
||
EyeIcon
|
||
} from '@heroicons/react/24/outline'
|
||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||
import { PendingUser } from '../../utils/api'
|
||
|
||
type UserType = 'personal' | 'company'
|
||
type UserRole = 'user' | 'admin'
|
||
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||
type StatusFilter = 'all' | 'pending' | 'verifying' | 'active'
|
||
|
||
export default function AdminUserVerifyPage() {
|
||
const {
|
||
pendingUsers,
|
||
loading,
|
||
error,
|
||
isAdmin,
|
||
fetchPendingUsers
|
||
} = useAdminUsers()
|
||
const [isClient, setIsClient] = useState(false)
|
||
|
||
// Handle client-side mounting
|
||
useEffect(() => {
|
||
setIsClient(true)
|
||
}, [])
|
||
const [search, setSearch] = useState('')
|
||
const [fType, setFType] = useState<'all' | UserType>('all')
|
||
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
||
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
||
const [perPage, setPerPage] = useState(10)
|
||
const [page, setPage] = useState(1)
|
||
|
||
// All computations must be after hooks but before conditional returns
|
||
const filtered = useMemo(() => {
|
||
return pendingUsers.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}`
|
||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
||
u.documents_uploaded === 1 && u.contract_signed === 1
|
||
|
||
return (
|
||
(fType === 'all' || u.user_type === fType) &&
|
||
(fRole === 'all' || u.role === fRole) &&
|
||
(fStatus === 'all' || u.status === fStatus) &&
|
||
(
|
||
fReady === 'all' ||
|
||
(fReady === 'ready' && isReadyToVerify) ||
|
||
(fReady === 'not_ready' && !isReadyToVerify)
|
||
) &&
|
||
(
|
||
!search.trim() ||
|
||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||
fullName.toLowerCase().includes(search.toLowerCase())
|
||
)
|
||
)
|
||
})
|
||
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
||
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
||
|
||
// Modal state
|
||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||
|
||
const applyFilters = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setPage(1)
|
||
}
|
||
|
||
const badge = (text: string, color: string) =>
|
||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${color}`}>
|
||
{text}
|
||
</span>
|
||
|
||
const typeBadge = (t: UserType) =>
|
||
t === 'personal'
|
||
? badge('Personal', 'bg-blue-100 text-blue-700')
|
||
: badge('Company', 'bg-purple-100 text-purple-700')
|
||
|
||
const roleBadge = (r: UserRole) =>
|
||
r === 'admin'
|
||
? badge('Admin', 'bg-indigo-100 text-indigo-700')
|
||
: badge('User', 'bg-gray-100 text-gray-700')
|
||
|
||
const statusBadge = (s: PendingUser['status']) => {
|
||
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
||
if (s === 'verifying') return badge('Verifying', 'bg-blue-100 text-blue-700')
|
||
return badge('Active', 'bg-green-100 text-green-700')
|
||
}
|
||
|
||
const verificationStatusBadge = (user: PendingUser) => {
|
||
const steps = [
|
||
{ name: 'Email', completed: user.email_verified === 1 },
|
||
{ name: 'Profile', completed: user.profile_completed === 1 },
|
||
{ name: 'Documents', completed: user.documents_uploaded === 1 },
|
||
{ name: 'Contract', completed: user.contract_signed === 1 }
|
||
]
|
||
|
||
const completedSteps = steps.filter(s => s.completed).length
|
||
const totalSteps = steps.length
|
||
|
||
if (completedSteps === totalSteps) {
|
||
return badge('Ready to Verify', 'bg-green-100 text-green-700')
|
||
} else {
|
||
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
|
||
}
|
||
}
|
||
|
||
// 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>
|
||
)
|
||
}
|
||
|
||
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 Verification Center</h1>
|
||
<p className="text-lg text-blue-700 mt-2">
|
||
Review and verify all users who need admin approval. Users must complete all steps before verification.
|
||
</p>
|
||
</div>
|
||
</header>
|
||
|
||
{/* 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 data</p>
|
||
<p className="text-sm text-red-600">{error}</p>
|
||
<button
|
||
onClick={fetchPendingUsers}
|
||
className="mt-2 text-sm underline hover:no-underline"
|
||
>
|
||
Try again
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filter Card */}
|
||
<form
|
||
onSubmit={applyFilters}
|
||
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 Pending Users
|
||
</h2>
|
||
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
||
<div className="lg:col-span-2">
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">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>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">User Type</label>
|
||
<select
|
||
value={fType}
|
||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
||
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>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
|
||
<select
|
||
value={fRole}
|
||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
||
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>
|
||
<option value="user">User</option>
|
||
<option value="admin">Admin</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">Verification Readiness</label>
|
||
<select
|
||
value={fReady}
|
||
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
||
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 Readiness</option>
|
||
<option value="ready">Ready to Verify</option>
|
||
<option value="not_ready">Not Ready</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
|
||
<select
|
||
value={fStatus}
|
||
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
||
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 Statuses</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="verifying">Verifying</option>
|
||
<option value="active">Active</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs font-semibold text-blue-900 mb-1">Rows per page</label>
|
||
<select
|
||
value={perPage}
|
||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
||
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"
|
||
>
|
||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
|
||
{/* Pending 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">
|
||
Users Pending Verification
|
||
</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">Progress</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">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()
|
||
|
||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
||
|
||
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">{verificationStatusBadge(u)}</td>
|
||
<td className="px-4 py-4">{statusBadge(u.status)}</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">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => {
|
||
setSelectedUserId(u.id.toString())
|
||
setIsDetailModalOpen(true)
|
||
}}
|
||
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"
|
||
>
|
||
<EyeIcon className="h-4 w-4" /> View
|
||
</button>
|
||
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
{current.length === 0 && !loading && (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">
|
||
No unverified 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} pending 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={() => {
|
||
fetchPendingUsers()
|
||
}}
|
||
/>
|
||
</PageLayout>
|
||
)
|
||
} |