first version

This commit is contained in:
liu
2026-07-23 20:41:25 +08:00
parent 00a0938a1b
commit 10cff754a4
211 changed files with 11577 additions and 842 deletions
+719
View File
@@ -0,0 +1,719 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Input,
InputNumber,
message,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import {
CheckSquareOutlined,
FileDoneOutlined,
ToolOutlined,
} from '@ant-design/icons';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IReconciliation from '@/domain/iReconciliation.ts';
import type { IReconDiff, IReconciliationItem } from '@/domain/iReconciliation.ts';
import { RECON_STATUS_MAP } from '@/domain/iReconciliation.ts';
import type IProductCategory from '@/domain/iProductCategory.ts';
import type ISupplier from '@/domain/iSupplier.ts';
import { getCategoryTree } from '@/api/product/category.ts';
import { getSupplierOptions } from '@/api/customer/supplier.ts';
import {
buildRecon,
getReconDiff,
remarkReconItem,
settleRecon,
toggleReconItem,
updateReconItem,
} from '@/api/recon/list.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
interface EditingItem {
product_name: string;
quantity: number;
weight: number;
publish_amount: number;
actual_amount: number;
}
/**
* 财务对账(D1/D2 筛选建单、D4 明细修改、D5 差额对比、D6 备注、D8 标记、D9 结算)
*/
const ReconListPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IReconciliation>>(null);
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
// 工作台抽屉
const [workOpen, setWorkOpen] = useState(false);
const [workLoading, setWorkLoading] = useState(false);
const [recon, setRecon] = useState<IReconciliation | null>(null);
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
const [savingItemId, setSavingItemId] = useState<number | null>(null);
const [diff, setDiff] = useState<IReconDiff | null>(null);
// 备注弹窗
const [remarkOpen, setRemarkOpen] = useState(false);
const [remarkTarget, setRemarkTarget] = useState<IReconciliationItem | null>(null);
const [remarkValue, setRemarkValue] = useState('');
useEffect(() => {
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
}, []);
const loadRecon = async (id: number) => {
const res = await Get<IReconciliation>('/recon/list', id);
const data = res.data.data ?? null;
setRecon(data);
const editingMap: Record<number, EditingItem> = {};
data?.items?.forEach((item) => {
if (item.id !== undefined) {
editingMap[item.id] = {
product_name: item.product_name ?? '',
quantity: Number(item.quantity ?? 0),
weight: Number(item.weight ?? 0),
publish_amount: Number(item.publish_amount ?? 0),
actual_amount: Number(item.actual_amount ?? 0),
};
}
});
setEditing(editingMap);
return data;
};
const openWorkbench = async (id: number) => {
setWorkOpen(true);
setWorkLoading(true);
setDiff(null);
try {
await loadRecon(id);
} finally {
setWorkLoading(false);
}
};
const loadDiff = async (id: number) => {
const res = await getReconDiff(id);
setDiff(res.data.data ?? null);
};
const handleBuild = async (record: IReconciliation) => {
const res = await buildRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 条对账明细`);
await tableRef.current?.reload();
};
const handleSettle = async (record: IReconciliation) => {
const res = await settleRecon(record.id!);
message.success(`已生成 ${res.data.data?.count} 张结算表`);
await tableRef.current?.reload();
};
const isItemDirty = (item: IReconciliationItem): boolean => {
const edit = editing[item.id!];
if (!edit) {
return false;
}
return (
edit.product_name !== (item.product_name ?? '') ||
edit.quantity !== Number(item.quantity ?? 0) ||
edit.weight !== Number(item.weight ?? 0) ||
edit.publish_amount !== Number(item.publish_amount ?? 0) ||
edit.actual_amount !== Number(item.actual_amount ?? 0)
);
};
const saveItem = async (item: IReconciliationItem) => {
const edit = editing[item.id!];
if (!edit || !isItemDirty(item)) {
return;
}
setSavingItemId(item.id!);
try {
const res = await updateReconItem(item.id!, {
product_name: edit.product_name,
quantity: edit.quantity,
weight: edit.weight,
publish_amount: edit.publish_amount,
actual_amount: edit.actual_amount,
});
message.success(`已保存,差额 ¥${res.data.data?.diff_amount}`);
await loadRecon(recon!.id!);
await loadDiff(recon!.id!);
} finally {
setSavingItemId(null);
}
};
const handleToggle = async (item: IReconciliationItem) => {
await toggleReconItem(item.id!);
await loadRecon(recon!.id!);
};
const openRemark = (item: IReconciliationItem) => {
setRemarkTarget(item);
setRemarkValue(item.store_remark ?? '');
setRemarkOpen(true);
};
const saveRemark = async () => {
await remarkReconItem(remarkTarget!.id!, remarkValue);
message.success('备注已保存');
setRemarkOpen(false);
await loadRecon(recon!.id!);
};
const readonly = recon?.status === 2;
const itemColumns: TableProps<IReconciliationItem>['columns'] = [
{
title: '品名',
dataIndex: 'product_name',
width: 160,
render: (_, record) =>
readonly ? (
record.product_name
) : (
<Input
size="small"
value={editing[record.id!]?.product_name}
onChange={(e) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], product_name: e.target.value },
}))
}
/>
),
},
{
title: '门店',
dataIndex: 'store',
width: 130,
render: (_, record) => record.store?.name ?? `门店#${record.store_id}`,
},
{
title: '订货量',
dataIndex: 'quantity',
width: 110,
render: (_, record) =>
readonly ? (
record.quantity
) : (
<InputNumber
size="small"
min={0}
precision={2}
value={editing[record.id!]?.quantity}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], quantity: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '称重',
dataIndex: 'weight',
width: 110,
render: (_, record) =>
readonly ? (
record.weight
) : (
<InputNumber
size="small"
min={0}
precision={3}
value={editing[record.id!]?.weight}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], weight: v ?? 0 },
}))
}
className="!w-20"
/>
),
},
{
title: '公布金额',
dataIndex: 'publish_amount',
width: 120,
render: (_, record) =>
readonly ? (
`¥${record.publish_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.publish_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], publish_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '实际金额',
dataIndex: 'actual_amount',
width: 120,
render: (_, record) =>
readonly ? (
`¥${record.actual_amount}`
) : (
<InputNumber
size="small"
min={0}
precision={2}
prefix="¥"
value={editing[record.id!]?.actual_amount}
onChange={(v) =>
setEditing((prev) => ({
...prev,
[record.id!]: { ...prev[record.id!], actual_amount: v ?? 0 },
}))
}
className="!w-24"
/>
),
},
{
title: '差额',
dataIndex: 'diff_amount',
width: 100,
align: 'right',
render: (v) => {
const num = Number(v ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{String(v)}
</Text>
);
},
},
{
title: '对账',
dataIndex: 'is_reconciled',
width: 80,
align: 'center',
render: (_, record) => (
<Switch
size="small"
disabled={readonly}
checked={record.is_reconciled === 1}
onChange={() => handleToggle(record)}
/>
),
},
{
title: '门店备注',
dataIndex: 'store_remark',
width: 120,
ellipsis: true,
render: (_, record) =>
record.store_remark || <Text type="secondary"></Text>,
},
{
title: '操作',
key: 'action',
width: 130,
fixed: 'right',
render: (_, record) =>
readonly ? null : (
<Space size={4}>
<AuthButton auth="recon.item.item.update">
<Button
size="small"
type="link"
disabled={!isItemDirty(record)}
loading={savingItemId === record.id}
onClick={() => saveItem(record)}
>
</Button>
</AuthButton>
<AuthButton auth="recon.item.item.update">
<Button size="small" type="link" onClick={() => openRemark(record)}>
</Button>
</AuthButton>
</Space>
),
},
];
const diffColumns = (nameTitle: string, nameKey: 'store_name' | 'product_name') => [
{ title: nameTitle, dataIndex: nameKey, render: (v: string) => v || '-' },
{ title: '公布金额', dataIndex: 'publish', align: 'right' as const, render: (v: number) => `¥${v}` },
{ title: '实际金额', dataIndex: 'actual', align: 'right' as const, render: (v: number) => `¥${v}` },
{
title: '差额',
dataIndex: 'diff',
align: 'right' as const,
render: (v: number) => (
<Text type={v === 0 ? 'secondary' : 'danger'} strong={v !== 0}>
¥{v}
</Text>
),
},
];
const columns: XinTableColumn<IReconciliation>[] = [
{
title: '对账单号',
dataIndex: 'recon_no',
valueType: 'text',
hideInForm: true,
},
{
title: '标题',
dataIndex: 'title',
valueType: 'text',
required: true,
rules: [{ required: true, message: '请输入对账标题' }],
},
{
title: '对账周期',
dataIndex: 'period',
hideInForm: true,
hideInSearch: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '开始日期',
dataIndex: 'period_start',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择开始日期' }],
},
{
title: '结束日期',
dataIndex: 'period_end',
valueType: 'date',
hideInTable: true,
required: true,
rules: [{ required: true, message: '请选择结束日期' }],
},
{
title: '商品分类',
dataIndex: 'category_id',
valueType: 'treeSelect',
hideInTable: true,
initialValue: 0,
fieldProps: {
treeData: [{ id: 0, name: '全部分类', children: categoryTree }],
fieldNames: { label: 'name', value: 'id', children: 'children' },
treeDefaultExpandAll: true,
},
},
{
title: '供应商',
dataIndex: 'supplier_id',
valueType: 'select',
hideInTable: true,
initialValue: 0,
fieldProps: {
options: [
{ label: '全部供应商', value: 0 },
...suppliers.map((s) => ({ label: s.name, value: s.id })),
],
},
},
{
title: '公布金额',
dataIndex: 'publish_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.publish_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.actual_amount}`,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(RECON_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = RECON_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '备注',
dataIndex: 'remark',
valueType: 'textarea',
hideInTable: true,
hideInSearch: true,
fieldProps: { rows: 2 },
},
];
const operateRender: XinTableProps<IReconciliation>['operateRender'] = (record, dom) => [
<AuthButton key="build" auth="recon.list.build">
<Popconfirm
title="生成对账明细?"
description="按周期/品类/供应商拉取已分摊的采购数据,重复生成会清空现有明细。"
disabled={record.status === 2}
onConfirm={() => handleBuild(record)}
>
<Button size="small" disabled={record.status === 2}>
</Button>
</Popconfirm>
</AuthButton>,
<Button
key="workbench"
size="small"
type="primary"
ghost
icon={<ToolOutlined />}
disabled={record.status === 0}
onClick={() => openWorkbench(record.id!)}
>
</Button>,
<AuthButton key="settle" auth="recon.list.settle">
<Popconfirm
title="生成结算表?"
description="按门店聚合对账明细生成结算表,对账单将变为已结算且不可再修改。"
disabled={record.status !== 1}
onConfirm={() => handleSettle(record)}
>
<Button
size="small"
type="primary"
icon={<FileDoneOutlined />}
disabled={record.status !== 1}
>
</Button>
</Popconfirm>
</AuthButton>,
// 编辑/删除由 XinTable 默认提供;删除仅草稿可用,由后端校验拦截
dom.edit,
dom.del,
];
const tableProps: XinTableProps<IReconciliation> = {
api: '/recon/list',
columns,
rowKey: 'id',
accessName: 'recon.list',
tableRef,
operateRender,
scroll: { x: 1300 },
formProps: {
grid: true,
colProps: { span: 12 },
layout: 'vertical',
},
modalProps: { width: 720 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
//
</Text>
</div>
<XinTable<IReconciliation> {...tableProps} />
{/* 对账工作台 */}
<Drawer
title={recon ? `对账工作台 · ${recon.recon_no}` : '对账工作台'}
open={workOpen}
onClose={() => setWorkOpen(false)}
width={1200}
loading={workLoading}
>
{recon ? (
<>
<Descriptions column={4} size="small" bordered>
<Descriptions.Item label="标题">{recon.title}</Descriptions.Item>
<Descriptions.Item label="周期">
{recon.period_start} ~ {recon.period_end}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={RECON_STATUS_MAP[recon.status ?? 0]?.color}>
{RECON_STATUS_MAP[recon.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="差额">
<Text
type={Number(recon.diff_amount) === 0 ? 'secondary' : 'danger'}
strong
>
¥{recon.diff_amount}
</Text>
</Descriptions.Item>
</Descriptions>
<Tabs
className="mt-4"
items={[
{
key: 'items',
label: (
<span>
<CheckSquareOutlined /> {recon.items?.length ?? 0}
</span>
),
children: (
<>
{!readonly ? (
<div className="mb-2 text-gray-500">
///
</div>
) : null}
<Table<IReconciliationItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={recon.items ?? []}
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 1250 }}
/>
</>
),
},
{
key: 'diff',
label: '差额对比',
children: (
<>
<Space className="mb-3">
<Button onClick={() => loadDiff(recon.id!)}></Button>
{diff ? (
<Text type="secondary">
¥{diff.total.publish} / ¥{diff.total.actual} /{' '}
<Text type={diff.total.diff === 0 ? 'secondary' : 'danger'} strong>
¥{diff.total.diff}
</Text>
</Text>
) : null}
</Space>
{diff ? (
<div className="grid grid-cols-2 gap-4">
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.store_id)}
size="small"
columns={diffColumns('门店', 'store_name')}
dataSource={diff.by_store}
pagination={false}
/>
</div>
<div>
<Title level={5}></Title>
<Table
rowKey={(row) => String(row.product_id)}
size="small"
columns={diffColumns('商品', 'product_name')}
dataSource={diff.by_product}
pagination={false}
/>
</div>
</div>
) : (
<Button type="primary" onClick={() => loadDiff(recon.id!)}>
</Button>
)}
</>
),
},
]}
/>
</>
) : null}
</Drawer>
{/* 门店备注弹窗 */}
<Modal
title="单品门店备注"
open={remarkOpen}
onCancel={() => setRemarkOpen(false)}
onOk={saveRemark}
okText="保存备注"
destroyOnHidden
>
<div className="mb-2 text-gray-500">
{remarkTarget?.product_name}
{remarkTarget?.store ? ` · ${remarkTarget.store.name}` : ''}
</div>
<Input.TextArea
rows={3}
maxLength={255}
showCount
value={remarkValue}
onChange={(e) => setRemarkValue(e.target.value)}
placeholder="填写该单品针对该门店的备注(如质量异常、补货说明等)"
/>
</Modal>
</>
);
};
export default ReconListPage;
+236
View File
@@ -0,0 +1,236 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Dropdown,
Tag,
Typography,
} from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type ISettlement from '@/domain/iSettlement.ts';
import { SETTLEMENT_STATUS_MAP } from '@/domain/iSettlement.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import { downloadSettlement } from '@/api/recon/settlement.ts';
import { Get } from '@/api/common/table.ts';
import AuthButton from '@/components/AuthButton';
const { Title, Text } = Typography;
/**
* 结算表(D9 生成于对账结算,D10 导出存档)
*/
const SettlementPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<ISettlement>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<ISettlement | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await Get<ISettlement>('/recon/settlement', id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const columns: XinTableColumn<ISettlement>[] = [
{
title: '结算单号',
dataIndex: 'settlement_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.settlement_no }}>{record.settlement_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '来源对账单',
dataIndex: 'recon',
hideInForm: true,
hideInSearch: true,
render: (_, record) =>
record.recon ? (
<span>
{record.recon.recon_no}
<Text type="secondary">{record.recon.title}</Text>
</span>
) : (
'-'
),
},
{
title: '结算周期',
dataIndex: 'period',
hideInForm: true,
hideInSearch: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '公布金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => `¥${record.total_amount}`,
},
{
title: '实际金额',
dataIndex: 'actual_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => <Text strong>¥{record.actual_amount}</Text>,
},
{
title: '差额',
dataIndex: 'diff_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => {
const num = Number(record.diff_amount ?? 0);
return (
<Text type={num === 0 ? 'secondary' : 'danger'} strong={num !== 0}>
¥{record.diff_amount}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(SETTLEMENT_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = SETTLEMENT_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
{
title: '结算时间',
dataIndex: 'settled_at',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => record.settled_at ?? '-',
},
];
const operateRender: XinTableProps<ISettlement>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
<AuthButton key="download" auth="recon.settlement.download">
<Dropdown
menu={{
items: [
{ key: 'xlsx', label: '下载 Excel', onClick: () => downloadSettlement(record.id!, 'xlsx') },
{ key: 'pdf', label: '下载 PDF', onClick: () => downloadSettlement(record.id!, 'pdf') },
],
}}
>
<Button size="small" type="primary" ghost icon={<DownloadOutlined />}>
</Button>
</Dropdown>
</AuthButton>,
];
const tableProps: XinTableProps<ISettlement> = {
api: '/recon/settlement',
columns,
rowKey: 'id',
accessName: 'recon.settlement',
tableRef,
operateRender,
formProps: false,
scroll: { x: 1200 },
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
Excel / PDF
</Text>
</div>
<XinTable<ISettlement> {...tableProps} />
<Drawer
title={detail ? `结算表 ${detail.settlement_no}` : '结算表详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={640}
loading={detailLoading}
>
{detail ? (
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
{SETTLEMENT_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="来源对账单">
{detail.recon?.recon_no ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="结算周期">
{detail.period_start} ~ {detail.period_end}
</Descriptions.Item>
<Descriptions.Item label="公布金额">¥{detail.total_amount}</Descriptions.Item>
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
<Descriptions.Item label="差额">¥{detail.diff_amount}</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.settled_at ?? '-'}
</Descriptions.Item>
<Descriptions.Item label="存档文件" span={2}>
{detail.file_path ?? <Text type="secondary"></Text>}
</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</>
);
};
export default SettlementPage;
+239
View File
@@ -0,0 +1,239 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Table,
Tag,
Typography,
} from 'antd';
import type { TableProps } from 'antd';
import XinTable from '@/components/XinTable';
import type {
XinTableColumn,
XinTableInstance,
XinTableProps,
} from '@/components/XinTable/typings.ts';
import type IStatement from '@/domain/iStatement.ts';
import type { IStatementItem } from '@/domain/iStatement.ts';
import { STATEMENT_STATUS_MAP } from '@/domain/iStatement.ts';
import { RECONCILED_MAP } from '@/domain/iReconciliation.ts';
import { getStoreOptions } from '@/api/customer/store.ts';
import type IStore from '@/domain/iStore.ts';
import { Get } from '@/api/common/table.ts';
const { Title, Text } = Typography;
/**
* 门店对账单(后台只读视角;生成/导出在小程序端)
*/
const StatementPage: React.FC = () => {
const tableRef = useRef<XinTableInstance<IStatement>>(null);
const [stores, setStores] = useState<IStore[]>([]);
const [detailOpen, setDetailOpen] = useState(false);
const [detail, setDetail] = useState<IStatement | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
getStoreOptions().then((res) => setStores(res.data.data ?? []));
}, []);
const openDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
try {
const res = await Get<IStatement>('/recon/statement', id);
setDetail(res.data.data ?? null);
} finally {
setDetailLoading(false);
}
};
const itemColumns: TableProps<IStatementItem>['columns'] = [
{ title: '品名', dataIndex: 'product_name' },
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
{ title: '数量', dataIndex: 'quantity', align: 'right' },
{ title: '重量', dataIndex: 'weight', align: 'right' },
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
{
title: '对账状态',
dataIndex: 'is_reconciled',
align: 'center',
render: (v) => {
const item = RECONCILED_MAP[Number(v ?? 0)];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
},
{ title: '备注', dataIndex: 'store_remark', render: (v) => v || '-' },
];
const columns: XinTableColumn<IStatement>[] = [
{
title: '对账单号',
dataIndex: 'statement_no',
valueType: 'text',
hideInForm: true,
render: (_, record) => <Text copyable={{ text: record.statement_no }}>{record.statement_no}</Text>,
},
{
title: '门店',
dataIndex: 'store_id',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: stores.map((s) => ({ label: s.name, value: s.id })),
showSearch: true,
optionFilterProp: 'label',
},
render: (_, record) => record.store?.name ?? '-',
},
{
title: '对账周期',
dataIndex: 'period_start',
valueType: 'dateRange',
hideInForm: true,
render: (_, record) => `${record.period_start} ~ ${record.period_end}`,
},
{
title: '总金额',
dataIndex: 'total_amount',
hideInForm: true,
hideInSearch: true,
align: 'right',
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
},
{
title: '回款周期',
dataIndex: 'payment_cycle_days',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => `${record.payment_cycle_days}`,
},
{
title: '应结算日期',
dataIndex: 'settlement_date',
hideInForm: true,
hideInSearch: true,
align: 'center',
render: (_, record) => {
const overdue =
record.status !== 2 && record.settlement_date
? new Date(record.settlement_date).getTime() < Date.now()
: false;
return (
<Text type={overdue ? 'danger' : undefined} strong={overdue}>
{record.settlement_date}
{overdue ? '(逾期)' : ''}
</Text>
);
},
},
{
title: '状态',
dataIndex: 'status',
valueType: 'select',
hideInForm: true,
fieldProps: {
options: Object.entries(STATEMENT_STATUS_MAP).map(([value, item]) => ({
value: Number(value),
label: item.text,
})),
},
render: (_, record) => {
const item = STATEMENT_STATUS_MAP[record.status ?? 0];
return <Tag color={item?.color}>{item?.text}</Tag>;
},
align: 'center',
},
];
const operateRender: XinTableProps<IStatement>['operateRender'] = (record) => [
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
</Button>,
];
const tableProps: XinTableProps<IStatement> = {
api: '/recon/statement',
columns,
rowKey: 'id',
accessName: 'recon.statement',
tableRef,
operateRender,
formProps: false,
actionBarRender: (dom) => [dom.search, dom.keywordSearch],
};
return (
<>
<div className="mb-5">
<Title level={3}></Title>
<Text type="secondary">
= +
</Text>
</div>
<XinTable<IStatement> {...tableProps} />
<Drawer
title={detail ? `对账单 ${detail.statement_no}` : '对账单详情'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={860}
loading={detailLoading}
>
{detail ? (
<>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATEMENT_STATUS_MAP[detail.status ?? 0]?.color}>
{STATEMENT_STATUS_MAP[detail.status ?? 0]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="对账周期">
{detail.period_start} ~ {detail.period_end}
</Descriptions.Item>
<Descriptions.Item label="回款周期(快照)">
{detail.payment_cycle_days}
</Descriptions.Item>
<Descriptions.Item label="应结算日期">
{detail.settlement_date}
</Descriptions.Item>
<Descriptions.Item label="总金额">¥{detail.total_amount}</Descriptions.Item>
{detail.remark ? (
<Descriptions.Item label="备注" span={2}>
{detail.remark}
</Descriptions.Item>
) : null}
</Descriptions>
<Title level={5} className="!mt-6 !mb-3">
{detail.items?.length ?? 0}
</Title>
<Table<IStatementItem>
rowKey="id"
size="small"
columns={itemColumns}
dataSource={detail.items ?? []}
pagination={false}
summary={() => (
<Table.Summary.Row>
<Table.Summary.Cell index={0} colSpan={4} align="right">
</Table.Summary.Cell>
<Table.Summary.Cell index={1} align="right">
<Text strong>¥{detail.total_amount}</Text>
</Table.Summary.Cell>
<Table.Summary.Cell index={2} colSpan={2} />
</Table.Summary.Row>
)}
/>
</>
) : null}
</Drawer>
</>
);
};
export default StatementPage;