341 lines
17 KiB
TypeScript
341 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React, { useMemo, useState } from 'react'
|
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
|
import { useTranslation } from '../../i18n/useTranslation'
|
|
|
|
type UserType = 'personal' | 'company'
|
|
type UserStatus = 'active' | 'inactive' | 'pending' | 'blocked'
|
|
|
|
export interface RegisteredUser {
|
|
id: string | number
|
|
name: string
|
|
email: string
|
|
userType: UserType
|
|
registeredAt: string | Date
|
|
refCode?: string
|
|
status: UserStatus
|
|
}
|
|
|
|
interface Props {
|
|
users?: RegisteredUser[]
|
|
loading?: boolean
|
|
}
|
|
|
|
function statusBadgeClass(s: UserStatus) {
|
|
switch (s) {
|
|
case 'active': return 'bg-green-100 text-green-800'
|
|
case 'inactive': return 'bg-gray-100 text-gray-800'
|
|
case 'pending': return 'bg-amber-100 text-amber-800'
|
|
case 'blocked': return 'bg-rose-100 text-rose-800'
|
|
default: return 'bg-slate-100 text-slate-800'
|
|
}
|
|
}
|
|
|
|
function typeBadgeClass(t: UserType) {
|
|
return t === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
|
}
|
|
|
|
// CSV export helper
|
|
function exportCsv(rows: RegisteredUser[]) {
|
|
const header = ['Name', 'Email', 'Type', 'Registered', 'Status']
|
|
const csv = [
|
|
header.join(','),
|
|
...rows.map(r => [
|
|
`"${r.name.replace(/"/g, '""')}"`,
|
|
`"${r.email}"`,
|
|
r.userType,
|
|
`"${new Date(r.registeredAt).toLocaleString()}"`,
|
|
r.status
|
|
].join(','))
|
|
].join('\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 = `registered-users-${Date.now()}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
export default function RegisteredUserList({ users, loading }: Props) {
|
|
const { t } = useTranslation()
|
|
// Normalize backend shape to local RegisteredUser shape
|
|
const normalizedUsers = useMemo<RegisteredUser[]>(() => {
|
|
if (!users || users.length === 0) return []
|
|
return users.map((u: any) => ({
|
|
id: u.id ?? u._id ?? u.userId ?? '',
|
|
name: u.name ?? u.fullName ?? u.companyName ?? u.email ?? '',
|
|
email: u.email ?? '',
|
|
userType: (u.userType ?? u.type ?? 'personal') as UserType,
|
|
registeredAt: u.registeredAt ?? u.createdAt ?? new Date().toISOString(),
|
|
status: (u.status ?? 'inactive') as UserStatus,
|
|
}))
|
|
}, [users])
|
|
|
|
// Latest 5 rows only from backend
|
|
const sorted = useMemo(() => {
|
|
const data = normalizedUsers.slice()
|
|
return data.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
|
}, [normalizedUsers])
|
|
const rows = sorted.slice(0, 5)
|
|
|
|
// Total badge: from backend list length
|
|
const totalRegistered = normalizedUsers.length
|
|
|
|
// Modal state
|
|
const [open, setOpen] = useState(false)
|
|
const [query, setQuery] = useState('')
|
|
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
|
const [statusFilter, setStatusFilter] = useState<'all' | UserStatus>('all')
|
|
const [page, setPage] = useState(1)
|
|
const pageSize = 10
|
|
|
|
// Full dataset for modal (backend only)
|
|
const allRows = useMemo(() => {
|
|
return normalizedUsers.slice().sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
|
}, [normalizedUsers])
|
|
|
|
const filtered = useMemo(() => {
|
|
return allRows.filter(r => {
|
|
if (typeFilter !== 'all' && r.userType !== typeFilter) return false
|
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
|
if (!query.trim()) return true
|
|
const q = query.toLowerCase()
|
|
return r.name.toLowerCase().includes(q) || r.email.toLowerCase().includes(q)
|
|
})
|
|
}, [allRows, query, typeFilter, statusFilter])
|
|
|
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
|
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize)
|
|
|
|
const resetAndOpen = () => {
|
|
setQuery('')
|
|
setTypeFilter('all')
|
|
setStatusFilter('all')
|
|
setPage(1)
|
|
setOpen(true)
|
|
}
|
|
|
|
// Lock scroll when modal open
|
|
React.useEffect(() => {
|
|
if (!open) return
|
|
const prev = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
return () => { document.body.style.overflow = prev }
|
|
}, [open])
|
|
|
|
return (
|
|
<>
|
|
<div className="mt-8 mb-8 rounded-[28px] border border-white/80 bg-white/85 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur overflow-hidden">
|
|
<div className="p-6 border-b border-slate-200/60 bg-white/40 flex items-start justify-between gap-4 flex-wrap">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-950 break-words">{t('referralManagement.registeredUsersTitle')}</h2>
|
|
<div className="mt-2 inline-flex items-center gap-2">
|
|
<span className="inline-flex items-center gap-2 rounded-full bg-violet-100 text-violet-800 px-3 py-1 text-[11px] font-semibold tracking-wide">
|
|
<UsersIcon className="h-4 w-4" />
|
|
{t('referralManagement.totalRefBadge')}
|
|
</span>
|
|
<span className="inline-flex items-center rounded-full bg-violet-600 text-white px-2 py-1 text-[11px] font-bold">
|
|
{totalRegistered}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-slate-600 mt-2 break-words">
|
|
{t('referralManagement.registeredUsersSubtitle')}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-2 break-words">
|
|
{t('referralManagement.showingLatest5')}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={resetAndOpen}
|
|
className="inline-flex items-center rounded-2xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500"
|
|
>
|
|
{t('referralManagement.viewAll')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-slate-200">
|
|
<thead className="bg-slate-50/80">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colUser')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colEmail')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colType')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colRegistered')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colStatus')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 bg-white/75">
|
|
{loading ? (
|
|
<>
|
|
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
|
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
|
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-1/2 bg-gray-200 animate-pulse rounded" /></td></tr>
|
|
</>
|
|
) : rows.length === 0 ? (
|
|
<tr>
|
|
<td className="px-6 py-6 text-sm text-slate-500 break-words" colSpan={5}>
|
|
{t('referralManagement.noRegisteredUsers')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
rows.map(u => {
|
|
const date = new Date(u.registeredAt).toLocaleString()
|
|
return (
|
|
<tr key={u.id}>
|
|
<td className="px-6 py-4 text-sm text-slate-900 break-words">{u.name}</td>
|
|
<td className="px-6 py-4 text-sm text-slate-700 break-words">{u.email}</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-slate-700 break-words">{date}</td>
|
|
<td className="px-6 py-4 text-sm">
|
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
|
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Modal with full list */}
|
|
{open && (
|
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setOpen(false)} aria-hidden />
|
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
|
<div className="w-full max-w-6xl rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)] overflow-hidden">
|
|
<div className="px-6 py-4 border-b border-slate-200/70 bg-white/45 flex items-center justify-between gap-3 flex-wrap">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-slate-900 break-words">{t('referralManagement.allRegisteredUsersTitle')}</h3>
|
|
<p className="text-xs text-slate-600 break-words">{t('referralManagement.allRegisteredUsersSubtitle')}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => exportCsv(filtered)}
|
|
className="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
|
>
|
|
{t('referralManagement.exportCsv')}
|
|
</button>
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
className="inline-flex items-center rounded-2xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-slate-800"
|
|
>
|
|
{t('common.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 border-b border-slate-100 grid grid-cols-1 md:grid-cols-4 gap-3">
|
|
<input
|
|
value={query}
|
|
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
|
placeholder={t('referralManagement.searchPlaceholder')}
|
|
className="md:col-span-2 w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 placeholder:text-slate-500"
|
|
/>
|
|
<select
|
|
value={typeFilter}
|
|
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
|
className="w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300"
|
|
>
|
|
<option value="all">{t('referralManagement.filterAllTypes')}</option>
|
|
<option value="personal">{t('referralManagement.typePersonal')}</option>
|
|
<option value="company">{t('referralManagement.typeCompany')}</option>
|
|
</select>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
|
className="w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300"
|
|
>
|
|
<option value="all">{t('referralManagement.filterAllStatus')}</option>
|
|
<option value="active">{t('referralManagement.filterActive')}</option>
|
|
<option value="inactive">{t('referralManagement.filterInactive')}</option>
|
|
<option value="pending">{t('referralManagement.filterPending')}</option>
|
|
<option value="blocked">{t('referralManagement.filterBlocked')}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-slate-200">
|
|
<thead className="bg-slate-50/80">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colUser')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colEmail')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colType')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colRegistered')}</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colStatus')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100 bg-white/75">
|
|
{pageRows.length === 0 ? (
|
|
<tr>
|
|
<td className="px-6 py-6 text-sm text-slate-500 break-words" colSpan={5}>
|
|
{t('referralManagement.noUsersMatchFilters')}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pageRows.map(u => {
|
|
const date = new Date(u.registeredAt).toLocaleString()
|
|
return (
|
|
<tr key={u.id}>
|
|
<td className="px-6 py-3 text-sm text-slate-900 break-words">{u.name}</td>
|
|
<td className="px-6 py-3 text-sm text-slate-700 break-words">{u.email}</td>
|
|
<td className="px-6 py-3 text-sm">
|
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-sm text-slate-700 break-words">{date}</td>
|
|
<td className="px-6 py-3 text-sm">
|
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
|
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="px-6 py-4 flex items-center justify-between gap-3 flex-wrap">
|
|
<span className="text-xs text-slate-600 break-words">
|
|
{t('referralManagement.showing')} {pageRows.length} {t('referralManagement.of')} {filtered.length} {t('referralManagement.colUser').toLowerCase()}s
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
disabled={page <= 1}
|
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
className="rounded-xl border border-slate-200 px-3 py-1.5 text-sm text-slate-700 disabled:opacity-50 hover:bg-slate-50"
|
|
>
|
|
{t('referralManagement.pagePrev')}
|
|
</button>
|
|
<span className="text-sm text-slate-700 break-words">
|
|
{t('referralManagement.pageOf').replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
|
</span>
|
|
<button
|
|
disabled={page >= totalPages}
|
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
|
className="rounded-xl border border-slate-200 px-3 py-1.5 text-sm text-slate-700 disabled:opacity-50 hover:bg-slate-50"
|
|
>
|
|
{t('referralManagement.pageNext')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|