feature: add admin user-management page
This commit is contained in:
parent
b2cfb1aa34
commit
ffb1fafc7e
340
src/app/admin/user-management/page.tsx
Normal file
340
src/app/admin/user-management/page.tsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import PageLayout from '../../components/PageLayout'
|
||||||
|
import {
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
EyeIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
XMarkIcon
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type UserType = 'personal' | 'company'
|
||||||
|
type UserStatus = 'active' | 'pending' | 'disabled'
|
||||||
|
type UserRole = 'user' | 'admin'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string
|
||||||
|
type: UserType
|
||||||
|
status: UserStatus
|
||||||
|
role: UserRole
|
||||||
|
created: string
|
||||||
|
lastLogin: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES: UserStatus[] = ['active','pending','disabled']
|
||||||
|
const TYPES: UserType[] = ['personal','company']
|
||||||
|
const ROLES: UserRole[] = ['user','admin']
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
const rand = (arr: any[]) => arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
const daysAgo = (d: number) => {
|
||||||
|
const dt = new Date()
|
||||||
|
dt.setDate(dt.getDate() - d)
|
||||||
|
return dt.toISOString().slice(0,10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUserManagementPage() {
|
||||||
|
// Mock users (stable memo)
|
||||||
|
const users = useMemo<User[]>(() => {
|
||||||
|
const firstNames = ['Anka','Peter','Primoz','Bojana','Hiska','Augst','Katarina','Tamara','Darija','Luka','Sara','Jonas','Maja','Niko','Eva']
|
||||||
|
const lastNames = ['Eravec','Oblak','Sranic','Pilih','Kaja','Feviz','Kravar','Skusek','Abersek','Novak','Schmidt','Mueller','Keller','Hansen','Mayr']
|
||||||
|
const list: User[] = []
|
||||||
|
for (let i=0;i<101;i++){
|
||||||
|
const fn = rand(firstNames)
|
||||||
|
const ln = rand(lastNames)
|
||||||
|
const createdDays = Math.floor(Math.random()*15)
|
||||||
|
const loginDays = Math.random() > 0.2 ? Math.floor(Math.random()*15) : null
|
||||||
|
list.push({
|
||||||
|
id: `U${i+1}`,
|
||||||
|
firstName: fn,
|
||||||
|
lastName: ln,
|
||||||
|
email: `${fn}.${ln}${i}@example.com`.toLowerCase(),
|
||||||
|
type: Math.random() > 0.92 ? 'company' : 'personal',
|
||||||
|
status: rand(STATUSES),
|
||||||
|
role: Math.random() > 0.96 ? 'admin' : 'user',
|
||||||
|
created: daysAgo(createdDays),
|
||||||
|
lastLogin: loginDays === null ? null : daysAgo(loginDays)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
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
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return users.filter(u =>
|
||||||
|
(fType==='all'||u.type===fType) &&
|
||||||
|
(fStatus==='all'||u.status===fStatus) &&
|
||||||
|
(fRole==='all'||u.role===fRole) &&
|
||||||
|
(
|
||||||
|
!search.trim() ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
`${u.firstName} ${u.lastName}`.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [users, 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)
|
||||||
|
|
||||||
|
const applyFilter = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
: badge('Disabled','rose')
|
||||||
|
|
||||||
|
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 stubs
|
||||||
|
const onView = (id: string) => console.log('View', id)
|
||||||
|
const onEdit = (id: string) => console.log('Edit', id)
|
||||||
|
const onDelete = (id: string) => console.log('Delete', id)
|
||||||
|
|
||||||
|
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-mgmt-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-mgmt-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 wrapper card */}
|
||||||
|
<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 Management
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm sm:text-base text-[#33507d] font-medium">
|
||||||
|
Manage all users, view statistics, and handle verification.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Card */}
|
||||||
|
<form
|
||||||
|
onSubmit={applyFilter}
|
||||||
|
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 Users
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
{/* 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-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>
|
||||||
|
{/* Type */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fType}
|
||||||
|
onChange={e => setFType(e.target.value as any)}
|
||||||
|
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>
|
||||||
|
{/* Status */}
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={e => setFStatus(e.target.value as any)}
|
||||||
|
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 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-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>
|
||||||
|
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 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>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 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]">
|
||||||
|
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-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">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">Last Login</th>
|
||||||
|
<th className="px-4 py-2 text-left">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{current.map(u => {
|
||||||
|
const initials = `${u.firstName[0]||''}${u.lastName[0]||''}`.toUpperCase()
|
||||||
|
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">
|
||||||
|
{u.firstName} {u.lastName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-gray-500">
|
||||||
|
{u.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{typeBadge(u.type)}</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">{u.created}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 italic">
|
||||||
|
{u.lastLogin ?? 'Never'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onView(u.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-700 px-2.5 py-1 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-4 w-4" /> View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(u.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50 hover:bg-amber-100 text-amber-700 px-2.5 py-1 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<PencilSquareIcon className="h-4 w-4" /> Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(u.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-md border border-red-200 bg-red-50 hover:bg-red-100 text-red-600 px-2.5 py-1 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{current.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-gray-500">
|
||||||
|
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-5 py-4 bg-gray-50 border-t border-gray-100">
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
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-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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user