admin/app/(dashboard)/identity-verifications/page.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

516 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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