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

390 lines
17 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 {
MagnifyingGlassIcon,
CheckIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'
import { useAdminUsers } from '../../hooks/useAdminUsers'
import { PendingUser } from '../../utils/api'
type UserType = 'personal' | 'company'
type UserRole = 'user' | 'admin'
export default function AdminUserVerifyPage() {
const {
pendingUsers,
loading,
error,
verifying,
verifyUser: handleVerifyUser,
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 [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}`
return (
(fType === 'all' || u.user_type === fType) &&
(fRole === 'all' || u.role === fRole) &&
(
!search.trim() ||
u.email.toLowerCase().includes(search.toLowerCase()) ||
fullName.toLowerCase().includes(search.toLowerCase())
)
)
})
}, [pendingUsers, search, fType, fRole])
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
const current = filtered.slice((page - 1) * perPage, page * perPage)
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="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<div className="h-4 w-4 rounded-full border-2 border-blue-500 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading...</p>
</div>
</div>
</div>
</PageLayout>
)
}
// Access check (only after client-side hydration)
if (!isAdmin) {
return (
<PageLayout>
<div className="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
<div className="relative mx-auto w-full max-w-4xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-red-500/20 p-6 sm:p-10">
<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="relative flex flex-col flex-1 px-4 sm:px-6 lg:px-10 py-10">
{/* Background */}
<div className="fixed inset-0 -z-10">
<div className="absolute inset-0 -z-20 bg-gradient-to-tr from-[#0d3894] via-[#1860d2] to-[#1d66d9]" />
<svg aria-hidden="true" className="absolute inset-0 h-full w-full stroke-white/10">
<defs>
<pattern id="admin-user-verify-pattern" x="50%" y={-1} width={200} height={200} patternUnits="userSpaceOnUse">
<path d="M.5 200V.5H200" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect fill="url(#admin-user-verify-pattern)" width="100%" height="100%" strokeWidth={0} />
</svg>
<div
aria-hidden="true"
className="absolute top-0 right-0 left-1/2 -ml-24 blur-3xl transform-gpu overflow-hidden lg:ml-24 xl:ml-48"
>
<div
className="aspect-[801/1036] w-[50.0625rem] bg-gradient-to-tr from-[#5b8dff] to-[#a78bfa] opacity-40"
style={{ clipPath: 'polygon(63.1% 29.5%,100% 17.1%,76.6% 3%,48.4% 0%,44.6% 4.7%,54.5% 25.3%,59.8% 49%,55.2% 57.8%,44.4% 57.2%,27.8% 47.9%,35.1% 81.5%,0% 97.7%,39.2% 100%,35.2% 81.4%,97.2% 52.8%,63.1% 29.5%)' }}
/>
</div>
</div>
{/* Outer container */}
<div className="relative mx-auto w-full max-w-7xl space-y-8 bg-white/95 backdrop-blur rounded-2xl shadow-2xl ring-1 ring-black/10 p-6 sm:p-10">
<div className="text-center">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-[#0e2f63]">
User Verification Center
</h1>
<p className="mt-2 text-sm sm:text-base text-[#33507d] font-medium">
Review and verify all users who need admin approval. Users must complete all steps before verification.
</p>
</div>
{/* Error Message */}
{error && (
<div className="rounded-lg border border-red-300 bg-red-50 text-red-700 px-5 py-4 flex gap-3 items-start">
<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-xl shadow-sm ring-1 ring-gray-200 px-4 sm:px-6 py-5 flex flex-col gap-4"
>
<h2 className="text-sm font-semibold text-[#0f2c55]">
Search & Filter Pending Users
</h2>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
<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-gray-400" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Email, name, company..."
className="w-full rounded-md border border-gray-300 pl-10 pr-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
</div>
<div>
<select
value={fType}
onChange={e => { setFType(e.target.value as any); setPage(1) }}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
</div>
<div>
<select
value={fRole}
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="all">All Roles</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<select
value={perPage}
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div className="flex items-stretch">
<button
type="submit"
className="w-full inline-flex items-center justify-center rounded-md bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold px-5 py-2.5 shadow transition"
>
Filter
</button>
</div>
</div>
</form>
{/* Pending Users Table */}
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<div className="text-sm font-semibold text-[#0f2c55]">
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-gray-50 text-gray-600 font-medium">
<tr>
<th className="px-4 py-2 text-left">User</th>
<th className="px-4 py-2 text-left">Type</th>
<th className="px-4 py-2 text-left">Progress</th>
<th className="px-4 py-2 text-left">Status</th>
<th className="px-4 py-2 text-left">Role</th>
<th className="px-4 py-2 text-left">Created</th>
<th className="px-4 py-2 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-500 border-b-transparent animate-spin" />
<span className="text-sm text-gray-500">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 isVerifying = verifying.has(u.id.toString())
const createdDate = new Date(u.created_at).toLocaleDateString()
// Check if user has completed all verification steps
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
u.documents_uploaded === 1 && u.contract_signed === 1
return (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<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-indigo-500 to-indigo-600 text-white text-xs font-semibold shadow">
{initials}
</div>
<div>
<div className="font-medium text-gray-900 leading-tight">
{displayName}
</div>
<div className="text-[11px] text-gray-500">{u.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">{typeBadge(u.user_type)}</td>
<td className="px-4 py-3">{verificationStatusBadge(u)}</td>
<td className="px-4 py-3">{statusBadge(u.status)}</td>
<td className="px-4 py-3">{roleBadge(u.role)}</td>
<td className="px-4 py-3 text-gray-700">{createdDate}</td>
<td className="px-4 py-3">
{isReadyToVerify ? (
<button
onClick={() => handleVerifyUser(u.id.toString())}
disabled={isVerifying}
className={`inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-medium transition
${isVerifying
? 'border-gray-200 bg-gray-100 text-gray-400 cursor-not-allowed'
: 'border-emerald-200 bg-emerald-50 hover:bg-emerald-100 text-emerald-700'
}`}
>
{isVerifying ? (
<>
<span className="h-3 w-3 rounded-full border-2 border-emerald-500 border-b-transparent animate-spin" />
Verifying...
</>
) : (
<>
<CheckIcon className="h-4 w-4" /> Verify
</>
)}
</button>
) : (
<span className="text-xs text-gray-500 italic">Incomplete steps</span>
)}
</td>
</tr>
)
})}
{current.length === 0 && !loading && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-sm text-gray-500">
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-5 py-4 bg-gray-50 border-t border-gray-100">
<div className="text-xs text-gray-600">
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-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
Previous
</button>
<button
disabled={page === totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 bg-white hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
)
}