Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { FormProvider, useForm } from 'react-hook-form'
|
||
|
||
import type { PaginationListColumnType } from '@/types'
|
||
import { addToast } from '@/lib/toast'
|
||
import PaginatedList from '@/components/PaginatedList'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import Button from '@/components/formElements/Button'
|
||
import Input from '@/components/formElements/Input'
|
||
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
|
||
import EyeIcon from '@/components/icons/EyeIcon'
|
||
import FileCheckIcon from '@/components/icons/FileCheckIcon'
|
||
import Modal from '@/components/modals/Modal'
|
||
import AdminTableActions from '@/components/ui/AdminTableActions'
|
||
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
||
import StatusChip from '@/components/ui/StatusChip'
|
||
import axiosInstance from '@/config/axios'
|
||
import { APP_ROUTES } from '@/constants/routes'
|
||
import { getIdentityStatus, IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS } from '@/constants/status'
|
||
import { formatPersonName, coerceToString } from '@/helpers'
|
||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import useAdminMutation from '@/hooks/useAdminMutation'
|
||
import { formatPersianDate } from '@/lib/formatters'
|
||
import { API_ROUTES } from '@/services/config'
|
||
|
||
type IdentityVerificationStatus = 'pending' | 'verified' | 'rejected'
|
||
|
||
interface RejectionReason {
|
||
id: number
|
||
code: string
|
||
label: string
|
||
sortOrder: number
|
||
}
|
||
|
||
interface VerificationUserSummary {
|
||
mobile: string
|
||
firstName: string | null
|
||
lastName: string | null
|
||
}
|
||
|
||
interface VerificationRow {
|
||
id: string
|
||
userId: string
|
||
status: IdentityVerificationStatus
|
||
nationalCode: string | null
|
||
contractVersion?: string | null
|
||
contractAcceptedAt?: string | null
|
||
contractAcceptanceId?: string | null
|
||
jibitMatched?: boolean | null
|
||
jibitInquiredAt?: string | null
|
||
jibitErrorCode?: string | null
|
||
reason?: { code: string; label: string } | null
|
||
rejectionReason?: string | null
|
||
user?: VerificationUserSummary
|
||
createdAt: string
|
||
reviewedAt?: string | null
|
||
[key: string]: unknown
|
||
}
|
||
|
||
interface RejectFormValues {
|
||
reasonId: string
|
||
freeTextReason: string
|
||
}
|
||
|
||
const REJECT_FORM_DEFAULTS: RejectFormValues = {
|
||
reasonId: '',
|
||
freeTextReason: '',
|
||
}
|
||
|
||
const columns: PaginationListColumnType[] = [
|
||
{
|
||
field: 'userId',
|
||
label: 'متقاضی',
|
||
filterable: true,
|
||
type: 'text',
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'nationalCode',
|
||
label: 'کد ملی',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'jibitMatched',
|
||
label: 'استعلام جیبت',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'status',
|
||
label: 'وضعیت',
|
||
filterable: true,
|
||
type: 'select',
|
||
sortable: true,
|
||
filterItems: IDENTITY_VERIFICATION_QUEUE_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'createdAt',
|
||
label: 'تاریخ ثبت',
|
||
filterable: true,
|
||
sortable: true,
|
||
type: 'dateFromTo',
|
||
},
|
||
{
|
||
field: 'actions',
|
||
label: 'عملیات',
|
||
},
|
||
]
|
||
|
||
const formatJibitCell = (row: VerificationRow) => {
|
||
if (row.jibitErrorCode) return `خطا: ${row.jibitErrorCode}`
|
||
if (row.jibitMatched === true) return 'تطبیق دارد'
|
||
if (row.jibitMatched === false) return 'تطبیق ندارد'
|
||
|
||
return '—'
|
||
}
|
||
|
||
const getUserSummary = (row: VerificationRow): VerificationUserSummary & { id: string } => {
|
||
const user = row.user
|
||
|
||
return {
|
||
id: row.userId,
|
||
mobile: user?.mobile ?? '—',
|
||
firstName: user?.firstName ?? null,
|
||
lastName: user?.lastName ?? null,
|
||
}
|
||
}
|
||
|
||
const IdentityVerificationsPage = () => {
|
||
const { showAlert } = useAlertModal()
|
||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.IDENTITY.ADMIN_LIST })
|
||
const [detailTarget, setDetailTarget] = useState<VerificationRow | null>(null)
|
||
const [rejectTarget, setRejectTarget] = useState<VerificationRow | null>(null)
|
||
const [rejectionReasons, setRejectionReasons] = useState<RejectionReason[]>([])
|
||
const isRejecting = rejectTarget?.id === pendingId
|
||
const rejectForm = useForm<RejectFormValues>({
|
||
defaultValues: REJECT_FORM_DEFAULTS,
|
||
})
|
||
const selectedReasonId = rejectForm.watch('reasonId')
|
||
const freeTextReason = rejectForm.watch('freeTextReason')
|
||
|
||
const loadRejectionReasons = useCallback(async () => {
|
||
try {
|
||
const response = await axiosInstance.get(API_ROUTES.IDENTITY.REJECTION_REASONS)
|
||
const payload = unwrapApiPayload(response.data)
|
||
const reasons = Array.isArray(payload) ? payload : []
|
||
|
||
setRejectionReasons(reasons as RejectionReason[])
|
||
} catch {
|
||
addToast({ title: 'بارگذاری دلایل رد ناموفق بود', color: 'danger' })
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (rejectTarget) {
|
||
void loadRejectionReasons()
|
||
}
|
||
}, [rejectTarget, loadRejectionReasons])
|
||
|
||
const handleApprove = (row: VerificationRow) => {
|
||
showAlert('این درخواست احراز هویت تأیید شود؟', () =>
|
||
runAction(row.id, () => axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_APPROVE(row.id)), 'درخواست تأیید شد')
|
||
)
|
||
}
|
||
|
||
const handleJibitInquiry = (row: VerificationRow) => {
|
||
showAlert('استعلام تطابق کدملی با موبایل از جیبت انجام شود؟', () =>
|
||
runAction(
|
||
row.id,
|
||
async () => {
|
||
const response = await axiosInstance.post(API_ROUTES.IDENTITY.ADMIN_JIBIT_INQUIRY(row.id))
|
||
const payload = unwrapApiPayload<Partial<VerificationRow>>(response.data) ?? {}
|
||
|
||
setDetailTarget({
|
||
...row,
|
||
...payload,
|
||
user: payload.user ?? row.user,
|
||
})
|
||
|
||
return response
|
||
},
|
||
'استعلام جیبت انجام شد'
|
||
)
|
||
)
|
||
}
|
||
|
||
const openRejectModal = (row: VerificationRow) => {
|
||
rejectForm.reset(REJECT_FORM_DEFAULTS)
|
||
setRejectTarget(row)
|
||
}
|
||
|
||
const closeRejectModal = () => {
|
||
if (isRejecting) return
|
||
|
||
setRejectTarget(null)
|
||
rejectForm.reset(REJECT_FORM_DEFAULTS)
|
||
}
|
||
|
||
const handleReject = async () => {
|
||
if (!rejectTarget) return
|
||
|
||
const reasonId = selectedReasonId ? Number(selectedReasonId) : undefined
|
||
const rejectionReason = freeTextReason.trim() || undefined
|
||
|
||
if (!reasonId && !rejectionReason) {
|
||
addToast({ title: 'حداقل یکی از دلیل ساختاریافته یا توضیح آزاد را وارد کنید', color: 'warning' })
|
||
|
||
return
|
||
}
|
||
|
||
const succeeded = await runAction(
|
||
rejectTarget.id,
|
||
() =>
|
||
axiosInstance.patch(API_ROUTES.IDENTITY.ADMIN_REJECT(rejectTarget.id), {
|
||
...(reasonId ? { reasonId } : {}),
|
||
...(rejectionReason ? { rejectionReason } : {}),
|
||
}),
|
||
'درخواست رد شد'
|
||
)
|
||
|
||
if (succeeded) {
|
||
setRejectTarget(null)
|
||
rejectForm.reset(REJECT_FORM_DEFAULTS)
|
||
}
|
||
}
|
||
|
||
const renderStatusCell = (row: VerificationRow, cellValue: unknown) => {
|
||
const { label, chipColor } = getIdentityStatus(coerceToString(cellValue))
|
||
|
||
const description =
|
||
row.status === 'rejected' && (row.reason?.label || row.rejectionReason)
|
||
? `${row.reason?.label ?? ''}${row.reason?.label && row.rejectionReason ? ' — ' : ''}${row.rejectionReason ?? ''}`
|
||
: undefined
|
||
|
||
return (
|
||
<StatusChip
|
||
chipColor={chipColor}
|
||
description={description}
|
||
label={label}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle="احراز هویت میزبان" />
|
||
<div className="admin-page-container">
|
||
<PaginatedList
|
||
columns={columns}
|
||
url={API_ROUTES.IDENTITY.ADMIN_LIST}
|
||
>
|
||
{{
|
||
userId: (row) => {
|
||
const user = getUserSummary(row as VerificationRow)
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<span>{formatPersonName(user.firstName, user.lastName)}</span>
|
||
<span className="text-text-muted text-xs">{user.mobile}</span>
|
||
</div>
|
||
)
|
||
},
|
||
jibitMatched: (row) => formatJibitCell(row as VerificationRow),
|
||
status: (row, cellValue) => renderStatusCell(row as VerificationRow, cellValue),
|
||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||
actions: (row) => {
|
||
const verification = row as VerificationRow
|
||
const isBusy = pendingId === verification.id
|
||
const user = getUserSummary(verification)
|
||
|
||
return (
|
||
<AdminTableActions>
|
||
<AdminTableViewButton
|
||
label="مشاهده کاربر"
|
||
mode="navigate"
|
||
to={APP_ROUTES.USER_DETAIL(user.id)}
|
||
/>
|
||
<Button
|
||
iconOnly
|
||
aria-label="جزئیات درخواست"
|
||
color="default"
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
setDetailTarget(verification)
|
||
}}
|
||
>
|
||
<EyeIcon className="size-4" />
|
||
</Button>
|
||
{verification.status === 'pending' ? (
|
||
<>
|
||
<Button
|
||
aria-label="استعلام جیبت"
|
||
color="primary"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleJibitInquiry(verification)
|
||
}}
|
||
>
|
||
استعلام
|
||
</Button>
|
||
<Button
|
||
iconOnly
|
||
aria-label="تأیید احراز هویت"
|
||
color="success"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleApprove(verification)
|
||
}}
|
||
>
|
||
<FileCheckIcon className="size-4" />
|
||
</Button>
|
||
<Button
|
||
iconOnly
|
||
aria-label="رد احراز هویت"
|
||
color="danger"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
openRejectModal(verification)
|
||
}}
|
||
>
|
||
<CloseCircleIcon className="size-4 text-fourth-900" />
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</AdminTableActions>
|
||
)
|
||
},
|
||
}}
|
||
</PaginatedList>
|
||
</div>
|
||
|
||
<Modal
|
||
footerChildren={
|
||
detailTarget?.status === 'pending' ? (
|
||
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-3">
|
||
<Button
|
||
fullWidth
|
||
color="primary"
|
||
disabled={pendingId === detailTarget.id}
|
||
isLoading={pendingId === detailTarget.id}
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleJibitInquiry(detailTarget)
|
||
}}
|
||
>
|
||
استعلام جیبت
|
||
</Button>
|
||
<Button
|
||
fullWidth
|
||
color="danger"
|
||
disabled={pendingId === detailTarget.id}
|
||
variant="flat"
|
||
onClick={() => {
|
||
const target = detailTarget
|
||
|
||
setDetailTarget(null)
|
||
openRejectModal(target)
|
||
}}
|
||
>
|
||
رد درخواست
|
||
</Button>
|
||
<Button
|
||
fullWidth
|
||
color="success"
|
||
disabled={pendingId === detailTarget.id}
|
||
isLoading={pendingId === detailTarget.id}
|
||
onClick={() => {
|
||
const target = detailTarget
|
||
|
||
setDetailTarget(null)
|
||
handleApprove(target)
|
||
}}
|
||
>
|
||
تأیید درخواست
|
||
</Button>
|
||
</div>
|
||
) : undefined
|
||
}
|
||
hideFooter={detailTarget?.status !== 'pending'}
|
||
isDismissable={detailTarget ? pendingId !== detailTarget.id : true}
|
||
isOpen={Boolean(detailTarget)}
|
||
size="2xl"
|
||
title="جزئیات احراز هویت"
|
||
onOpenChange={(open) => {
|
||
if (!open && pendingId !== detailTarget?.id) setDetailTarget(null)
|
||
}}
|
||
>
|
||
{detailTarget ? (
|
||
<dl className="grid grid-cols-1 gap-3 rounded-2xl bg-surface-secondary p-4 sm:grid-cols-2">
|
||
<div>
|
||
<dt className="text-xs text-muted">متقاضی</dt>
|
||
<dd className="mt-1 text-sm font-semibold">{formatPersonName(detailTarget.user?.firstName, detailTarget.user?.lastName)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">شماره موبایل</dt>
|
||
<dd
|
||
className="mt-1 text-sm font-semibold"
|
||
dir="ltr"
|
||
>
|
||
{detailTarget.user?.mobile ?? '—'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">کد ملی</dt>
|
||
<dd
|
||
className="mt-1 text-sm font-semibold"
|
||
dir="ltr"
|
||
>
|
||
{detailTarget.nationalCode ?? '—'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">شناسه برگزارکننده</dt>
|
||
<dd
|
||
className="mt-1 break-all text-sm font-semibold"
|
||
dir="ltr"
|
||
>
|
||
{detailTarget.userId}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">نسخه قرارداد</dt>
|
||
<dd className="mt-1 text-sm font-semibold">{detailTarget.contractVersion ?? '—'}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">تاریخ پذیرش</dt>
|
||
<dd className="mt-1 text-sm font-semibold">
|
||
{detailTarget.contractAcceptedAt ? formatPersianDate(detailTarget.contractAcceptedAt) : '—'}
|
||
</dd>
|
||
</div>
|
||
<div className="sm:col-span-2">
|
||
<dt className="text-xs text-muted">شناسه پذیرش الکترونیکی</dt>
|
||
<dd
|
||
className="mt-1 break-all text-sm font-semibold"
|
||
dir="ltr"
|
||
>
|
||
{detailTarget.contractAcceptanceId ?? '—'}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">نتیجه استعلام جیبت</dt>
|
||
<dd className="mt-1 text-sm font-semibold">{formatJibitCell(detailTarget)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-xs text-muted">زمان استعلام</dt>
|
||
<dd className="mt-1 text-sm font-semibold">
|
||
{detailTarget.jibitInquiredAt ? formatPersianDate(detailTarget.jibitInquiredAt) : '—'}
|
||
</dd>
|
||
</div>
|
||
</dl>
|
||
) : null}
|
||
</Modal>
|
||
|
||
<Modal
|
||
acceptDanger
|
||
acceptBtnDisabled={(!selectedReasonId && !freeTextReason.trim()) || isRejecting}
|
||
acceptBtnText="رد درخواست"
|
||
hideCloseButton={isRejecting}
|
||
isDismissable={!isRejecting}
|
||
isLoading={isRejecting}
|
||
isOpen={Boolean(rejectTarget)}
|
||
rejectBtnText="انصراف"
|
||
size="sm"
|
||
title="رد درخواست احراز هویت"
|
||
onAccept={() => {
|
||
void handleReject()
|
||
}}
|
||
onOpenChange={(open) => {
|
||
if (!open) closeRejectModal()
|
||
}}
|
||
onReject={closeRejectModal}
|
||
>
|
||
<FormProvider {...rejectForm}>
|
||
<div className="flex flex-col gap-4">
|
||
<p className="text-sm text-text-muted">
|
||
حداقل یکی از «دلیل ساختاریافته» یا «توضیح آزاد» را وارد کنید. این اطلاعات برای کاربر نمایش داده میشود.
|
||
</p>
|
||
<Input
|
||
generalType="select"
|
||
label="دلیل ساختاریافته"
|
||
name="reasonId"
|
||
placeholder="انتخاب کنید"
|
||
selectKey="id"
|
||
selectOptions={rejectionReasons}
|
||
selectValue="label"
|
||
/>
|
||
<Input
|
||
generalType="textarea"
|
||
label="توضیح آزاد"
|
||
name="freeTextReason"
|
||
placeholder="در صورت نیاز توضیح تکمیلی برای کاربر بنویسید"
|
||
textAreaMinRows={3}
|
||
/>
|
||
</div>
|
||
</FormProvider>
|
||
</Modal>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default IdentityVerificationsPage
|