first version
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||
import { CUSTOMER_LEVEL_STATUS_MAP } from '@/domain/iCustomerLevel.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 客户等级管理
|
||||
*/
|
||||
const CustomerLevelPage: React.FC = () => {
|
||||
const columns: XinTableColumn<ICustomerLevel>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '等级名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入等级名称' }],
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
initialValue: 1,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = CUSTOMER_LEVEL_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<ICustomerLevel> = {
|
||||
api: '/customer/level',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'customer.level',
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 640 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>客户等级</Title>
|
||||
<Text type="secondary">同一商品按客户等级显示不同单价,门店绑定等级后小程序端按对应价格展示。</Text>
|
||||
</div>
|
||||
<XinTable<ICustomerLevel> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerLevelPage;
|
||||
@@ -0,0 +1,294 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IMiniUser from '@/domain/iMiniUser.ts';
|
||||
import { MINI_USER_STATUS_MAP, MINI_USER_TYPE_MAP } from '@/domain/iMiniUser.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import { bindMiniUser, toggleMiniUserStatus } from '@/api/customer/miniUser.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface BindFormValues {
|
||||
type: number;
|
||||
store_id?: number;
|
||||
supplier_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序用户管理(用户由小程序登录自动生成,后台只做绑定与状态管理)
|
||||
*/
|
||||
const MiniUserPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IMiniUser>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
|
||||
// 绑定弹窗
|
||||
const [bindOpen, setBindOpen] = useState(false);
|
||||
const [bindTarget, setBindTarget] = useState<IMiniUser | null>(null);
|
||||
const [bindLoading, setBindLoading] = useState(false);
|
||||
const [bindForm] = Form.useForm<BindFormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
getStoreOptions().then((res) => setStores(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const openBind = (record: IMiniUser) => {
|
||||
setBindTarget(record);
|
||||
bindForm.setFieldsValue({
|
||||
type: record.type && record.type > 0 ? record.type : undefined,
|
||||
store_id: record.store_id || undefined,
|
||||
supplier_id: record.supplier_id || undefined,
|
||||
});
|
||||
setBindOpen(true);
|
||||
};
|
||||
|
||||
const handleBind = async (values: BindFormValues) => {
|
||||
if (!bindTarget?.id) {
|
||||
return;
|
||||
}
|
||||
setBindLoading(true);
|
||||
try {
|
||||
await bindMiniUser(bindTarget.id, {
|
||||
type: values.type,
|
||||
store_id: values.type === 1 ? values.store_id : undefined,
|
||||
supplier_id: values.type === 2 ? values.supplier_id : undefined,
|
||||
});
|
||||
message.success('绑定成功');
|
||||
setBindOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
} finally {
|
||||
setBindLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (record: IMiniUser) => {
|
||||
await toggleMiniUserStatus(record.id!, record.status === 1 ? 0 : 1);
|
||||
message.success(record.status === 1 ? '已停用' : '已启用');
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<IMiniUser>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space size={8}>
|
||||
{record.avatar ? (
|
||||
<img src={record.avatar} alt="" className="h-6 w-6 rounded-full" />
|
||||
) : null}
|
||||
<span>{record.nickname || '-'}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record.phone || <Text type="secondary">未绑定</Text>,
|
||||
},
|
||||
{
|
||||
title: '用户类型',
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: '待绑定' },
|
||||
{ value: 1, label: '门店' },
|
||||
{ value: 2, label: '供应商' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = MINI_USER_TYPE_MAP[record.type ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '绑定主体',
|
||||
dataIndex: 'bound_name',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => {
|
||||
if (record.type === 1) {
|
||||
return <Tag color="blue">{record.store?.name ?? `门店#${record.store_id}`}</Tag>;
|
||||
}
|
||||
if (record.type === 2) {
|
||||
return (
|
||||
<Tag color="purple">{record.supplier?.name ?? `供应商#${record.supplier_id}`}</Tag>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary">待后台绑定</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = MINI_USER_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'last_login_at',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.last_login_at ?? <Text type="secondary">从未登录</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IMiniUser>['operateRender'] = (record) => [
|
||||
<AuthButton key="bind" auth="customer.miniUser.bind">
|
||||
<Button size="small" color="blue" variant="outlined" onClick={() => openBind(record)}>
|
||||
绑定
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
<AuthButton key="status" auth="customer.miniUser.update">
|
||||
<Popconfirm
|
||||
title={record.status === 1 ? '确定停用该账号?' : '确定启用该账号?'}
|
||||
description={record.status === 1 ? '停用后该用户将无法登录小程序' : undefined}
|
||||
onConfirm={() => handleToggleStatus(record)}
|
||||
>
|
||||
<Button size="small" danger={record.status === 1}>
|
||||
{record.status === 1 ? '停用' : '启用'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IMiniUser> = {
|
||||
api: '/customer/miniUser',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'customer.miniUser',
|
||||
tableRef,
|
||||
operateRender,
|
||||
// 无新增/编辑表单
|
||||
formProps: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>小程序用户</Title>
|
||||
<Text type="secondary">
|
||||
用户由微信小程序登录自动创建;手机号授权后按手机号自动匹配门店/供应商,未命中的需在此人工绑定。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IMiniUser> {...tableProps} />
|
||||
|
||||
<Modal
|
||||
title={`绑定主体 · ${bindTarget?.nickname || ''}`}
|
||||
open={bindOpen}
|
||||
onCancel={() => setBindOpen(false)}
|
||||
onOk={() => bindForm.submit()}
|
||||
confirmLoading={bindLoading}
|
||||
okText="确认绑定"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form<BindFormValues>
|
||||
form={bindForm}
|
||||
layout="vertical"
|
||||
onFinish={handleBind}
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item
|
||||
label="用户类型"
|
||||
name="type"
|
||||
rules={[{ required: true, message: '请选择用户类型' }]}
|
||||
>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: '门店', value: 1 },
|
||||
{ label: '供应商', value: 2 },
|
||||
]}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.type !== cur.type}>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
if (type === 1) {
|
||||
return (
|
||||
<Form.Item
|
||||
label="绑定门店"
|
||||
name="store_id"
|
||||
rules={[{ required: true, message: '请选择门店' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择门店"
|
||||
options={stores.map((s) => ({
|
||||
label: `${s.name}(${s.code})`,
|
||||
value: s.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
if (type === 2) {
|
||||
return (
|
||||
<Form.Item
|
||||
label="绑定供应商"
|
||||
name="supplier_id"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择供应商"
|
||||
options={suppliers.map((s) => ({ label: s.name, value: s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniUserPage;
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type INotice from '@/domain/iNotice.ts';
|
||||
import { NOTICE_READ_MAP, NOTICE_TYPE_MAP } from '@/domain/iNotice.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 通知管理(user_id=0 为全员广播)
|
||||
*/
|
||||
const NoticePage: React.FC = () => {
|
||||
const columns: XinTableColumn<INotice>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入通知标题' }],
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
initialValue: 'system',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择通知类型' }],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 'system', label: '系统' },
|
||||
{ value: 'order', label: '订单' },
|
||||
{ value: 'price', label: '价格' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = NOTICE_TYPE_MAP[record.type ?? 'system'];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '接收对象',
|
||||
dataIndex: 'user_id',
|
||||
valueType: 'digit',
|
||||
hideInTable: false,
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
precision: 0,
|
||||
placeholder: '留空或 0 = 全员广播',
|
||||
},
|
||||
align: 'center',
|
||||
render: (_, record) =>
|
||||
record.user_id === 0 ? (
|
||||
<Tag color="gold">全员广播</Tag>
|
||||
) : (
|
||||
<Tag>{`用户 #${record.user_id}`}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 3 },
|
||||
},
|
||||
{
|
||||
title: '阅读状态',
|
||||
dataIndex: 'is_read',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: '未读' },
|
||||
{ value: 1, label: '已读' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = NOTICE_READ_MAP[record.is_read ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<INotice> = {
|
||||
api: '/customer/notice',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'customer.notice',
|
||||
// 通知只有新增与删除,无编辑
|
||||
editShow: () => false,
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 640 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>通知管理</Title>
|
||||
<Text type="secondary">
|
||||
向小程序用户发送消息;接收对象留空或填 0 为全员广播,价格调整通知由批量调价自动生成。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<INotice> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticePage;
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import { STORE_STATUS_MAP } from '@/domain/iStore.ts';
|
||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||
import { getLevelOptions } from '@/api/customer/level.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 门店管理
|
||||
*/
|
||||
const StorePage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const levelOptions = levels.map((l) => ({ label: l.name, value: l.id }));
|
||||
|
||||
const columns: XinTableColumn<IStore>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '门店名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入门店名称' }],
|
||||
},
|
||||
{
|
||||
title: '门店编码',
|
||||
dataIndex: 'code',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入门店编码' }],
|
||||
},
|
||||
{
|
||||
title: '客户等级',
|
||||
dataIndex: 'level_id',
|
||||
valueType: 'select',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择客户等级' }],
|
||||
fieldProps: {
|
||||
options: levelOptions,
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
},
|
||||
render: (_, record) =>
|
||||
record.level ? <Tag color="blue">{record.level.name}</Tag> : <Tag>未设置</Tag>,
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contact',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '回款周期(天)',
|
||||
dataIndex: 'payment_cycle_days',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: { min: 0, precision: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
initialValue: 1,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = STORE_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '门店地址',
|
||||
dataIndex: 'address',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IStore> = {
|
||||
api: '/customer/store',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'customer.store',
|
||||
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<IStore> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorePage;
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import { Tag, Typography } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { SUPPLIER_STATUS_MAP } from '@/domain/iSupplier.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 供应商管理
|
||||
*/
|
||||
const SupplierPage: React.FC = () => {
|
||||
const columns: XinTableColumn<ISupplier>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '供应商名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入供应商名称' }],
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contact',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '主营品类',
|
||||
dataIndex: 'main_products',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
render: (_, record) =>
|
||||
record.main_products
|
||||
? record.main_products.split('/').map((item) => (
|
||||
<Tag key={item} color="green">
|
||||
{item}
|
||||
</Tag>
|
||||
))
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
initialValue: 1,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = SUPPLIER_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'address',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<ISupplier> = {
|
||||
api: '/customer/supplier',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'customer.supplier',
|
||||
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<ISupplier> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupplierPage;
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
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 IStoreOrder from '@/domain/iStoreOrder.ts';
|
||||
import type { IStoreOrderItem } from '@/domain/iStoreOrder.ts';
|
||||
import { STORE_ORDER_STATUS_MAP } from '@/domain/iStoreOrder.ts';
|
||||
import { getStoreOrder, updateOrderStatus } from '@/api/order/store.ts';
|
||||
import { getStoreOptions } from '@/api/customer/store.ts';
|
||||
import type IStore from '@/domain/iStore.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 状态流转合法路径:待汇总→配送中/取消;已汇总→配送中;配送中→已完成
|
||||
*/
|
||||
const NEXT_STATUS: Record<number, { status: number; label: string; danger?: boolean }[]> = {
|
||||
0: [
|
||||
{ status: 2, label: '开始配送' },
|
||||
{ status: 9, label: '取消订单', danger: true },
|
||||
],
|
||||
1: [{ status: 2, label: '开始配送' }],
|
||||
2: [{ status: 3, label: '完成订单' }],
|
||||
};
|
||||
|
||||
/**
|
||||
* 门店订单管理(只读 + 状态流转,订单由小程序端创建)
|
||||
*/
|
||||
const StoreOrderPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IStoreOrder>>(null);
|
||||
const [stores, setStores] = useState<IStore[]>([]);
|
||||
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IStoreOrder | 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 getStoreOrder(id);
|
||||
setDetail(res.data.data ?? null);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: number, status: number) => {
|
||||
await updateOrderStatus(id, status);
|
||||
message.success('状态已更新');
|
||||
setDetailOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IStoreOrderItem>['columns'] = [
|
||||
{ title: '品名', dataIndex: 'product_name' },
|
||||
{ title: '规格', dataIndex: 'product_spec', render: (v) => v || '-' },
|
||||
{ title: '单价', dataIndex: 'price', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '订货量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '金额', dataIndex: 'amount', align: 'right', render: (v) => `¥${v}` },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v) => v || '-' },
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IStoreOrder>[] = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.order_no }}>{record.order_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: 'order_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.order_date,
|
||||
},
|
||||
{
|
||||
title: '订货总量',
|
||||
dataIndex: 'total_quantity',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '订单金额',
|
||||
dataIndex: 'total_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => <Text strong>¥{record.total_amount}</Text>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(STORE_ORDER_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = STORE_ORDER_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
ellipsis: true,
|
||||
render: (_, record) => record.remark || '-',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IStoreOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IStoreOrder> = {
|
||||
api: '/order/store',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'order.store',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>门店订单</Title>
|
||||
<Text type="secondary">
|
||||
小程序下单汇总;待汇总订单将进入采购单生成,订单创建与取消在小程序端完成。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IStoreOrder> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title="订单详情"
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={720}
|
||||
loading={detailLoading}
|
||||
footer={
|
||||
detail && NEXT_STATUS[detail.status ?? -1] ? (
|
||||
<Space className="flex justify-end">
|
||||
{NEXT_STATUS[detail.status!].map((action) => (
|
||||
<AuthButton key={action.status} auth="order.store.update">
|
||||
<Popconfirm
|
||||
title={`确认将订单状态更新为「${action.label}」?`}
|
||||
onConfirm={() => handleStatusChange(detail.id!, action.status)}
|
||||
>
|
||||
<Button type={action.danger ? undefined : 'primary'} danger={action.danger}>
|
||||
{action.label}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
))}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="订单号">{detail.order_no}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="订货日期">{detail.order_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{STORE_ORDER_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订货总量">{detail.total_quantity}</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">
|
||||
商品明细
|
||||
</Title>
|
||||
<Table<IStoreOrderItem>
|
||||
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} />
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreOrderPage;
|
||||
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Space, Tag, Typography } from 'antd';
|
||||
import { NodeExpandOutlined } from '@ant-design/icons';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import { CATEGORY_STATUS_MAP } from '@/domain/iProductCategory.ts';
|
||||
import { getCategoryTable } from '@/api/product/category.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 递归收集全部节点 id(用于展开整棵树)
|
||||
*/
|
||||
function collectAllIds(nodes: IProductCategory[]): number[] {
|
||||
const ids: number[] = [];
|
||||
const walk = (list: IProductCategory[]) => {
|
||||
list.forEach((node) => {
|
||||
if (node.id !== undefined) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
walk(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(nodes);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品分类管理(多级分类树表)
|
||||
*/
|
||||
const ProductCategoryPage: React.FC = () => {
|
||||
const [expandedKeys, setExpandedKeys] = useState<number[]>([]);
|
||||
const [allIds, setAllIds] = useState<number[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
|
||||
const loadTree = async () => {
|
||||
const res = await getCategoryTable();
|
||||
const tree = res.data.data ?? [];
|
||||
setCategoryTree(tree);
|
||||
setAllIds(collectAllIds(tree));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTree();
|
||||
}, []);
|
||||
|
||||
const columns: XinTableColumn<IProductCategory>[] = [
|
||||
{
|
||||
title: '分类名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入分类名称' }],
|
||||
},
|
||||
{
|
||||
title: '上级分类',
|
||||
dataIndex: 'parent_id',
|
||||
valueType: 'treeSelect',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
treeData: [{ id: 0, name: '顶级分类', children: categoryTree }],
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
placeholder: '默认顶级分类',
|
||||
treeDefaultExpandAll: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
initialValue: 1,
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 0, label: '停用' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = CATEGORY_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IProductCategory> = {
|
||||
api: '/product/category',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'product.category',
|
||||
// 后端直接返回分类树,不走分页接口
|
||||
handleRequest: async () => {
|
||||
const res = await getCategoryTable();
|
||||
const tree = res.data.data ?? [];
|
||||
setCategoryTree(tree);
|
||||
setAllIds(collectAllIds(tree));
|
||||
return { data: tree, total: tree.length };
|
||||
},
|
||||
pagination: { pageSize: 200 },
|
||||
expandable: {
|
||||
expandedRowKeys: expandedKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedKeys([...keys] as number[]),
|
||||
},
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 640 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div>
|
||||
<Title level={3}>商品分类</Title>
|
||||
<Text type="secondary">
|
||||
多级分类(如蔬菜/水果/其他),采购单导出与对账筛选按分类归组;有子分类或挂载商品时不可删除。
|
||||
</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<NodeExpandOutlined />}
|
||||
onClick={() =>
|
||||
setExpandedKeys(expandedKeys.length ? [] : allIds)
|
||||
}
|
||||
>
|
||||
{expandedKeys.length ? '全部收起' : '全部展开'}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<XinTable<IProductCategory> {...tableProps} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCategoryPage;
|
||||
@@ -0,0 +1,414 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
TreeSelect,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined, TableOutlined } from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type { XinTableColumn, XinTableProps } from '@/components/XinTable/typings.ts';
|
||||
import type IProduct from '@/domain/iProduct.ts';
|
||||
import type { IBatchPriceUpdate, IPriceMatrixRow } from '@/domain/iProduct.ts';
|
||||
import { PRODUCT_STATUS_MAP } from '@/domain/iProduct.ts';
|
||||
import type ICustomerLevel from '@/domain/iCustomerLevel.ts';
|
||||
import type IProductCategory from '@/domain/iProductCategory.ts';
|
||||
import { getLevelOptions } from '@/api/customer/level.ts';
|
||||
import { getSupplierOptions } from '@/api/customer/supplier.ts';
|
||||
import type ISupplier from '@/domain/iSupplier.ts';
|
||||
import { getCategoryTree } from '@/api/product/category.ts';
|
||||
import { batchPrice, getPriceMatrix } from '@/api/product/goods.ts';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 商品档案管理(A1 商品列表 / A2 价格矩阵与批量调价)
|
||||
*/
|
||||
const ProductGoodsPage: React.FC = () => {
|
||||
const [levels, setLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||||
const [categoryTree, setCategoryTree] = useState<IProductCategory[]>([]);
|
||||
|
||||
// ===== 价格矩阵抽屉 =====
|
||||
const [matrixOpen, setMatrixOpen] = useState(false);
|
||||
const [matrixLoading, setMatrixLoading] = useState(false);
|
||||
const [saveLoading, setSaveLoading] = useState(false);
|
||||
const [matrixRows, setMatrixRows] = useState<IPriceMatrixRow[]>([]);
|
||||
const [matrixSnapshot, setMatrixSnapshot] = useState<IPriceMatrixRow[]>([]);
|
||||
const [matrixLevels, setMatrixLevels] = useState<ICustomerLevel[]>([]);
|
||||
const [matrixKeyword, setMatrixKeyword] = useState('');
|
||||
const [matrixCategory, setMatrixCategory] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
getLevelOptions().then((res) => setLevels(res.data.data ?? []));
|
||||
getSupplierOptions().then((res) => setSuppliers(res.data.data ?? []));
|
||||
getCategoryTree().then((res) => setCategoryTree(res.data.data ?? []));
|
||||
}, []);
|
||||
|
||||
const loadMatrix = async (
|
||||
keyword = matrixKeyword,
|
||||
categoryId = matrixCategory
|
||||
) => {
|
||||
setMatrixLoading(true);
|
||||
try {
|
||||
const res = await getPriceMatrix({
|
||||
keyword: keyword || undefined,
|
||||
category_id: categoryId,
|
||||
});
|
||||
const rows = res.data.data?.rows ?? [];
|
||||
setMatrixRows(rows);
|
||||
setMatrixSnapshot(JSON.parse(JSON.stringify(rows)));
|
||||
setMatrixLevels(res.data.data?.levels ?? []);
|
||||
} finally {
|
||||
setMatrixLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openMatrix = () => {
|
||||
setMatrixOpen(true);
|
||||
loadMatrix('', undefined);
|
||||
};
|
||||
|
||||
const onMatrixPriceChange = (
|
||||
productId: number,
|
||||
levelId: number,
|
||||
value: number | null
|
||||
) => {
|
||||
setMatrixRows((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === productId ? { ...row, [`price_${levelId}`]: value } : row
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
/** diff 出被修改的价格行,提交批量调价 */
|
||||
const saveMatrix = async () => {
|
||||
const updates: IBatchPriceUpdate[] = [];
|
||||
for (const row of matrixRows) {
|
||||
const old = matrixSnapshot.find((r) => r.id === row.id);
|
||||
for (const level of matrixLevels) {
|
||||
const key = `price_${level.id}`;
|
||||
const next = row[key];
|
||||
const prev = old?.[key];
|
||||
if (next !== null && next !== undefined && String(next) !== String(prev ?? '')) {
|
||||
updates.push({ product_id: row.id, level_id: level.id!, price: next as number });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (updates.length === 0) {
|
||||
message.info('没有需要保存的价格调整');
|
||||
return;
|
||||
}
|
||||
setSaveLoading(true);
|
||||
try {
|
||||
await batchPrice(updates);
|
||||
message.success(`已更新 ${updates.length} 条价格,受影响门店将收到通知`);
|
||||
await loadMatrix();
|
||||
} finally {
|
||||
setSaveLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const matrixColumns: TableProps<IPriceMatrixRow>['columns'] = [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'name',
|
||||
fixed: 'left',
|
||||
width: 160,
|
||||
render: (name: string, row) => (
|
||||
<div>
|
||||
<div className="font-medium">{name}</div>
|
||||
<Text type="secondary" className="text-xs">
|
||||
{row.spec}
|
||||
{row.unit ? ` / ${row.unit}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...matrixLevels.map((level) => ({
|
||||
title: level.name,
|
||||
key: `price_${level.id}`,
|
||||
width: 150,
|
||||
render: (_: unknown, row: IPriceMatrixRow) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={row[`price_${level.id}`] as number | null}
|
||||
onChange={(v) => onMatrixPriceChange(row.id, level.id!, v)}
|
||||
className="w-32"
|
||||
/>
|
||||
),
|
||||
})),
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IProduct>[] = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 70,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入商品名称' }],
|
||||
},
|
||||
{
|
||||
title: '规格/包规',
|
||||
dataIndex: 'spec',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '等级',
|
||||
dataIndex: 'grade',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
dataIndex: 'unit',
|
||||
valueType: 'text',
|
||||
hideInSearch: true,
|
||||
initialValue: '斤',
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category_id',
|
||||
valueType: 'treeSelect',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请选择分类' }],
|
||||
fieldProps: {
|
||||
treeData: categoryTree,
|
||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
||||
treeDefaultExpandAll: true,
|
||||
showSearch: true,
|
||||
treeNodeFilterProp: 'name',
|
||||
placeholder: '选择分类',
|
||||
},
|
||||
render: (_, record) =>
|
||||
record.category ? <Tag color="cyan">{record.category.name}</Tag> : '-',
|
||||
},
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier_id',
|
||||
valueType: 'select',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
options: suppliers.map((s) => ({ label: s.name, value: s.id })),
|
||||
showSearch: true,
|
||||
optionFilterProp: 'label',
|
||||
allowClear: true,
|
||||
placeholder: '默认供应商(可选)',
|
||||
},
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '等级价格',
|
||||
dataIndex: 'prices',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Space size={[0, 4]} wrap>
|
||||
{record.prices?.length
|
||||
? record.prices.map((p) => (
|
||||
<Tag key={p.level_id} color="geekblue">
|
||||
{p.level?.name ?? `等级${p.level_id}`} ¥{p.price}
|
||||
</Tag>
|
||||
))
|
||||
: '-'}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
hideInSearch: true,
|
||||
fieldProps: { min: 0 },
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
initialValue: 1,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 1, label: '上架' },
|
||||
{ value: 0, label: '下架' },
|
||||
],
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = PRODUCT_STATUS_MAP[record.status ?? 1];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
hideInSearch: true,
|
||||
hideInTable: true,
|
||||
fieldProps: { rows: 2 },
|
||||
},
|
||||
{
|
||||
title: '等级价格设置',
|
||||
dataIndex: 'prices',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
fieldRender: () => (
|
||||
<Form.List name="prices">
|
||||
{(fields, { add, remove }) => (
|
||||
<div className="space-y-2">
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<Space key={key} align="baseline" className="flex">
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'level_id']}
|
||||
rules={[{ required: true, message: '请选择等级' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
placeholder="选择客户等级"
|
||||
options={levels.map((l) => ({ label: l.name, value: l.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'price']}
|
||||
rules={[{ required: true, message: '请输入单价' }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
placeholder="单价"
|
||||
className="w-32"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => remove(name)}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
block
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add()}
|
||||
>
|
||||
添加等级价格
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IProduct> = {
|
||||
api: '/product/goods',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'product.goods',
|
||||
scroll: { x: 1200 },
|
||||
actionBarRender: (dom) => [
|
||||
dom.add,
|
||||
<Button key="matrix" icon={<TableOutlined />} onClick={openMatrix}>
|
||||
价格矩阵
|
||||
</Button>,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
layout: 'vertical',
|
||||
},
|
||||
modalProps: { width: 800 },
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>商品列表</Title>
|
||||
<Text type="secondary">
|
||||
商品档案与多等级价格体系;「价格矩阵」支持按等级批量调价,调价后自动通知受影响门店。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IProduct> {...tableProps} />
|
||||
|
||||
<Drawer
|
||||
title="价格矩阵 · 批量调价"
|
||||
open={matrixOpen}
|
||||
onClose={() => setMatrixOpen(false)}
|
||||
width={matrixLevels.length * 150 + 260}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => loadMatrix()}>刷新</Button>
|
||||
<Button type="primary" loading={saveLoading} onClick={saveMatrix}>
|
||||
保存调价
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space className="mb-4" wrap>
|
||||
<TreeSelect
|
||||
style={{ width: 180 }}
|
||||
placeholder="按分类筛选"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
treeNodeFilterProp="name"
|
||||
fieldNames={{ label: 'name', value: 'id', children: 'children' }}
|
||||
treeData={categoryTree}
|
||||
value={matrixCategory}
|
||||
onChange={(v) => {
|
||||
setMatrixCategory(v);
|
||||
loadMatrix(matrixKeyword, v);
|
||||
}}
|
||||
/>
|
||||
<Input.Search
|
||||
style={{ width: 240 }}
|
||||
placeholder="搜索品名/规格"
|
||||
allowClear
|
||||
value={matrixKeyword}
|
||||
onChange={(e) => setMatrixKeyword(e.target.value)}
|
||||
onSearch={(v) => loadMatrix(v, matrixCategory)}
|
||||
/>
|
||||
</Space>
|
||||
<Table<IPriceMatrixRow>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={matrixLoading}
|
||||
columns={matrixColumns}
|
||||
dataSource={matrixRows}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
scroll={{ x: matrixLevels.length * 150 + 160 }}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGoodsPage;
|
||||
@@ -0,0 +1,590 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Form,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined,
|
||||
PlusOutlined,
|
||||
SendOutlined,
|
||||
SplitCellsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { TableProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {
|
||||
XinTableColumn,
|
||||
XinTableInstance,
|
||||
XinTableProps,
|
||||
} from '@/components/XinTable/typings.ts';
|
||||
import type IPurchaseOrder from '@/domain/iPurchaseOrder.ts';
|
||||
import type {
|
||||
IAllocationAggRow,
|
||||
IPurchaseOrderItem,
|
||||
} from '@/domain/iPurchaseOrder.ts';
|
||||
import { PURCHASE_STATUS_MAP } from '@/domain/iPurchaseOrder.ts';
|
||||
import {
|
||||
allocatePurchase,
|
||||
exportPurchase,
|
||||
generatePurchase,
|
||||
getAllocation,
|
||||
sendPurchaseItem,
|
||||
updatePurchaseItem,
|
||||
} from '@/api/purchase/order.ts';
|
||||
import { Get } from '@/api/common/table.ts';
|
||||
import AuthButton from '@/components/AuthButton';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 行内编辑中的明细值 */
|
||||
interface EditingItem {
|
||||
price: number;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购单管理(C1 汇总生成 / C2-C3 导出 / C4 明细修改 / C5-C6 发送 / D3 分摊)
|
||||
*/
|
||||
const PurchaseOrderPage: React.FC = () => {
|
||||
const tableRef = useRef<XinTableInstance<IPurchaseOrder>>(null);
|
||||
|
||||
// 生成采购单
|
||||
const [generateOpen, setGenerateOpen] = useState(false);
|
||||
const [generateLoading, setGenerateLoading] = useState(false);
|
||||
const [generateForm] = Form.useForm<{ purchase_date: dayjs.Dayjs }>();
|
||||
|
||||
// 详情抽屉
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detail, setDetail] = useState<IPurchaseOrder | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [editing, setEditing] = useState<Record<number, EditingItem>>({});
|
||||
const [savingItemId, setSavingItemId] = useState<number | null>(null);
|
||||
|
||||
// 分摊
|
||||
const [allocating, setAllocating] = useState(false);
|
||||
const [allocation, setAllocation] = useState<{
|
||||
byStore: IAllocationAggRow[];
|
||||
byProduct: IAllocationAggRow[];
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const res = await Get<IPurchaseOrder>('/purchase/order', id);
|
||||
const purchase = res.data.data ?? null;
|
||||
setDetail(purchase);
|
||||
const editingMap: Record<number, EditingItem> = {};
|
||||
purchase?.items?.forEach((item) => {
|
||||
if (item.id !== undefined) {
|
||||
editingMap[item.id] = {
|
||||
price: Number(item.price ?? 0),
|
||||
quantity: Number(item.quantity ?? 0),
|
||||
weight: Number(item.weight ?? 0),
|
||||
};
|
||||
}
|
||||
});
|
||||
setEditing(editingMap);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (id: number) => {
|
||||
setAllocation(null);
|
||||
setDetailOpen(true);
|
||||
await loadDetail(id);
|
||||
await loadAllocation(id);
|
||||
};
|
||||
|
||||
const loadAllocation = async (id: number) => {
|
||||
try {
|
||||
const res = await getAllocation(id);
|
||||
const data = res.data.data;
|
||||
if (data) {
|
||||
setAllocation({
|
||||
byStore: data.by_store ?? [],
|
||||
byProduct: data.by_product ?? [],
|
||||
total: data.total_amount ?? 0,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 未分摊时忽略
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async (values: { purchase_date: dayjs.Dayjs }) => {
|
||||
setGenerateLoading(true);
|
||||
try {
|
||||
const res = await generatePurchase(values.purchase_date.format('YYYY-MM-DD'));
|
||||
message.success(`采购单 ${res.data.data?.purchase_no} 已生成`);
|
||||
setGenerateOpen(false);
|
||||
await tableRef.current?.reload();
|
||||
await openDetail(res.data.data!.id);
|
||||
} finally {
|
||||
setGenerateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isItemDirty = (item: IPurchaseOrderItem): boolean => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
edit.price !== Number(item.price ?? 0) ||
|
||||
edit.quantity !== Number(item.quantity ?? 0) ||
|
||||
edit.weight !== Number(item.weight ?? 0)
|
||||
);
|
||||
};
|
||||
|
||||
const saveItem = async (item: IPurchaseOrderItem) => {
|
||||
const edit = editing[item.id!];
|
||||
if (!edit || !isItemDirty(item)) {
|
||||
return;
|
||||
}
|
||||
setSavingItemId(item.id!);
|
||||
try {
|
||||
const res = await updatePurchaseItem(item.id!, {
|
||||
price: edit.price,
|
||||
quantity: edit.quantity,
|
||||
weight: edit.weight,
|
||||
});
|
||||
message.success(`金额已重算:¥${res.data.data?.amount}`);
|
||||
await loadDetail(detail!.id!);
|
||||
} finally {
|
||||
setSavingItemId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (item: IPurchaseOrderItem) => {
|
||||
await sendPurchaseItem(item.id!);
|
||||
message.success('已发送给供应商');
|
||||
await loadDetail(detail!.id!);
|
||||
await tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleAllocate = async () => {
|
||||
setAllocating(true);
|
||||
try {
|
||||
const res = await allocatePurchase(detail!.id!);
|
||||
message.success(`分摊完成,共 ${res.data.data?.count} 条记录`);
|
||||
await loadAllocation(detail!.id!);
|
||||
} finally {
|
||||
setAllocating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemColumns: TableProps<IPurchaseOrderItem>['columns'] = [
|
||||
{ title: '序号', dataIndex: 'sort', width: 60, align: 'center' },
|
||||
{ title: '品名', dataIndex: 'product_name', width: 130 },
|
||||
{ title: '规格', dataIndex: 'product_spec', width: 110, render: (v) => v || '-' },
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'supplier',
|
||||
width: 130,
|
||||
render: (_, record) => record.supplier?.name ?? '-',
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
width: 130,
|
||||
render: (_, record) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
value={editing[record.id!]?.price}
|
||||
onChange={(v) =>
|
||||
setEditing((prev) => ({
|
||||
...prev,
|
||||
[record.id!]: { ...prev[record.id!], price: v ?? 0 },
|
||||
}))
|
||||
}
|
||||
className="!w-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<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: 130,
|
||||
render: (_, record) => (
|
||||
<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-24"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v) => <Text strong>¥{String(v)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '发送状态',
|
||||
dataIndex: 'is_sent',
|
||||
width: 150,
|
||||
render: (_, record) =>
|
||||
record.is_sent === 1 ? (
|
||||
<Tag color="success">
|
||||
已发送
|
||||
{record.sent_at ? ` ${record.sent_at}` : ''}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>未发送</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right',
|
||||
render: (_, record) => (
|
||||
<Space size={4}>
|
||||
<AuthButton auth="purchase.order.update">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={!isItemDirty(record)}
|
||||
loading={savingItemId === record.id}
|
||||
onClick={() => saveItem(record)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</AuthButton>
|
||||
{record.is_sent !== 1 ? (
|
||||
<AuthButton auth="purchase.order.send">
|
||||
<Popconfirm
|
||||
title="确认发送该明细给供应商?"
|
||||
onConfirm={() => handleSend(record)}
|
||||
>
|
||||
<Button size="small" type="link" icon={<SendOutlined />}>
|
||||
发送
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const aggColumns = (nameTitle: string): TableProps<IAllocationAggRow>['columns'] => [
|
||||
{
|
||||
title: nameTitle,
|
||||
key: 'name',
|
||||
render: (_, row) => row.store_name ?? row.product_name ?? '-',
|
||||
},
|
||||
{ title: '数量', dataIndex: 'quantity', align: 'right' },
|
||||
{ title: '重量', dataIndex: 'weight', align: 'right' },
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
align: 'right',
|
||||
render: (v) => `¥${v}`,
|
||||
},
|
||||
];
|
||||
|
||||
const columns: XinTableColumn<IPurchaseOrder>[] = [
|
||||
{
|
||||
title: '采购单号',
|
||||
dataIndex: 'purchase_no',
|
||||
valueType: 'text',
|
||||
hideInForm: true,
|
||||
render: (_, record) => <Text copyable={{ text: record.purchase_no }}>{record.purchase_no}</Text>,
|
||||
},
|
||||
{
|
||||
title: '采购日期',
|
||||
dataIndex: 'purchase_date',
|
||||
valueType: 'dateRange',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
render: (_, record) => record.purchase_date,
|
||||
},
|
||||
{
|
||||
title: '预估金额',
|
||||
dataIndex: 'estimate_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) => `¥${record.estimate_amount}`,
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'right',
|
||||
render: (_, record) =>
|
||||
Number(record.actual_amount) > 0 ? (
|
||||
<Text strong>¥{record.actual_amount}</Text>
|
||||
) : (
|
||||
<Text type="secondary">未录入</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
hideInForm: true,
|
||||
fieldProps: {
|
||||
options: Object.entries(PURCHASE_STATUS_MAP).map(([value, item]) => ({
|
||||
value: Number(value),
|
||||
label: item.text,
|
||||
})),
|
||||
},
|
||||
render: (_, record) => {
|
||||
const item = PURCHASE_STATUS_MAP[record.status ?? 0];
|
||||
return <Tag color={item?.color}>{item?.text}</Tag>;
|
||||
},
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '制单人',
|
||||
dataIndex: 'operator',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (_, record) => record.operator?.nickname ?? '-',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const operateRender: XinTableProps<IPurchaseOrder>['operateRender'] = (record) => [
|
||||
<Button key="detail" size="small" onClick={() => openDetail(record.id!)}>
|
||||
详情
|
||||
</Button>,
|
||||
<AuthButton key="export" auth="purchase.order.export">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'all-xlsx', label: '全品类 Excel', onClick: () => exportPurchase(record.id!, 'all', 'xlsx') },
|
||||
{ key: 'all-pdf', label: '全品类 PDF', onClick: () => exportPurchase(record.id!, 'all', 'pdf') },
|
||||
{ key: 'category-xlsx', label: '蔬果分类 Excel', onClick: () => exportPurchase(record.id!, 'category', 'xlsx') },
|
||||
{ key: 'category-pdf', label: '蔬果分类 PDF', onClick: () => exportPurchase(record.id!, 'category', 'pdf') },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<DownloadOutlined />} />
|
||||
</Dropdown>
|
||||
</AuthButton>,
|
||||
];
|
||||
|
||||
const tableProps: XinTableProps<IPurchaseOrder> = {
|
||||
api: '/purchase/order',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'purchase.order',
|
||||
tableRef,
|
||||
operateRender,
|
||||
formProps: false,
|
||||
actionBarRender: (dom) => [
|
||||
<AuthButton key="generate" auth="purchase.order.generate">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateOpen(true)}>
|
||||
生成采购单
|
||||
</Button>
|
||||
</AuthButton>,
|
||||
dom.search,
|
||||
dom.keywordSearch,
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>采购单</Title>
|
||||
<Text type="secondary">
|
||||
按门店订单汇总生成;录入实际称重/单价后执行金额分摊(按订货比例摊到各门店,尾差修正确保金额守恒)。
|
||||
</Text>
|
||||
</div>
|
||||
<XinTable<IPurchaseOrder> {...tableProps} />
|
||||
|
||||
{/* 生成采购单 */}
|
||||
<Modal
|
||||
title="生成采购单"
|
||||
open={generateOpen}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
onOk={() => generateForm.submit()}
|
||||
confirmLoading={generateLoading}
|
||||
okText="确认生成"
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="py-2 text-gray-500">
|
||||
将汇总所选日期全部「待汇总」订单,按商品聚合生成采购单(估算单价取最低等级价)。
|
||||
</div>
|
||||
<Form
|
||||
form={generateForm}
|
||||
layout="vertical"
|
||||
onFinish={handleGenerate}
|
||||
initialValues={{ purchase_date: dayjs() }}
|
||||
>
|
||||
<Form.Item
|
||||
label="采购日期"
|
||||
name="purchase_date"
|
||||
rules={[{ required: true, message: '请选择采购日期' }]}
|
||||
>
|
||||
<DatePicker className="w-full" allowClear={false} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 采购单详情 */}
|
||||
<Drawer
|
||||
title={detail ? `采购单 ${detail.purchase_no}` : '采购单详情'}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
width={1080}
|
||||
loading={detailLoading}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={3} size="small" bordered>
|
||||
<Descriptions.Item label="采购日期">{detail.purchase_date}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={PURCHASE_STATUS_MAP[detail.status ?? 0]?.color}>
|
||||
{PURCHASE_STATUS_MAP[detail.status ?? 0]?.text}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="制单人">
|
||||
{detail.operator?.nickname ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估金额">¥{detail.estimate_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际金额">¥{detail.actual_amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="总重量">{detail.total_weight}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
className="mt-4"
|
||||
items={[
|
||||
{
|
||||
key: 'items',
|
||||
label: `采购明细(${detail.items?.length ?? 0})`,
|
||||
children: (
|
||||
<>
|
||||
<div className="mb-2 text-gray-500">
|
||||
录入实际称重与单价后点击行内「保存」,金额由后端重算(称重>0 按 称重×单价,否则按 数量×单价)。
|
||||
</div>
|
||||
<Table<IPurchaseOrderItem>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={itemColumns}
|
||||
dataSource={detail.items ?? []}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'allocation',
|
||||
label: '金额分摊',
|
||||
children: (
|
||||
<>
|
||||
<Space className="mb-3">
|
||||
<AuthButton auth="purchase.order.allocate">
|
||||
<Popconfirm
|
||||
title="执行金额分摊?"
|
||||
description="按订货比例将实际金额摊到各门店单品,重复执行会先清空旧分摊记录。"
|
||||
onConfirm={handleAllocate}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SplitCellsOutlined />}
|
||||
loading={allocating}
|
||||
>
|
||||
执行分摊
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
{allocation ? (
|
||||
<Text type="secondary">
|
||||
分摊总额:¥{allocation.total}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{allocation && (allocation.byStore.length > 0 || allocation.byProduct.length > 0) ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Title level={5}>按门店</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.store_id)}
|
||||
size="small"
|
||||
columns={aggColumns('门店')}
|
||||
dataSource={allocation.byStore}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Title level={5}>按商品</Title>
|
||||
<Table<IAllocationAggRow>
|
||||
rowKey={(row) => String(row.product_id)}
|
||||
size="small"
|
||||
columns={aggColumns('商品')}
|
||||
dataSource={allocation.byProduct}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无分摊记录,请先录入实际金额后执行分摊" />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PurchaseOrderPage;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user