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

396 lines
18 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 } 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>
)
}