Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
351 lines
11 KiB
TypeScript
351 lines
11 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 Input from '@/components/formElements/Input'
|
||
import Button from '@/components/formElements/Button'
|
||
import CloseCircleIcon from '@/components/icons/CloseCircleIcon'
|
||
import FileCheckIcon from '@/components/icons/FileCheckIcon'
|
||
import ManualPayoutModal from '@/components/admin/ManualPayoutModal'
|
||
import PlayCircleIcon from '@/components/icons/PlayCircleIcon'
|
||
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 { getWithdrawalRequestStatus, WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS } from '@/constants/status'
|
||
import { formatCurrency, formatPersonName, coerceToString } from '@/helpers'
|
||
import useAlertModal from '@/hooks/useAlertModal'
|
||
import useAdminMutation from '@/hooks/useAdminMutation'
|
||
import { formatIranianMobile, formatPersianDate } from '@/lib/formatters'
|
||
import { API_ROUTES } from '@/services/config'
|
||
|
||
type WithdrawalRequestStatusValue = 'pending' | 'processing' | 'completed' | 'rejected'
|
||
|
||
interface WithdrawalRequestUserSummary {
|
||
mobile: string
|
||
firstName: string | null
|
||
lastName: string | null
|
||
}
|
||
|
||
interface WithdrawalRequestBankAccountSummary {
|
||
iban: string
|
||
bankName: string | null
|
||
accountHolder: string | null
|
||
}
|
||
|
||
interface WithdrawalRequestRow {
|
||
id: string
|
||
userId: string
|
||
walletId: string
|
||
bankAccountId: string
|
||
withdrawalCode: string
|
||
amount: number
|
||
status: WithdrawalRequestStatusValue
|
||
rejectionReason: string | null
|
||
processedAt: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
user?: WithdrawalRequestUserSummary
|
||
bankAccount?: WithdrawalRequestBankAccountSummary
|
||
[key: string]: unknown
|
||
}
|
||
|
||
const columns: PaginationListColumnType[] = [
|
||
{
|
||
field: 'withdrawalCode',
|
||
label: 'کد درخواست',
|
||
filterable: false,
|
||
sortable: false,
|
||
type: 'text',
|
||
},
|
||
{
|
||
field: 'userId',
|
||
label: 'کاربر',
|
||
filterable: true,
|
||
sortable: false,
|
||
type: 'text',
|
||
},
|
||
{
|
||
field: 'amount',
|
||
label: 'مبلغ',
|
||
filterable: false,
|
||
sortable: true,
|
||
},
|
||
{
|
||
field: 'status',
|
||
label: 'وضعیت',
|
||
filterable: true,
|
||
sortable: true,
|
||
type: 'select',
|
||
filterItems: WITHDRAWAL_REQUEST_STATUS_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'createdAt',
|
||
label: 'تاریخ ثبت',
|
||
filterable: false,
|
||
sortable: true,
|
||
},
|
||
{
|
||
field: 'processedAt',
|
||
label: 'تاریخ پردازش',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'bankDetails',
|
||
label: 'حساب بانکی',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'actions',
|
||
label: 'عملیات',
|
||
},
|
||
]
|
||
|
||
const WithdrawalRequestsPage = () => {
|
||
const { showAlert } = useAlertModal()
|
||
const { pendingId, runAction } = useAdminMutation({ url: API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_LIST })
|
||
const [rejectTarget, setRejectTarget] = useState<WithdrawalRequestRow | null>(null)
|
||
const [manualTarget, setManualTarget] = useState<WithdrawalRequestRow | null>(null)
|
||
const [rejectReason, setRejectReason] = useState('')
|
||
const isRejecting = rejectTarget?.id === pendingId
|
||
|
||
const handleProcess = (row: WithdrawalRequestRow) => {
|
||
showAlert('این درخواست برداشت به وضعیت «در حال پردازش» تغییر کند؟', () =>
|
||
runAction(
|
||
row.id,
|
||
() => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_PROCESS(row.id)),
|
||
'درخواست به «در حال پردازش» تغییر کرد'
|
||
)
|
||
)
|
||
}
|
||
|
||
const openRejectModal = (row: WithdrawalRequestRow) => {
|
||
setRejectReason('')
|
||
setRejectTarget(row)
|
||
}
|
||
|
||
const closeRejectModal = () => {
|
||
if (isRejecting) return
|
||
|
||
setRejectTarget(null)
|
||
setRejectReason('')
|
||
}
|
||
|
||
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.WITHDRAWAL_REQUESTS.ADMIN_REJECT(rejectTarget.id), { reason }),
|
||
'درخواست رد شد'
|
||
)
|
||
|
||
if (succeeded) {
|
||
setRejectTarget(null)
|
||
setRejectReason('')
|
||
}
|
||
}
|
||
|
||
const renderStatusCell = (row: WithdrawalRequestRow, cellValue: unknown) => {
|
||
const { label, chipColor } = getWithdrawalRequestStatus(coerceToString(cellValue))
|
||
|
||
return (
|
||
<StatusChip
|
||
chipColor={chipColor}
|
||
description={row.status === 'rejected' ? row.rejectionReason : undefined}
|
||
label={label}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar pageTitle="درخواستهای برداشت" />
|
||
<div className="admin-page-container">
|
||
<PaginatedList
|
||
columns={columns}
|
||
url={API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_LIST}
|
||
>
|
||
{{
|
||
userId: (row) => {
|
||
const withdrawal = row as WithdrawalRequestRow
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<span>{formatPersonName(withdrawal.user?.firstName, withdrawal.user?.lastName)}</span>
|
||
{withdrawal.user?.mobile ? (
|
||
<span
|
||
className="text-text-muted text-xs"
|
||
dir="ltr"
|
||
>
|
||
{formatIranianMobile(withdrawal.user.mobile)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
)
|
||
},
|
||
amount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
|
||
status: (row, cellValue) => renderStatusCell(row as WithdrawalRequestRow, cellValue),
|
||
createdAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||
processedAt: (_row, cellValue) => formatPersianDate(cellValue),
|
||
bankDetails: (row) => {
|
||
const withdrawal = row as WithdrawalRequestRow
|
||
const bankAccount = withdrawal.bankAccount
|
||
|
||
if (!bankAccount) {
|
||
return <span className="text-text-muted text-xs">—</span>
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-1">
|
||
<span
|
||
className="text-xs font-mono"
|
||
dir="ltr"
|
||
>
|
||
{bankAccount.iban || '—'}
|
||
</span>
|
||
<span className="text-text-muted text-xs">{bankAccount.bankName ?? '—'}</span>
|
||
<span className="text-text-muted text-xs">{bankAccount.accountHolder ?? '—'}</span>
|
||
</div>
|
||
)
|
||
},
|
||
actions: (row) => {
|
||
const withdrawal = row as WithdrawalRequestRow
|
||
const isBusy = pendingId === withdrawal.id
|
||
const canProcess = withdrawal.status === 'pending' || withdrawal.status === 'processing'
|
||
|
||
return (
|
||
<AdminTableActions>
|
||
<AdminTableViewButton
|
||
label="مشاهده کاربر"
|
||
mode="navigate"
|
||
to={APP_ROUTES.USER_DETAIL(withdrawal.userId)}
|
||
/>
|
||
{canProcess && withdrawal.status === 'pending' ? (
|
||
<Button
|
||
iconOnly
|
||
aria-label="پردازش درخواست برداشت"
|
||
color="primary"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
handleProcess(withdrawal)
|
||
}}
|
||
>
|
||
<PlayCircleIcon
|
||
className="size-4"
|
||
color="currentColor"
|
||
/>
|
||
</Button>
|
||
) : null}
|
||
{canProcess ? (
|
||
<>
|
||
<Button
|
||
iconOnly
|
||
aria-label="تکمیل درخواست برداشت"
|
||
color="success"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
setManualTarget(withdrawal)
|
||
}}
|
||
>
|
||
<FileCheckIcon className="size-4" />
|
||
</Button>
|
||
<Button
|
||
iconOnly
|
||
aria-label="رد درخواست برداشت"
|
||
color="danger"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
openRejectModal(withdrawal)
|
||
}}
|
||
>
|
||
<CloseCircleIcon className="size-4 text-fourth-900" />
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
</AdminTableActions>
|
||
)
|
||
},
|
||
}}
|
||
</PaginatedList>
|
||
</div>
|
||
|
||
<Modal
|
||
acceptDanger
|
||
acceptBtnDisabled={rejectReason.trim().length < 3}
|
||
acceptBtnText="رد درخواست"
|
||
isLoading={isRejecting}
|
||
isOpen={Boolean(rejectTarget)}
|
||
rejectBtnText="انصراف"
|
||
size="lg"
|
||
title="رد درخواست برداشت"
|
||
onAccept={handleReject}
|
||
onOpenChange={(open) => {
|
||
if (!open) closeRejectModal()
|
||
}}
|
||
onReject={closeRejectModal}
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
<p className="text-sm text-text-muted">دلیل رد این درخواست را بنویسید. این توضیح برای کاربر نمایش داده میشود.</p>
|
||
<Input
|
||
generalType="textarea"
|
||
label="دلیل رد"
|
||
name="rejectReason"
|
||
placeholder="حداقل ۳ کاراکتر"
|
||
textAreaMinRows={3}
|
||
value={rejectReason}
|
||
onValueChange={(next) => {
|
||
setRejectReason(coerceToString(next))
|
||
}}
|
||
/>
|
||
</div>
|
||
</Modal>
|
||
|
||
<ManualPayoutModal
|
||
isLoading={manualTarget?.id === pendingId}
|
||
isOpen={Boolean(manualTarget)}
|
||
title={manualTarget ? `ثبت پرداخت «${manualTarget.withdrawalCode}»` : 'ثبت پرداخت دستی'}
|
||
onClose={() => {
|
||
setManualTarget(null)
|
||
}}
|
||
onSubmit={async (payload) => {
|
||
if (!manualTarget) return false
|
||
|
||
return runAction(
|
||
manualTarget.id,
|
||
() => axiosInstance.patch(API_ROUTES.WITHDRAWAL_REQUESTS.ADMIN_COMPLETE(manualTarget.id), payload),
|
||
'برداشت با رسید دستی تکمیل شد'
|
||
)
|
||
}}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default WithdrawalRequestsPage
|