Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
304 lines
9.7 KiB
TypeScript
304 lines
9.7 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
|
||
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 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 {
|
||
BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS,
|
||
getBankAccountVerificationStatus,
|
||
type BankAccountVerificationStatus,
|
||
} from '@/constants/status'
|
||
import { formatPersonName, coerceToString } from '@/helpers'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import useAdminMutation from '@/hooks/useAdminMutation'
|
||
import { formatPersianDate } from '@/lib/formatters'
|
||
import { API_ROUTES } from '@/services/config'
|
||
|
||
interface BankAccountUserSummary {
|
||
mobile: string
|
||
firstName: string | null
|
||
lastName: string | null
|
||
}
|
||
|
||
interface BankAccountRow {
|
||
id: string
|
||
userId: string
|
||
ownerType: 'guest' | 'organizer'
|
||
accountHolder: string
|
||
iban: string
|
||
bankName: string | null
|
||
nationalCode: string | null
|
||
verificationStatus: BankAccountVerificationStatus
|
||
rejectionReason: string | null
|
||
jibitMatched: boolean | null
|
||
jibitInquiredAt: string | null
|
||
jibitErrorCode: string | null
|
||
createdAt: string
|
||
user?: BankAccountUserSummary
|
||
[key: string]: unknown
|
||
}
|
||
|
||
const columns: PaginationListColumnType[] = [
|
||
{
|
||
field: 'userId',
|
||
label: 'کاربر',
|
||
filterable: true,
|
||
sortable: false,
|
||
type: 'text',
|
||
},
|
||
{
|
||
field: 'ownerType',
|
||
label: 'نوع',
|
||
filterable: true,
|
||
sortable: false,
|
||
type: 'select',
|
||
filterItems: [
|
||
{ code: 'guest', name: 'مهمان' },
|
||
{ code: 'organizer', name: 'میزبان' },
|
||
],
|
||
},
|
||
{
|
||
field: 'iban',
|
||
label: 'شبا',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'nationalCode',
|
||
label: 'کد ملی',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'jibitMatched',
|
||
label: 'استعلام جیبت',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'verificationStatus',
|
||
label: 'وضعیت',
|
||
filterable: true,
|
||
sortable: true,
|
||
type: 'select',
|
||
filterItems: BANK_ACCOUNT_VERIFICATION_STATUS_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'createdAt',
|
||
label: 'تاریخ ثبت',
|
||
filterable: false,
|
||
sortable: true,
|
||
type: 'date',
|
||
},
|
||
{
|
||
field: 'actions',
|
||
label: 'عملیات',
|
||
},
|
||
]
|
||
|
||
const BankAccountsPage = () => {
|
||
const { showAlert } = useAlertModal()
|
||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.BANK_ACCOUNTS.ADMIN_LIST })
|
||
const [rejectTarget, setRejectTarget] = useState<BankAccountRow | null>(null)
|
||
const [rejectReason, setRejectReason] = useState('')
|
||
const isRejecting = rejectTarget?.id === pendingId
|
||
|
||
const handleInquire = (row: BankAccountRow) => {
|
||
showAlert('استعلام تطبیق شبا از جیبیت انجام شود؟', () =>
|
||
runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_JIBIT_INQUIRY(row.id)), 'نتیجه استعلام جیبیت ذخیره شد')
|
||
)
|
||
}
|
||
|
||
const handleApprove = (row: BankAccountRow) => {
|
||
if (row.jibitMatched !== true) {
|
||
addToast({ title: 'تأیید فقط پس از استعلام موفق جیبیت (matched) ممکن است', color: 'warning' })
|
||
|
||
return
|
||
}
|
||
|
||
showAlert(`حساب «${row.accountHolder}» تأیید شود؟`, () =>
|
||
runAction(row.id, () => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_APPROVE(row.id)), 'حساب بانکی تأیید شد')
|
||
)
|
||
}
|
||
|
||
const handleReject = async () => {
|
||
if (!rejectTarget) return
|
||
const reason = rejectReason.trim()
|
||
|
||
if (reason.length < 3) {
|
||
addToast({ title: 'دلیل رد باید حداقل ۳ کاراکتر باشد', color: 'warning' })
|
||
|
||
return
|
||
}
|
||
|
||
const succeeded = await runAction(
|
||
rejectTarget.id,
|
||
() => axiosInstance.patch(API_ROUTES.BANK_ACCOUNTS.ADMIN_REJECT(rejectTarget.id), { reason }),
|
||
'حساب بانکی رد شد'
|
||
)
|
||
|
||
if (succeeded) {
|
||
setRejectTarget(null)
|
||
setRejectReason('')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle="حسابهای بانکی" />
|
||
<div className="admin-page-container">
|
||
<PaginatedList
|
||
columns={columns}
|
||
url={API_ROUTES.BANK_ACCOUNTS.ADMIN_LIST}
|
||
>
|
||
{{
|
||
userId: (row) => {
|
||
const account = row as BankAccountRow
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<span>{formatPersonName(account.user?.firstName, account.user?.lastName)}</span>
|
||
{account.user?.mobile ? <span className="text-text-muted text-xs">{account.user.mobile}</span> : null}
|
||
</div>
|
||
)
|
||
},
|
||
ownerType: (_row, cellValue) => (coerceToString(cellValue) === 'organizer' ? 'میزبان' : 'مهمان'),
|
||
iban: (_row, cellValue) => (
|
||
<span
|
||
className="font-mono text-xs"
|
||
dir="ltr"
|
||
>
|
||
{coerceToString(cellValue)}
|
||
</span>
|
||
),
|
||
jibitMatched: (row) => {
|
||
const account = row as BankAccountRow
|
||
|
||
if (account.jibitErrorCode) return <span className="text-fourth-900 text-xs">{account.jibitErrorCode}</span>
|
||
if (account.jibitMatched === true) return <span className="text-fifth text-xs">مطابق</span>
|
||
if (account.jibitMatched === false) return <span className="text-fourth-900 text-xs">عدم تطابق</span>
|
||
|
||
return <span className="text-text-muted text-xs">استعلام نشده</span>
|
||
},
|
||
verificationStatus: (row, cellValue) => {
|
||
const account = row as BankAccountRow
|
||
const { label, chipColor } = getBankAccountVerificationStatus(coerceToString(cellValue))
|
||
|
||
return (
|
||
<StatusChip
|
||
chipColor={chipColor}
|
||
description={account.verificationStatus === 'rejected' ? (account.rejectionReason ?? undefined) : undefined}
|
||
label={label}
|
||
/>
|
||
)
|
||
},
|
||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||
actions: (row) => {
|
||
const account = row as BankAccountRow
|
||
const isBusy = pendingId === account.id
|
||
const isPending = account.verificationStatus === 'pending_review'
|
||
|
||
return (
|
||
<AdminTableActions>
|
||
<AdminTableViewButton
|
||
label="مشاهده کاربر"
|
||
mode="navigate"
|
||
to={APP_ROUTES.USER_DETAIL(account.userId)}
|
||
/>
|
||
{isPending ? (
|
||
<>
|
||
<Button
|
||
color="primary"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleInquire(account)
|
||
}}
|
||
>
|
||
استعلام
|
||
</Button>
|
||
<Button
|
||
iconOnly
|
||
aria-label="تأیید حساب بانکی"
|
||
color="success"
|
||
disabled={isBusy || account.jibitMatched !== true}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleApprove(account)
|
||
}}
|
||
>
|
||
<FileCheckIcon className="size-4" />
|
||
</Button>
|
||
<Button
|
||
iconOnly
|
||
aria-label="رد حساب بانکی"
|
||
color="danger"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
setRejectReason('')
|
||
setRejectTarget(account)
|
||
}}
|
||
>
|
||
<CloseCircleIcon className="size-4 text-fourth-900" />
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</AdminTableActions>
|
||
)
|
||
},
|
||
}}
|
||
</PaginatedList>
|
||
</div>
|
||
|
||
<Modal
|
||
acceptBtnText="رد حساب"
|
||
isLoading={isRejecting}
|
||
isOpen={Boolean(rejectTarget)}
|
||
rejectBtnText="انصراف"
|
||
title="رد حساب بانکی"
|
||
onAccept={() => {
|
||
void handleReject()
|
||
}}
|
||
onOpenChange={(open) => {
|
||
if (!open && !isRejecting) {
|
||
setRejectTarget(null)
|
||
setRejectReason('')
|
||
}
|
||
}}
|
||
>
|
||
<Input
|
||
generalType="textarea"
|
||
label="دلیل رد"
|
||
name="reason"
|
||
value={rejectReason}
|
||
onValueChange={(next) => {
|
||
setRejectReason(coerceToString(next))
|
||
}}
|
||
/>
|
||
</Modal>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default BankAccountsPage
|