Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import { useQueryClient } from '@tanstack/react-query'
|
||
|
||
import { addToast } from '@/lib/toast'
|
||
import type { PaginationListColumnType } from '@/types'
|
||
import PaginatedList from '@/components/PaginatedList'
|
||
import PageNavbar from '@/components/layouts/PageNavbar'
|
||
import Button from '@/components/formElements/Button'
|
||
import FileCheckIcon from '@/components/icons/FileCheckIcon'
|
||
import Modal from '@/components/modals/Modal'
|
||
import { ListSkeleton } from '@/components/feedback/LoadingState'
|
||
import AdminTableActions from '@/components/ui/AdminTableActions'
|
||
import AdminTableViewButton from '@/components/ui/AdminTableViewButton'
|
||
import StatusChip from '@/components/ui/StatusChip'
|
||
import axiosInstance from '@/config/axios'
|
||
import { getSettlementStatus, type SettlementStatus, SETTLEMENT_STATUS_FILTER_ITEMS } from '@/constants/status'
|
||
import { formatCurrency, coerceToString } from '@/helpers'
|
||
import { unwrapApiPayload } from '@/helpers/listResponse'
|
||
import useAdminMutation from '@/hooks/useAdminMutation'
|
||
import { formatPersianDate } from '@/lib/formatters'
|
||
import { adminKeys } from '@/queries/admin/adminKeys'
|
||
import { API_ROUTES } from '@/services/config'
|
||
|
||
const CreateSettlementModal = dynamic(() => import('@/app/(dashboard)/settlements/_components/CreateSettlementModal'), { ssr: false })
|
||
const ManualPayoutModal = dynamic(() => import('@/components/admin/ManualPayoutModal'), { ssr: false })
|
||
|
||
interface SettlementItem {
|
||
id: string
|
||
amount: number | string
|
||
organizerEarningId: string
|
||
eventId: string
|
||
eventTitle: string
|
||
}
|
||
|
||
interface SettlementRow {
|
||
id: string
|
||
settlementCode: string
|
||
organizerId: string
|
||
bankAccountId: string
|
||
periodStart: string
|
||
periodEnd: string
|
||
totalAmount: number | string
|
||
status: SettlementStatus
|
||
processedAt?: string | null
|
||
failureReason?: string | null
|
||
manualTrackingCode?: string | null
|
||
manualReceiptUrl?: string | null
|
||
manualNote?: string | null
|
||
createdAt: string
|
||
updatedAt: string
|
||
items?: SettlementItem[]
|
||
[key: string]: unknown
|
||
}
|
||
|
||
const COMPLETABLE_STATUSES: SettlementStatus[] = ['pending', 'processing']
|
||
|
||
const columns: PaginationListColumnType[] = [
|
||
{
|
||
field: 'settlementCode',
|
||
label: 'کد تسویه',
|
||
filterable: false,
|
||
sortable: false,
|
||
type: 'text',
|
||
},
|
||
{
|
||
field: 'organizerId',
|
||
label: 'میزبان',
|
||
filterable: true,
|
||
sortable: false,
|
||
type: 'text',
|
||
hideInTable: true,
|
||
},
|
||
{
|
||
field: 'period',
|
||
label: 'بازه زمانی',
|
||
filterable: false,
|
||
sortable: false,
|
||
},
|
||
{
|
||
field: 'totalAmount',
|
||
label: 'مبلغ کل',
|
||
filterable: false,
|
||
sortable: true,
|
||
},
|
||
{
|
||
field: 'status',
|
||
label: 'وضعیت',
|
||
filterable: true,
|
||
sortable: true,
|
||
type: 'select',
|
||
filterItems: SETTLEMENT_STATUS_FILTER_ITEMS,
|
||
},
|
||
{
|
||
field: 'createdAt',
|
||
label: 'تاریخ ثبت',
|
||
filterable: false,
|
||
sortable: true,
|
||
type: 'date',
|
||
},
|
||
{
|
||
field: 'actions',
|
||
label: 'عملیات',
|
||
},
|
||
]
|
||
|
||
const formatDate = (value: unknown, withTime = false) =>
|
||
formatPersianDate(value, withTime ? { dateStyle: 'medium', timeStyle: 'short' } : { dateStyle: 'medium' })
|
||
|
||
const SettlementsPage = () => {
|
||
const queryClient = useQueryClient()
|
||
const { pendingId, runAction } = useAdminMutation({
|
||
errorMessage: 'تکمیل تسویه با خطا مواجه شد',
|
||
url: API_ROUTES.SETTLEMENTS.ADMIN_LIST,
|
||
})
|
||
const [detailTarget, setDetailTarget] = useState<SettlementRow | null>(null)
|
||
const [detailItems, setDetailItems] = useState<SettlementItem[]>([])
|
||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||
const [manualTarget, setManualTarget] = useState<SettlementRow | null>(null)
|
||
|
||
const openDetail = async (row: SettlementRow) => {
|
||
setDetailTarget(row)
|
||
setDetailItems([])
|
||
setIsLoadingDetail(true)
|
||
try {
|
||
const response = await axiosInstance.get(API_ROUTES.SETTLEMENTS.ADMIN_DETAIL(row.id))
|
||
const payload = unwrapApiPayload<{ items?: SettlementItem[] }>(response.data)
|
||
|
||
setDetailItems(Array.isArray(payload.items) ? payload.items : [])
|
||
} catch {
|
||
addToast({ title: 'بارگذاری جزئیات تسویه ناموفق بود', color: 'danger' })
|
||
} finally {
|
||
setIsLoadingDetail(false)
|
||
}
|
||
}
|
||
|
||
const closeDetail = () => {
|
||
setDetailTarget(null)
|
||
setDetailItems([])
|
||
}
|
||
|
||
return (
|
||
<section className="h-full w-full text-right">
|
||
<PageNavbar
|
||
endSlot={
|
||
<Button
|
||
size="sm"
|
||
onClick={() => {
|
||
setIsCreateOpen(true)
|
||
}}
|
||
>
|
||
ایجاد تسویه
|
||
</Button>
|
||
}
|
||
pageTitle="تسویهها"
|
||
/>
|
||
<div className="admin-page-container">
|
||
<PaginatedList
|
||
columns={columns}
|
||
url={API_ROUTES.SETTLEMENTS.ADMIN_LIST}
|
||
>
|
||
{{
|
||
period: (row) => {
|
||
const settlement = row as SettlementRow
|
||
|
||
return (
|
||
<span className="text-sm">
|
||
{formatDate(settlement.periodStart)} — {formatDate(settlement.periodEnd)}
|
||
</span>
|
||
)
|
||
},
|
||
totalAmount: (_row, cellValue) => formatCurrency(Number(cellValue ?? 0)),
|
||
createdAt: (_row, cellValue) => formatDate(cellValue, true),
|
||
status: (row, cellValue) => {
|
||
const settlement = row as SettlementRow
|
||
const { label, chipColor } = getSettlementStatus(coerceToString(cellValue))
|
||
|
||
return (
|
||
<StatusChip
|
||
chipColor={chipColor}
|
||
description={settlement.status === 'failed' ? settlement.failureReason : undefined}
|
||
label={label}
|
||
/>
|
||
)
|
||
},
|
||
actions: (row) => {
|
||
const settlement = row as SettlementRow
|
||
const isBusy = pendingId === settlement.id
|
||
const canComplete = COMPLETABLE_STATUSES.includes(settlement.status)
|
||
|
||
return (
|
||
<AdminTableActions>
|
||
<AdminTableViewButton
|
||
label="مشاهده جزئیات تسویه"
|
||
mode="open-detail"
|
||
onClick={() => openDetail(settlement)}
|
||
/>
|
||
{canComplete ? (
|
||
<Button
|
||
iconOnly
|
||
aria-label="تکمیل تسویه"
|
||
color="success"
|
||
disabled={isBusy}
|
||
isLoading={isBusy}
|
||
size="sm"
|
||
variant="flat"
|
||
onClick={() => {
|
||
setManualTarget(settlement)
|
||
}}
|
||
>
|
||
<FileCheckIcon className="size-4" />
|
||
</Button>
|
||
) : null}
|
||
</AdminTableActions>
|
||
)
|
||
},
|
||
}}
|
||
</PaginatedList>
|
||
</div>
|
||
|
||
<Modal
|
||
hideFooter
|
||
isLoading={isLoadingDetail}
|
||
isOpen={Boolean(detailTarget)}
|
||
rejectBtnText="بستن"
|
||
size="2xl"
|
||
title={detailTarget ? `اقلام تسویه «${detailTarget.settlementCode}»` : ''}
|
||
onOpenChange={(open) => {
|
||
if (!open) closeDetail()
|
||
}}
|
||
onReject={closeDetail}
|
||
>
|
||
{isLoadingDetail ? (
|
||
<ListSkeleton count={3} />
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{detailTarget?.status === 'failed' && detailTarget?.failureReason ? (
|
||
<div className="rounded-md bg-fourth-100 px-3 py-2 text-sm text-fourth-900">
|
||
<span className="font-semibold">دلیل شکست: </span>
|
||
{detailTarget.failureReason}
|
||
</div>
|
||
) : null}
|
||
|
||
{detailTarget?.status === 'completed' ? (
|
||
<div className="flex flex-col gap-2 rounded-md bg-fifth-50 px-3 py-3 text-sm">
|
||
<p>
|
||
<span className="font-semibold">شماره پیگیری: </span>
|
||
<span dir="ltr">{detailTarget.manualTrackingCode ?? '—'}</span>
|
||
</p>
|
||
{detailTarget.manualNote ? (
|
||
<p>
|
||
<span className="font-semibold">یادداشت: </span>
|
||
{detailTarget.manualNote}
|
||
</p>
|
||
) : null}
|
||
{detailTarget.manualReceiptUrl ? (
|
||
<a
|
||
className="w-fit text-primary underline"
|
||
href={detailTarget.manualReceiptUrl}
|
||
rel="noreferrer"
|
||
target="_blank"
|
||
>
|
||
مشاهده رسید پرداخت
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{detailItems.length === 0 ? (
|
||
<div className="py-6 text-center text-sm text-text-muted">هیچ قلمی برای این تسویه ثبت نشده است.</div>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="grid grid-cols-[1fr_auto] gap-2 border-b border-divider pb-2 text-xs font-semibold text-text-muted">
|
||
<span>رویداد</span>
|
||
<span>مبلغ</span>
|
||
</div>
|
||
{detailItems.map((item) => (
|
||
<div
|
||
key={item.id}
|
||
className="grid grid-cols-[1fr_auto] items-center gap-2 border-b border-divider py-2 text-sm last:border-b-0"
|
||
>
|
||
<span>{item.eventTitle}</span>
|
||
<span>{formatCurrency(Number(item.amount ?? 0))}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{isCreateOpen ? (
|
||
<CreateSettlementModal
|
||
isOpen={isCreateOpen}
|
||
onClose={() => {
|
||
setIsCreateOpen(false)
|
||
}}
|
||
onCreated={() => {
|
||
setIsCreateOpen(false)
|
||
void queryClient.invalidateQueries({ queryKey: adminKeys.listByUrl(API_ROUTES.SETTLEMENTS.ADMIN_LIST) })
|
||
}}
|
||
/>
|
||
) : null}
|
||
|
||
{manualTarget ? (
|
||
<ManualPayoutModal
|
||
isOpen
|
||
isLoading={manualTarget.id === pendingId}
|
||
title={`ثبت پرداخت «${manualTarget.settlementCode}»`}
|
||
onClose={() => {
|
||
setManualTarget(null)
|
||
}}
|
||
onSubmit={async (payload) => {
|
||
return runAction(
|
||
manualTarget.id,
|
||
() => axiosInstance.patch(API_ROUTES.SETTLEMENTS.ADMIN_COMPLETE(manualTarget.id), payload),
|
||
'تسویه با رسید دستی تکمیل شد'
|
||
)
|
||
}}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
export default SettlementsPage
|