@@ -1,4 +1,5 @@
import { useEffect , useState } from 'react' ;
import { useSearchParams } from 'react-router-dom' ;
import {
Button ,
Card ,
@@ -6,6 +7,7 @@ import {
Descriptions ,
Drawer ,
Form ,
Input ,
Modal ,
Select ,
Space ,
@@ -17,44 +19,61 @@ import {
} from 'antd' ;
import type { ColumnsType } from 'antd/es/table' ;
import type { Dayjs } from 'dayjs' ;
import {
STORE_SETTLEMENT_KIND_LABELS ,
STORE_SETTLEMENT_STATUS_LABELS ,
storeSettlementStatusLabel ,
type StoreSettlementKind ,
type StoreSettlementStatus ,
} from '@dukang/shared-types' ;
import { request , type Paginated } from '../lib/api' ;
import { ADMIN_OPTIONS_PAGE_SIZE , fmtTime } from '../lib/constants' ;
import { downloadExcelCsv } from '../lib/exportExcel' ;
import { useAdminList } from '../lib/useAdminList' ;
type Row = {
kind : StoreSettlementKind ;
id : string ;
billNo : string ;
billDate : string ;
redeemCount : number ;
redeemAmount : number ;
settlementRate : number ;
payoutAmount : number ;
status : string ;
paidAt ? : string | null ;
store ? : { id : string ; name : string ; cityName : string ; phone? : string } ;
amount : number ;
status : StoreSettlementStatus ;
date : string ;
overdue? : boolean ;
redeemCount? : number | null ;
redeemAmount? : number | null ;
settlementRate ? : number | null ;
payoutCount? : number | null ;
store ? : { id : string ; name : string ; cityName : string ; phone? : string | null } ;
} ;
type StoreOption = { id : string ; name : string ; phone : string } ;
const STATUS_LABELS : Record < string , string > = {
UNPAID : '未打款' ,
PAID : '已打款' ,
} ;
const STATUS_COLORS : Record < string , string > = {
UNPAID : 'red' ,
PAID : 'green' ,
PENDING_REVIEW : 'orange' ,
REJECTED : 'red' ,
} ;
const KIND_COLORS : Record < StoreSettlementKind , string > = {
T1_BILL : 'blue' ,
WITHDRAW : 'purple' ,
} ;
export default function StoreBillsPage() {
const [ searchParams ] = useSearchParams ( ) ;
const initialKind = searchParams . get ( 'kind' ) === 'WITHDRAW' ? 'WITHDRAW' : '' ;
const [ form ] = Form . useForm ( ) ;
const [ filters , setFilters ] = useState < Record < string , string > > ( { } ) ;
const [ filters , setFilters ] = useState < Record < string , string > > ( {
kind : initialKind ,
status : initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '' ,
} ) ;
const [ stores , setStores ] = useState < StoreOption [ ] > ( [ ] ) ;
const { data , loading , page , pageSize , setPage , setPageSize , reload } = useAdminList < Row > (
'/admin/store-bill s' ,
'/admin/store-settlement s' ,
( ) = > {
const qs = new URLSearchParams ( ) ;
if ( filters . kind ) qs . set ( 'kind' , filters . kind ) ;
if ( filters . status ) qs . set ( 'status' , filters . status ) ;
if ( filters . storeId ) qs . set ( 'storeId' , filters . storeId ) ;
if ( filters . dateFrom ) qs . set ( 'dateFrom' , filters . dateFrom ) ;
@@ -64,21 +83,38 @@ export default function StoreBillsPage() {
[ filters ] ,
) ;
const [ detail , setDetail ] = useState < Record < string , unknown > | null > ( null ) ;
const [ detailKind , setDetailKind ] = useState < StoreSettlementKind | null > ( null ) ;
const [ drawerOpen , setDrawerOpen ] = useState ( false ) ;
const [ exporting , setExporting ] = useState ( false ) ;
const [ selectedKeys , setSelectedKeys ] = useState < React.Key [ ] > ( [ ] ) ;
const [ batchLoading , setBatchLoading ] = useState ( false ) ;
const [ overdueSummary , setOverdueSummary ] = useState < {
pendingCount : number ;
overdueCount : number ;
} | null > ( null ) ;
useEffect ( ( ) = > {
form . setFieldsValue ( {
kind : filters.kind || undefined ,
status : filters.status || undefined ,
} ) ;
} , [ filters . kind , filters . status , form ] ) ;
useEffect ( ( ) = > {
void request < Paginated < StoreOption > > ( ` /admin/stores?pageSize= ${ ADMIN_OPTIONS_PAGE_SIZE } ` )
. then ( ( res ) = > setStores ( res . items ) )
. catch ( ( ) = > { } ) ;
void request < { pendingCount : number ; overdueCount : number } > (
'/admin/store-withdrawals/overdue-summary' ,
)
. then ( setOverdueSummary )
. catch ( ( ) = > { } ) ;
} , [ ] ) ;
function confirmPay ( ids : string [ ] , amountHint? : number ) {
Modal . confirm ( {
title : '确认打款?' ,
content : ` 将确认 ${ ids . length } 笔门店对账单 ${ amountHint != null ? ` ,合计约 ¥ ${ amountHint . toFixed ( 2 ) } ` : '' } 。此操作不可撤销。 ` ,
content : ` 将确认 ${ ids . length } 笔 T+1 门店对账单 ${ amountHint != null ? ` ,合计约 ¥ ${ amountHint . toFixed ( 2 ) } ` : '' } 。此操作不可撤销。 ` ,
okText : '确认打款' ,
cancelText : '取消' ,
onOk : async ( ) = > {
@@ -102,9 +138,75 @@ export default function StoreBillsPage() {
} ) ;
}
async function openDetail ( id : string ) {
const d = await request < Record < string , unknown > > ( ` /admin/store-bills/ ${ id } ` ) ;
function approveWithdraw ( id : string ) {
let paymentRef = '' ;
Modal . confirm ( {
title : '审核通过并标记已结算?' ,
content : (
< Input
placeholder = "打款凭证号(可选)"
onChange = { ( e ) = > {
paymentRef = e . target . value ;
} }
/ >
) ,
okText : '通过并结算' ,
cancelText : '取消' ,
onOk : async ( ) = > {
await request ( ` /admin/store-withdrawals/ ${ id } /approve ` , {
method : 'POST' ,
body : JSON.stringify ( { paymentRef : paymentRef.trim ( ) || undefined } ) ,
} ) ;
message . success ( '已通过并标记已结算' ) ;
setDrawerOpen ( false ) ;
reload ( ) ;
void request < { pendingCount : number ; overdueCount : number } > (
'/admin/store-withdrawals/overdue-summary' ,
)
. then ( setOverdueSummary )
. catch ( ( ) = > { } ) ;
} ,
} ) ;
}
function rejectWithdraw ( id : string ) {
let reason = '' ;
Modal . confirm ( {
title : '驳回提现申请?' ,
content : (
< Input.TextArea
rows = { 3 }
placeholder = "请填写驳回理由"
onChange = { ( e ) = > {
reason = e . target . value ;
} }
/ >
) ,
okText : '确认驳回' ,
okButtonProps : { danger : true } ,
cancelText : '取消' ,
onOk : async ( ) = > {
if ( ! reason . trim ( ) ) {
message . error ( '请填写驳回理由' ) ;
throw new Error ( 'reason required' ) ;
}
await request ( ` /admin/store-withdrawals/ ${ id } /reject ` , {
method : 'POST' ,
body : JSON.stringify ( { reason : reason.trim ( ) } ) ,
} ) ;
message . success ( '已驳回' ) ;
setDrawerOpen ( false ) ;
reload ( ) ;
} ,
} ) ;
}
async function openDetail ( row : Row ) {
const path =
row . kind === 'WITHDRAW' ? ` /admin/store-withdrawals/ ${ row . id } ` : ` /admin/store-bills/ ${ row . id } ` ;
const d = await request < Record < string , unknown > > ( path ) ;
setDetail ( d ) ;
setDetailKind ( row . kind ) ;
setDrawerOpen ( true ) ;
}
@@ -118,88 +220,134 @@ export default function StoreBillsPage() {
if ( filters . dateTo ) qs . set ( 'dateTo' , filters . dateTo ) ;
const result = await request < { csv : string ; count : number } > ( ` /admin/store-bills/export? ${ qs } ` ) ;
downloadExcelCsv ( result . csv , ` 门店对账单_ ${ filters . dateFrom || 'all' } _ ${ filters . dateTo || 'all' } .csv ` ) ;
message . success ( ` 已导出 ${ result . count } 条 ` ) ;
message . success ( ` 已导出 ${ result . count } 条 T+1 账单 ` ) ;
} finally {
setExporting ( false ) ;
}
}
const summary = data ? . summary ;
const selectedRows = ( data ? . items ? ? [ ] ) . filter ( ( r ) = > selectedKeys . includes ( r . id ) ) ;
const selectedAmount = selectedRows . reduce ( ( s , r ) = > s + Number ( r . payoutAmount ) , 0 ) ;
const selectedRows = ( data ? . items ? ? [ ] ) . filter (
( r ) = >
selectedKeys . includes ( ` ${ r . kind } - ${ r . id } ` ) &&
r . kind === 'T1_BILL' &&
r . status === 'UNPAID' ,
) ;
const selectedAmount = selectedRows . reduce ( ( s , r ) = > s + Number ( r . amount ) , 0 ) ;
const columns : ColumnsType < Row > = [
{ title : '账单号' , dataIndex : 'billNo' , width : 170 , ellipsis : true } ,
{
title : '账单日 ' ,
dataIndex : 'billDate ' ,
width : 110 ,
render : ( v ) = > String ( v || '' ) . slice ( 0 , 10 ) ,
title : '类型 ' ,
dataIndex : 'kind ' ,
width : 100 ,
render : ( k : StoreSettlementKind ) = > (
< Tag color = { KIND_COLORS [ k ] } > { STORE_SETTLEMENT_KIND_LABELS [ k ] ? ? k } < / Tag >
) ,
} ,
{
title : '单号' ,
dataIndex : 'billNo' ,
width : 180 ,
ellipsis : true ,
render : ( v , row ) = > (
< Space >
< span > { v } < / span >
{ row . overdue ? < Tag color = "magenta" > 超 时 < / Tag > : null }
< / Space >
) ,
} ,
{
title : '日期' ,
dataIndex : 'date' ,
width : 160 ,
render : ( v , row ) = > ( row . kind === 'T1_BILL' ? String ( v || '' ) . slice ( 0 , 10 ) : fmtTime ( v ) ) ,
} ,
{ title : '门店' , dataIndex : [ 'store' , 'name' ] , width : 140 , ellipsis : true } ,
{ title : '登录手机' , dataIndex : [ 'store' , 'phone' ] , width : 120 , render : ( v ) = > v || '—' } ,
{ title : '城市' , dataIndex : [ 'store' , 'cityName' ] , width : 90 } ,
{ title : '核销笔数' , dataIndex : 'redeemCount' , width : 90 } ,
{
title : '核销金额 ' ,
dataIndex : 'redeemAmount' ,
width : 110 ,
render : ( v ) = > ` ¥ ${ Number ( v ) . toFixed ( 2 ) } ` ,
} ,
{
title : '结算比例' ,
dataIndex : 'settlementRate' ,
width : 90 ,
render : ( v ) = > ` ${ Math . round ( Number ( v ) * 100 ) } % ` ,
title : '笔数 ' ,
width : 80 ,
render : ( _ , row ) = >
row . kind === 'T1_BILL' ? ( row . redeemCount ? ? '—' ) : ( row . payoutCount ? ? '—' ) ,
} ,
{
title : '应付金额' ,
dataIndex : 'payoutA mount' ,
dataIndex : 'a mount' ,
width : 110 ,
render : ( v ) = > ` ¥ ${ Number ( v ) . toFixed ( 2 ) } ` ,
} ,
{
title : '状态' ,
dataIndex : 'status' ,
width : 90 ,
render : ( s ) = > < Tag color = { STATUS_COLORS [ s ] || 'default' } > { STATUS_LABELS [ s ] || s } < / Tag > ,
width : 100 ,
render : ( s : string , row ) = > (
< Tag color = { STATUS_COLORS [ s ] || 'default' } > { storeSettlementStatusLabel ( row . kind , s ) } < / Tag >
) ,
} ,
{
title : '操作' ,
width : 160 ,
width : 200 ,
fixed : 'right' ,
render : ( _ , row ) = > (
< Space size = { 0 } >
< Button type = "link" size = "small" onClick = { ( ) = > void openDetail ( row . id ) } >
< Button type = "link" size = "small" onClick = { ( ) = > void openDetail ( row ) } >
明 细
< / Button >
{ row . status === 'UNPAID' && (
< Button type = "link" size = "small" onClick = { ( ) = > confirmPay ( [ row . id ] , Number ( row . payoutAmount ) ) } >
{ row . kind === 'T1_BILL' && row . status === 'UNPAID' && (
< Button type = "link" size = "small" onClick = { ( ) = > confirmPay ( [ row . id ] , Number ( row . amount ) ) } >
确 认 打 款
< / Button >
) }
{ row . kind === 'WITHDRAW' && row . status === 'PENDING_REVIEW' && (
< >
< Button type = "link" size = "small" onClick = { ( ) = > approveWithdraw ( row . id ) } >
通 过
< / Button >
< Button type = "link" size = "small" danger onClick = { ( ) = > rejectWithdraw ( row . id ) } >
驳 回
< / Button >
< / >
) }
< / Space >
) ,
} ,
] ;
const withdrawDetailItems =
( detail ? . items as Array < Record < string , unknown > > | undefined ) ? ? [ ] ;
const storeAccount = detail ? . storeAccount as
| {
name? : string ;
phone? : string ;
bankAccountName? : string ;
bankAccountNo? : string ;
bankBranch? : string ;
}
| undefined ;
return (
< div >
< Space direction = "vertical" size = { 0 } style = { { marginBottom : 16 } } >
< Typography.Title level = { 4 } style = { { margin : 0 } } >
门 店 对 账 单
门 店 账 单
< / Typography.Title >
< Typography.Text type = "secondary" >
按 核 销 日 汇 总 ( 每 日 8 :00 自 动 出 账 ) ; 未 打 款 红 色 、 已 打 款 绿 色
含 T + 1 自 动 出 账 与 门 店 手 动 提 现 ; 按 类 型 筛 选 , 详 情 与 审 核 动 作 沿 用 原 接 口
< / Typography.Text >
{ overdueSummary && overdueSummary . overdueCount > 0 ? (
< Typography.Text type = "danger" >
手 动 提 现 超 时 未 审 { overdueSummary . overdueCount } 笔 ( 待 审 共 { overdueSummary . pendingCount } )
< / Typography.Text >
) : null }
< / Space >
{ summary && (
< Card size = "small" style = { { marginBottom : 16 } } >
< Space size = "large" wrap >
< Statistic title = "账单 数" value = { summary . count } / >
< Statistic title = "记录 数" value = { summary . count } / >
< Statistic title = "核销金额合计" value = { summary . redeemAmount ? ? 0 } prefix = "¥" precision = { 2 } / >
< Statistic title = "应付合计" value = { summary . payoutAmount ? ? 0 } prefix = "¥" precision = { 2 } / >
< Statistic title = "应付合计" value = { summary . payoutAmount ? ? summary . totalAmount ? ? 0 } prefix = "¥" precision = { 2 } / >
< / Space >
< / Card >
) }
@@ -208,16 +356,37 @@ export default function StoreBillsPage() {
form = { form }
layout = "inline"
style = { { marginBottom : 16 } }
onFinish = { ( v : { storeId? : string ; status? : string ; range ? : [ Dayjs , Dayjs ] } ) = > {
initialValues = { {
kind : filters.kind || undefined ,
status : filters.status || undefined ,
} }
onFinish = { ( v : {
kind? : string ;
storeId? : string ;
status? : string ;
range ? : [ Dayjs , Dayjs ] ;
} ) = > {
setFilters ( {
kind : v.kind || '' ,
storeId : v.storeId || '' ,
status : v.status || '' ,
dateFrom : v.range?. [ 0 ] ? v . range [ 0 ] . format ( 'YYYY-MM-DD' ) : '' ,
dateTo : v.range?. [ 1 ] ? v . range [ 1 ] . format ( 'YYYY-MM-DD' ) : '' ,
} ) ;
setPage ( 1 ) ;
setSelectedKeys ( [ ] ) ;
} }
>
< Form.Item name = "kind" label = "类型" >
< Select
allowClear
style = { { width : 140 } }
options = { Object . entries ( STORE_SETTLEMENT_KIND_LABELS ) . map ( ( [ value , label ] ) = > ( {
value ,
label ,
} ) ) }
/ >
< / Form.Item >
< Form.Item name = "storeId" label = "门店" >
< Select
allowClear
@@ -232,10 +401,16 @@ export default function StoreBillsPage() {
< Select
allowClear
style = { { width : 120 } }
options = { Object . entries ( STATUS_LABELS ) . map ( ( [ value , label ] ) = > ( { value , label } ) ) }
options = { Object . entries ( STORE_SETTLEMENT_STATUS_LABELS ) . map ( ( [ value , label ] ) = > ( {
value ,
label :
value === 'PAID'
? '已打款/已结算'
: label ,
} ) ) }
/ >
< / Form.Item >
< Form.Item name = "range" label = "账单 日" >
< Form.Item name = "range" label = "日期 " >
< DatePicker.RangePicker / >
< / Form.Item >
< Form.Item >
@@ -249,6 +424,7 @@ export default function StoreBillsPage() {
form . resetFields ( ) ;
setFilters ( { } ) ;
setPage ( 1 ) ;
setSelectedKeys ( [ ] ) ;
} }
>
重 置
@@ -256,33 +432,36 @@ export default function StoreBillsPage() {
< / Form.Item >
< Form.Item >
< Button loading = { exporting } onClick = { ( ) = > void exportExcel ( ) } >
导 出 Excel
导 出 T + 1 Excel
< / Button >
< / Form.Item >
< Form.Item >
< Button
type = "primary"
disabled = { ! selectedKeys . length }
disabled = { ! selectedRows . length }
loading = { batchLoading }
onClick = { ( ) = > confirmPay ( selectedKeys . map ( String ) , selectedAmount ) }
onClick = { ( ) = > confirmPay ( selectedRows . map ( ( r ) = > r . id ) , selectedAmount ) }
>
批 量 确 认 打 款 ( { selectedKeys . length } )
批 量 确 认 打 款 ( { selectedRows . length } )
< / Button >
< / Form.Item >
< / Form >
< Table
rowKey = "id"
rowKey = { ( r ) = > ` ${ r . kind } - ${ r . id } ` }
className = "admin-table-nowrap"
loading = { loading }
columns = { columns }
dataSource = { data ? . items ? ? [ ] }
rowClassName = { ( row ) = > ( row . overdue ? 'ant-table-row-selected' : '' ) }
rowSelection = { {
selectedRowKeys : selectedKeys ,
onChange : setSelectedKeys ,
getCheckboxProps : ( r ) = > ( { disabled : r.status !== 'UNPAID' } ) ,
getCheckboxProps : ( r ) = > ( {
disabled : ! ( r . kind === 'T1_BILL' && r . status === 'UNPAID' ) ,
} ) ,
} }
scroll = { { x : 1200 } }
scroll = { { x : 1300 } }
pagination = { {
current : page ,
pageSize ,
@@ -295,14 +474,25 @@ export default function StoreBillsPage() {
} }
/ >
< Drawer title = "门店对账单明细" open = { drawerOpen } onClose = { ( ) = > setDrawerOpen ( false ) } width = { 520 } >
{ detail && (
< Drawer
title = { detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细' }
open = { drawerOpen }
onClose = { ( ) = > setDrawerOpen ( false ) }
width = { detailKind === 'WITHDRAW' ? 640 : 520 }
>
{ detail && detailKind === 'T1_BILL' && (
< >
< Descriptions column = { 1 } size = "small" bordered >
< Descriptions.Item label = "账单号" > { String ( detail . billNo ) } < / Descriptions.Item >
< Descriptions.Item label = "账单日" > { String ( detail . billDate || '' ) . slice ( 0 , 10 ) } < / Descriptions.Item >
< Descriptions.Item label = "应付" > ¥ { Number ( detail . payoutAmount ? ? 0 ) . toFixed ( 2 ) } < / Descriptions.Item >
< Descriptions.Item label = "状态" > { STATUS_LABELS [ String ( detail . status ) ] || String ( detail . status ) } < / Descriptions.Item >
< Descriptions.Item label = "账单日" >
{ String ( detail . billDate || '' ) . slice ( 0 , 10 ) }
< / Descriptions.Item >
< Descriptions.Item label = "应付" >
¥ { Number ( detail . payoutAmount ? ? 0 ) . toFixed ( 2 ) }
< / Descriptions.Item >
< Descriptions.Item label = "状态" >
{ storeSettlementStatusLabel ( 'T1_BILL' , String ( detail . status ) ) }
< / Descriptions.Item >
< Descriptions.Item label = "打款时间" >
{ detail . paidAt ? fmtTime ( String ( detail . paidAt ) ) : '—' }
< / Descriptions.Item >
@@ -318,7 +508,8 @@ export default function StoreBillsPage() {
columns = { [
{
title : '核销单号' ,
render : ( _ , r ) = > String ( ( r . redeemRecord as { redeemNo? : string } ) ? . redeemNo || '—' ) ,
render : ( _ , r ) = >
String ( ( r . redeemRecord as { redeemNo? : string } ) ? . redeemNo || '—' ) ,
} ,
{
title : '金额' ,
@@ -334,6 +525,83 @@ export default function StoreBillsPage() {
/ >
< / >
) }
{ detail && detailKind === 'WITHDRAW' && (
< >
< Descriptions column = { 1 } size = "small" bordered >
< Descriptions.Item label = "提现单号" > { String ( detail . withdrawNo ) } < / Descriptions.Item >
< Descriptions.Item label = "金额" >
¥ { Number ( detail . amount ? ? 0 ) . toFixed ( 2 ) }
< / Descriptions.Item >
< Descriptions.Item label = "状态" >
{ storeSettlementStatusLabel ( 'WITHDRAW' , String ( detail . status ) ) }
< / Descriptions.Item >
< Descriptions.Item label = "申请时间" >
{ detail . appliedAt ? fmtTime ( String ( detail . appliedAt ) ) : '—' }
< / Descriptions.Item >
< Descriptions.Item label = "驳回理由" >
{ detail . rejectReason ? String ( detail . rejectReason ) : '—' }
< / Descriptions.Item >
< Descriptions.Item label = "打款凭证" >
{ detail . paymentRef ? String ( detail . paymentRef ) : '—' }
< / Descriptions.Item >
< / Descriptions >
{ storeAccount ? (
< >
< Typography.Title level = { 5 } style = { { marginTop : 16 } } >
收 款 账 户
< / Typography.Title >
< Descriptions column = { 1 } size = "small" bordered >
< Descriptions.Item label = "户名" >
{ storeAccount . bankAccountName || '—' }
< / Descriptions.Item >
< Descriptions.Item label = "账号" >
{ storeAccount . bankAccountNo || '—' }
< / Descriptions.Item >
< Descriptions.Item label = "开户行" >
{ storeAccount . bankBranch || '—' }
< / Descriptions.Item >
< / Descriptions >
< / >
) : null }
< Typography.Title level = { 5 } style = { { marginTop : 16 } } >
提 现 明 细
< / Typography.Title >
< Table
size = "small"
rowKey = "id"
pagination = { false }
dataSource = { withdrawDetailItems }
columns = { [
{
title : '核销单号' ,
render : ( _ , r ) = > {
const payout = r . storePayout as
| { redeemRecord ? : { redeemNo? : string } ; payoutAmount? : number }
| undefined ;
return String ( payout ? . redeemRecord ? . redeemNo || '—' ) ;
} ,
} ,
{
title : '应付' ,
render : ( _ , r ) = > {
const payout = r . storePayout as { payoutAmount? : number } | undefined ;
return ` ¥ ${ Number ( payout ? . payoutAmount ? ? 0 ) . toFixed ( 2 ) } ` ;
} ,
} ,
] }
/ >
{ String ( detail . status ) === 'PENDING_REVIEW' ? (
< Space style = { { marginTop : 16 } } >
< Button type = "primary" onClick = { ( ) = > approveWithdraw ( String ( detail . id ) ) } >
通 过
< / Button >
< Button danger onClick = { ( ) = > rejectWithdraw ( String ( detail . id ) ) } >
驳 回
< / Button >
< / Space >
) : null }
< / >
) }
< / Drawer >
< / div >
) ;