|
|
@@ -1,15 +1,23 @@
|
|
|
-import React, { useState } from 'react';
|
|
|
-import { Table, Button, Space, Input, Modal, Form, message, Select, DatePicker } from 'antd';
|
|
|
-import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons';
|
|
|
+import React, { useState, useEffect } from 'react';
|
|
|
+import { Table, Button, Space, Input, Modal, Form, message, Select, DatePicker, Card, Typography, Layout, Spin, Tag } from 'antd';
|
|
|
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, FilterOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
-import type { InferResponseType } from 'hono/client';
|
|
|
+import type { InferResponseType, InferRequestType } from 'hono/client';
|
|
|
import { expenseClient, clientClient } from '@/client/api';
|
|
|
-import dayjs from 'dayjs';
|
|
|
+import dayjs, { Dayjs } from 'dayjs';
|
|
|
+import { App } from 'antd';
|
|
|
+import { formatCurrency, formatDate } from '@/client/utils/utils';
|
|
|
+import { errorLogger, apiLogger } from '@/client/utils/logger';
|
|
|
|
|
|
// 定义类型
|
|
|
type ExpenseItem = InferResponseType<typeof expenseClient.$get, 200>['data'][0];
|
|
|
type ExpenseListResponse = InferResponseType<typeof expenseClient.$get, 200>;
|
|
|
type ClientItem = InferResponseType<typeof clientClient.$get, 200>['data'][0];
|
|
|
+type CreateExpenseRequest = InferRequestType<typeof expenseClient.$post>['json'];
|
|
|
+type UpdateExpenseRequest = InferRequestType<typeof expenseClient[':id']['$put']>['json'];
|
|
|
+
|
|
|
+const { Title } = Typography;
|
|
|
+const { Content } = Layout;
|
|
|
|
|
|
const Expenses: React.FC = () => {
|
|
|
const [form] = Form.useForm();
|
|
|
@@ -17,101 +25,144 @@ const Expenses: React.FC = () => {
|
|
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
|
const [searchText, setSearchText] = useState('');
|
|
|
const [clients, setClients] = useState<ClientItem[]>([]);
|
|
|
+ const [dataSource, setDataSource] = useState<ExpenseItem[]>([]);
|
|
|
+ const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 });
|
|
|
+ const [filters, setFilters] = useState({
|
|
|
+ clientId: undefined as string | undefined,
|
|
|
+ type: undefined as string | undefined,
|
|
|
+ status: undefined as string | undefined,
|
|
|
+ dateRange: undefined as [Dayjs | null, Dayjs | null] | undefined,
|
|
|
+ });
|
|
|
+ const [filterVisible, setFilterVisible] = useState(false);
|
|
|
|
|
|
- // 获取客户列表
|
|
|
+ const { message: antdMessage } = App.useApp();
|
|
|
const queryClient = useQueryClient();
|
|
|
-
|
|
|
+
|
|
|
// 获取客户列表
|
|
|
- const { data: clientsData } = useQuery({
|
|
|
+ const { data: clientsData, isLoading: isClientsLoading } = useQuery({
|
|
|
queryKey: ['clients'],
|
|
|
- queryFn: () => clientClient.$get({ query: { page: 1, pageSize: 1000 } }) as Promise<InferResponseType<typeof clientClient.$get, 200>>,
|
|
|
+ queryFn: async () => {
|
|
|
+ apiLogger('Fetching clients list');
|
|
|
+ const response = await clientClient.$get({ query: { page: 1, pageSize: 1000 } }) as Promise<InferResponseType<typeof clientClient.$get, 200>>;
|
|
|
+ apiLogger(`Fetched ${response.data.length} clients`);
|
|
|
+ return response;
|
|
|
+ },
|
|
|
onSuccess: (result) => {
|
|
|
setClients(result.data);
|
|
|
},
|
|
|
+ onError: (error) => {
|
|
|
+ errorLogger('Failed to fetch clients:', error);
|
|
|
+ antdMessage.error('获取客户列表失败');
|
|
|
+ },
|
|
|
});
|
|
|
-
|
|
|
- // 获取费用列表数据
|
|
|
+
|
|
|
// 获取费用列表数据
|
|
|
- const fetchExpenses = ({ page, pageSize }: { page: number; pageSize: number }): Promise<ExpenseListResponse> =>
|
|
|
- expenseClient.$get({ query: { page, pageSize, keyword: searchText } });
|
|
|
-
|
|
|
- const { data, isLoading: loading, refetch } = useQuery({
|
|
|
- queryKey: ['expenses', pagination.current, pagination.pageSize, searchText],
|
|
|
- queryFn: () => fetchExpenses({ page: pagination.current, pageSize: pagination.pageSize }) as Promise<ExpenseListResponse>,
|
|
|
+ const fetchExpenses = async ({ page, pageSize }: { page: number; pageSize: number }): Promise<ExpenseListResponse> => {
|
|
|
+ apiLogger(`Fetching expenses with parameters: page=${page}, pageSize=${pageSize}, keyword=${searchText}`);
|
|
|
+
|
|
|
+ const queryParams: Record<string, any> = { page, pageSize };
|
|
|
+
|
|
|
+ if (searchText) queryParams.keyword = searchText;
|
|
|
+ if (filters.clientId) queryParams.clientId = filters.clientId;
|
|
|
+ if (filters.type) queryParams.type = filters.type;
|
|
|
+ if (filters.status) queryParams.status = filters.status;
|
|
|
+ if (filters.dateRange?.[0]) queryParams.startDate = filters.dateRange[0].format('YYYY-MM-DD');
|
|
|
+ if (filters.dateRange?.[1]) queryParams.endDate = filters.dateRange[1].format('YYYY-MM-DD');
|
|
|
+
|
|
|
+ const response = await expenseClient.$get({ query: queryParams }) as Promise<ExpenseListResponse>;
|
|
|
+ apiLogger(`Fetched ${response.data.length} expenses, total: ${response.pagination.total}`);
|
|
|
+ return response;
|
|
|
+ };
|
|
|
+
|
|
|
+ const { data, isLoading: isExpensesLoading, refetch } = useQuery({
|
|
|
+ queryKey: ['expenses', pagination.current, pagination.pageSize, searchText, filters],
|
|
|
+ queryFn: () => fetchExpenses({ page: pagination.current, pageSize: pagination.pageSize }),
|
|
|
onSuccess: (result) => {
|
|
|
setDataSource(result.data);
|
|
|
- setPagination({
|
|
|
- ...pagination,
|
|
|
+ setPagination(prev => ({
|
|
|
+ ...prev,
|
|
|
total: result.pagination.total,
|
|
|
- });
|
|
|
+ }));
|
|
|
+ },
|
|
|
+ onError: (error) => {
|
|
|
+ errorLogger('Failed to fetch expenses:', error);
|
|
|
+ antdMessage.error('获取费用列表失败');
|
|
|
},
|
|
|
});
|
|
|
-
|
|
|
+
|
|
|
// 创建费用记录
|
|
|
const createExpense = useMutation(
|
|
|
- (data: any) => expenseClient.$post({ json: data }),
|
|
|
+ (data: CreateExpenseRequest) => expenseClient.$post({ json: data }),
|
|
|
{
|
|
|
onSuccess: () => {
|
|
|
- message.success('费用记录创建成功');
|
|
|
+ apiLogger('Expense created successfully');
|
|
|
+ antdMessage.success('费用记录创建成功');
|
|
|
queryClient.invalidateQueries(['expenses']);
|
|
|
},
|
|
|
- onError: () => {
|
|
|
- message.error('操作失败,请重试');
|
|
|
+ onError: (error) => {
|
|
|
+ errorLogger('Failed to create expense:', error);
|
|
|
+ antdMessage.error('创建费用记录失败');
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
-
|
|
|
+
|
|
|
// 更新费用记录
|
|
|
const updateExpense = useMutation(
|
|
|
- ({ id, data }: { id: string; data: any }) => expenseClient[':id'].$put({ param: { id }, json: data }),
|
|
|
+ ({ id, data }: { id: string; data: UpdateExpenseRequest }) => expenseClient[':id'].$put({ param: { id }, json: data }),
|
|
|
{
|
|
|
onSuccess: () => {
|
|
|
- message.success('费用记录更新成功');
|
|
|
+ apiLogger('Expense updated successfully');
|
|
|
+ antdMessage.success('费用记录更新成功');
|
|
|
queryClient.invalidateQueries(['expenses']);
|
|
|
},
|
|
|
- onError: () => {
|
|
|
- message.error('操作失败,请重试');
|
|
|
+ onError: (error) => {
|
|
|
+ errorLogger('Failed to update expense:', error);
|
|
|
+ antdMessage.error('更新费用记录失败');
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
-
|
|
|
+
|
|
|
// 删除费用记录
|
|
|
const deleteExpense = useMutation(
|
|
|
(id: string) => expenseClient[':id'].$delete({ param: { id } }),
|
|
|
{
|
|
|
onSuccess: () => {
|
|
|
- message.success('费用记录删除成功');
|
|
|
+ apiLogger('Expense deleted successfully');
|
|
|
+ antdMessage.success('费用记录删除成功');
|
|
|
queryClient.invalidateQueries(['expenses']);
|
|
|
},
|
|
|
- onError: () => {
|
|
|
- message.error('删除失败,请重试');
|
|
|
+ onError: (error) => {
|
|
|
+ errorLogger('Failed to delete expense:', error);
|
|
|
+ antdMessage.error('删除费用记录失败');
|
|
|
}
|
|
|
}
|
|
|
);
|
|
|
-
|
|
|
- // 初始化获取客户数据
|
|
|
- React.useEffect(() => {
|
|
|
- fetchClients();
|
|
|
- }, [fetchClients]);
|
|
|
-
|
|
|
- const [dataSource, setDataSource] = useState<ExpenseItem[]>([]);
|
|
|
- const [pagination, setPagination] = useState({
|
|
|
- current: 1,
|
|
|
- pageSize: 10,
|
|
|
- total: 0,
|
|
|
- });
|
|
|
-
|
|
|
+
|
|
|
// 搜索
|
|
|
const handleSearch = () => {
|
|
|
- run({ page: 1, pageSize: pagination.pageSize });
|
|
|
+ setPagination(prev => ({ ...prev, current: 1 }));
|
|
|
+ refetch();
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
+ // 重置筛选器
|
|
|
+ const resetFilters = () => {
|
|
|
+ setFilters({
|
|
|
+ clientId: undefined,
|
|
|
+ type: undefined,
|
|
|
+ status: undefined,
|
|
|
+ dateRange: undefined,
|
|
|
+ });
|
|
|
+ setSearchText('');
|
|
|
+ setPagination(prev => ({ ...prev, current: 1 }));
|
|
|
+ refetch();
|
|
|
+ };
|
|
|
+
|
|
|
// 分页变化
|
|
|
const handleTableChange = (pagination: any) => {
|
|
|
setPagination(pagination);
|
|
|
- run({ page: pagination.current, pageSize: pagination.pageSize });
|
|
|
+ refetch();
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
// 显示添加/编辑弹窗
|
|
|
const showModal = (record?: ExpenseItem) => {
|
|
|
setModalVisible(true);
|
|
|
@@ -123,8 +174,7 @@ const Expenses: React.FC = () => {
|
|
|
userId: record.userId,
|
|
|
type: record.type,
|
|
|
amount: record.amount,
|
|
|
- clientId: record.clientId,
|
|
|
- projectId: record.projectId,
|
|
|
+ clientId: record.clientId?.toString(),
|
|
|
department: record.department,
|
|
|
description: record.description,
|
|
|
status: record.status,
|
|
|
@@ -133,8 +183,8 @@ const Expenses: React.FC = () => {
|
|
|
reimbursementDate: record.reimbursementDate ? dayjs(record.reimbursementDate) : null,
|
|
|
paymentMethod: record.paymentMethod,
|
|
|
invoiceNumber: record.invoiceNumber,
|
|
|
- currency: record.currency,
|
|
|
- exchangeRate: record.exchangeRate,
|
|
|
+ currency: record.currency || 'CNY',
|
|
|
+ exchangeRate: record.exchangeRate || 1,
|
|
|
foreignAmount: record.foreignAmount ? parseFloat(record.foreignAmount) : undefined,
|
|
|
});
|
|
|
} else {
|
|
|
@@ -142,114 +192,180 @@ const Expenses: React.FC = () => {
|
|
|
form.resetFields();
|
|
|
}
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
// 关闭弹窗
|
|
|
const handleCancel = () => {
|
|
|
setModalVisible(false);
|
|
|
form.resetFields();
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
// 提交表单
|
|
|
const handleSubmit = async () => {
|
|
|
try {
|
|
|
const values = await form.validateFields();
|
|
|
|
|
|
// 处理日期字段
|
|
|
- if (values.expenseDate) values.expenseDate = values.expenseDate.format('YYYY-MM-DD');
|
|
|
- if (values.approveDate) values.approveDate = values.approveDate.format('YYYY-MM-DD');
|
|
|
- if (values.reimbursementDate) values.reimbursementDate = values.reimbursementDate.format('YYYY-MM-DD');
|
|
|
+ const formattedValues = { ...values };
|
|
|
+ if (formattedValues.expenseDate) formattedValues.expenseDate = formattedValues.expenseDate.format('YYYY-MM-DD');
|
|
|
+ if (formattedValues.approveDate) formattedValues.approveDate = formattedValues.approveDate.format('YYYY-MM-DD');
|
|
|
+ if (formattedValues.reimbursementDate) formattedValues.reimbursementDate = formattedValues.reimbursementDate.format('YYYY-MM-DD');
|
|
|
|
|
|
if (editingKey) {
|
|
|
// 更新操作
|
|
|
- await expenseClient[':id'].$put({
|
|
|
- param: { id: editingKey },
|
|
|
- json: values,
|
|
|
- });
|
|
|
- message.success('费用记录更新成功');
|
|
|
+ await updateExpense.mutateAsync({ id: editingKey, data: formattedValues });
|
|
|
} else {
|
|
|
// 创建操作
|
|
|
- await expenseClient.$post({ json: values });
|
|
|
- message.success('费用记录创建成功');
|
|
|
+ await createExpense.mutateAsync(formattedValues);
|
|
|
}
|
|
|
|
|
|
setModalVisible(false);
|
|
|
- run({ page: pagination.current, pageSize: pagination.pageSize });
|
|
|
} catch (error) {
|
|
|
- message.error('操作失败,请重试');
|
|
|
+ errorLogger('Form submission failed:', error);
|
|
|
+ antdMessage.error('表单提交失败,请检查输入');
|
|
|
}
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
// 删除操作
|
|
|
const handleDelete = async (id: string) => {
|
|
|
try {
|
|
|
- await expenseClient[':id'].$delete({ param: { id } });
|
|
|
- message.success('费用记录删除成功');
|
|
|
- run({ page: pagination.current, pageSize: pagination.pageSize });
|
|
|
+ Modal.confirm({
|
|
|
+ title: '确认删除',
|
|
|
+ content: '确定要删除这条费用记录吗?此操作不可撤销。',
|
|
|
+ okText: '确认',
|
|
|
+ cancelText: '取消',
|
|
|
+ onOk: async () => {
|
|
|
+ await deleteExpense.mutateAsync(id);
|
|
|
+ },
|
|
|
+ });
|
|
|
} catch (error) {
|
|
|
- message.error('删除失败,请重试');
|
|
|
+ errorLogger('Delete operation failed:', error);
|
|
|
+ antdMessage.error('删除操作失败');
|
|
|
}
|
|
|
};
|
|
|
-
|
|
|
+
|
|
|
+ // 导出数据
|
|
|
+ const handleExport = () => {
|
|
|
+ apiLogger('Exporting expense data');
|
|
|
+ antdMessage.info('正在导出数据...');
|
|
|
+ // 实际实现应调用后端导出API
|
|
|
+ };
|
|
|
+
|
|
|
+ // 状态标签样式
|
|
|
+ const renderStatusTag = (status: string) => {
|
|
|
+ let color = 'default';
|
|
|
+ switch (status) {
|
|
|
+ case '待审批':
|
|
|
+ color = 'processing';
|
|
|
+ break;
|
|
|
+ case '已审批':
|
|
|
+ color = 'success';
|
|
|
+ break;
|
|
|
+ case '已报销':
|
|
|
+ color = 'blue';
|
|
|
+ break;
|
|
|
+ case '已拒绝':
|
|
|
+ color = 'error';
|
|
|
+ break;
|
|
|
+ default:
|
|
|
+ color = 'default';
|
|
|
+ }
|
|
|
+ return <Tag color={color}>{status}</Tag>;
|
|
|
+ };
|
|
|
+
|
|
|
// 表格列定义
|
|
|
const columns = [
|
|
|
{
|
|
|
title: '费用ID',
|
|
|
dataIndex: 'id',
|
|
|
key: 'id',
|
|
|
+ width: 80,
|
|
|
+ sorter: true,
|
|
|
},
|
|
|
{
|
|
|
title: '费用日期',
|
|
|
dataIndex: 'expenseDate',
|
|
|
key: 'expenseDate',
|
|
|
- render: (date: string) => date ? new Date(date).toLocaleDateString() : '-',
|
|
|
+ width: 120,
|
|
|
+ render: (date: string) => formatDate(date),
|
|
|
+ sorter: (a: ExpenseItem, b: ExpenseItem) => new Date(a.expenseDate || '').getTime() - new Date(b.expenseDate || '').getTime(),
|
|
|
},
|
|
|
{
|
|
|
title: '费用类型',
|
|
|
dataIndex: 'type',
|
|
|
key: 'type',
|
|
|
+ width: 120,
|
|
|
+ filters: [
|
|
|
+ { text: '差旅费', value: '差旅费' },
|
|
|
+ { text: '招待费', value: '招待费' },
|
|
|
+ { text: '办公费', value: '办公费' },
|
|
|
+ { text: '交通费', value: '交通费' },
|
|
|
+ { text: '其他', value: '其他' },
|
|
|
+ ],
|
|
|
+ onFilter: (value: string, record: ExpenseItem) => record.type === value,
|
|
|
},
|
|
|
{
|
|
|
title: '金额',
|
|
|
dataIndex: 'amount',
|
|
|
key: 'amount',
|
|
|
- render: (amount: number) => `¥${amount.toFixed(2)}`,
|
|
|
+ width: 120,
|
|
|
+ render: (amount: number) => formatCurrency(amount),
|
|
|
+ sorter: (a: ExpenseItem, b: ExpenseItem) => a.amount - b.amount,
|
|
|
},
|
|
|
{
|
|
|
title: '客户',
|
|
|
dataIndex: 'clientId',
|
|
|
key: 'clientId',
|
|
|
+ width: 160,
|
|
|
render: (clientId: string) => {
|
|
|
const client = clients.find(c => c.id.toString() === clientId);
|
|
|
return client ? client.companyName : '-';
|
|
|
},
|
|
|
+ filters: clients.map(client => ({
|
|
|
+ text: client.companyName,
|
|
|
+ value: client.id.toString(),
|
|
|
+ })),
|
|
|
+ onFilter: (value: string, record: ExpenseItem) => record.clientId?.toString() === value,
|
|
|
},
|
|
|
{
|
|
|
title: '状态',
|
|
|
dataIndex: 'status',
|
|
|
key: 'status',
|
|
|
+ width: 100,
|
|
|
+ render: renderStatusTag,
|
|
|
+ filters: [
|
|
|
+ { text: '待审批', value: '待审批' },
|
|
|
+ { text: '已审批', value: '已审批' },
|
|
|
+ { text: '已报销', value: '已报销' },
|
|
|
+ { text: '已拒绝', value: '已拒绝' },
|
|
|
+ ],
|
|
|
+ onFilter: (value: string, record: ExpenseItem) => record.status === value,
|
|
|
},
|
|
|
{
|
|
|
- title: '操作用户',
|
|
|
- dataIndex: 'userId',
|
|
|
- key: 'userId',
|
|
|
+ title: '支付方式',
|
|
|
+ dataIndex: 'paymentMethod',
|
|
|
+ key: 'paymentMethod',
|
|
|
+ width: 120,
|
|
|
},
|
|
|
{
|
|
|
title: '操作',
|
|
|
key: 'action',
|
|
|
+ width: 160,
|
|
|
render: (_: any, record: ExpenseItem) => (
|
|
|
<Space size="middle">
|
|
|
<Button
|
|
|
- type="text"
|
|
|
+ type="link"
|
|
|
icon={<EditOutlined />}
|
|
|
onClick={() => showModal(record)}
|
|
|
+ disabled={!access.canEditExpense}
|
|
|
>
|
|
|
编辑
|
|
|
</Button>
|
|
|
<Button
|
|
|
- type="text"
|
|
|
+ type="link"
|
|
|
danger
|
|
|
icon={<DeleteOutlined />}
|
|
|
- onClick={() => handleDelete(record.id)}
|
|
|
+ onClick={() => handleDelete(record.id.toString())}
|
|
|
+ disabled={!access.canDeleteExpense}
|
|
|
>
|
|
|
删除
|
|
|
</Button>
|
|
|
@@ -257,44 +373,177 @@ const Expenses: React.FC = () => {
|
|
|
),
|
|
|
},
|
|
|
];
|
|
|
-
|
|
|
+
|
|
|
+ // 费用类型选项
|
|
|
+ const expenseTypeOptions = [
|
|
|
+ { label: '差旅费', value: '差旅费' },
|
|
|
+ { label: '招待费', value: '招待费' },
|
|
|
+ { label: '办公费', value: '办公费' },
|
|
|
+ { label: '交通费', value: '交通费' },
|
|
|
+ { label: '其他', value: '其他' },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 费用状态选项
|
|
|
+ const expenseStatusOptions = [
|
|
|
+ { label: '待审批', value: '待审批' },
|
|
|
+ { label: '已审批', value: '已审批' },
|
|
|
+ { label: '已报销', value: '已报销' },
|
|
|
+ { label: '已拒绝', value: '已拒绝' },
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 支付方式选项
|
|
|
+ const paymentMethodOptions = [
|
|
|
+ { label: '现金', value: '现金' },
|
|
|
+ { label: '银行卡', value: '银行卡' },
|
|
|
+ { label: '支付宝', value: '支付宝' },
|
|
|
+ { label: '微信', value: '微信' },
|
|
|
+ ];
|
|
|
+
|
|
|
return (
|
|
|
- <div className="p-4">
|
|
|
- <div className="flex justify-between items-center mb-4">
|
|
|
- <h2 className="text-xl font-bold">费用管理</h2>
|
|
|
- <Button
|
|
|
- type="primary"
|
|
|
- icon={<PlusOutlined />}
|
|
|
- onClick={() => showModal()}
|
|
|
- >
|
|
|
- 添加费用
|
|
|
- </Button>
|
|
|
+ <Content className="p-4">
|
|
|
+ <div className="flex justify-between items-center mb-6">
|
|
|
+ <Title level={2}>费用管理</Title>
|
|
|
+ <Space>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<PlusOutlined />}
|
|
|
+ onClick={() => showModal()}
|
|
|
+ >
|
|
|
+ 添加费用
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ icon={<DownloadOutlined />}
|
|
|
+ onClick={handleExport}
|
|
|
+ >
|
|
|
+ 导出
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ icon={<ReloadOutlined />}
|
|
|
+ onClick={refetch}
|
|
|
+ loading={isExpensesLoading}
|
|
|
+ >
|
|
|
+ 刷新
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
</div>
|
|
|
-
|
|
|
- <div className="mb-4">
|
|
|
- <Input
|
|
|
- placeholder="搜索费用类型或描述"
|
|
|
- prefix={<SearchOutlined />}
|
|
|
- value={searchText}
|
|
|
- onChange={(e) => setSearchText(e.target.value)}
|
|
|
- onPressEnter={handleSearch}
|
|
|
- style={{ width: 300 }}
|
|
|
+
|
|
|
+ <Card className="mb-6">
|
|
|
+ <div className="flex flex-col md:flex-row gap-4 mb-4">
|
|
|
+ <div className="flex-grow">
|
|
|
+ <Input
|
|
|
+ placeholder="搜索费用类型或描述"
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
+ value={searchText}
|
|
|
+ onChange={(e) => setSearchText(e.target.value)}
|
|
|
+ onPressEnter={handleSearch}
|
|
|
+ className="w-full md:w-2/3"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Space>
|
|
|
+ <Button type="default" onClick={handleSearch}>
|
|
|
+ 搜索
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ icon={<FilterOutlined />}
|
|
|
+ onClick={() => setFilterVisible(!filterVisible)}
|
|
|
+ >
|
|
|
+ 高级筛选
|
|
|
+ </Button>
|
|
|
+ <Button onClick={resetFilters}>
|
|
|
+ 重置
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {filterVisible && (
|
|
|
+ <div className="border-t pt-4 mt-4 grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
+ <Form.Item
|
|
|
+ label="客户"
|
|
|
+ name="clientId"
|
|
|
+ initialValue={filters.clientId}
|
|
|
+ onChange={([value]) => setFilters(prev => ({ ...prev, clientId: value }))}
|
|
|
+ >
|
|
|
+ <Select placeholder="请选择客户" style={{ width: '100%' }}>
|
|
|
+ {clients.map(client => (
|
|
|
+ <Select.Option key={client.id} value={client.id.toString()}>
|
|
|
+ {client.companyName}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
+ <Form.Item
|
|
|
+ label="费用类型"
|
|
|
+ name="type"
|
|
|
+ initialValue={filters.type}
|
|
|
+ onChange={([value]) => setFilters(prev => ({ ...prev, type: value }))}
|
|
|
+ >
|
|
|
+ <Select placeholder="请选择费用类型" style={{ width: '100%' }}>
|
|
|
+ {expenseTypeOptions.map(option => (
|
|
|
+ <Select.Option key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
+ <Form.Item
|
|
|
+ label="费用状态"
|
|
|
+ name="status"
|
|
|
+ initialValue={filters.status}
|
|
|
+ onChange={([value]) => setFilters(prev => ({ ...prev, status: value }))}
|
|
|
+ >
|
|
|
+ <Select placeholder="请选择费用状态" style={{ width: '100%' }}>
|
|
|
+ {expenseStatusOptions.map(option => (
|
|
|
+ <Select.Option key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
+ <Form.Item
|
|
|
+ label="日期范围"
|
|
|
+ name="dateRange"
|
|
|
+ initialValue={filters.dateRange}
|
|
|
+ onChange={([value]) => setFilters(prev => ({ ...prev, dateRange: value }))}
|
|
|
+ className="md:col-span-3"
|
|
|
+ >
|
|
|
+ <DatePicker.RangePicker
|
|
|
+ format="YYYY-MM-DD"
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ placeholder={['开始日期', '结束日期']}
|
|
|
+ />
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
+ <div className="md:col-span-3 flex justify-end">
|
|
|
+ <Button type="primary" onClick={handleSearch}>
|
|
|
+ 应用筛选
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ <Spin spinning={isExpensesLoading || isClientsLoading}>
|
|
|
+ <Table
|
|
|
+ columns={columns}
|
|
|
+ dataSource={dataSource}
|
|
|
+ rowKey="id"
|
|
|
+ pagination={{
|
|
|
+ ...pagination,
|
|
|
+ showSizeChanger: true,
|
|
|
+ showTotal: (total) => `共 ${total} 条记录`,
|
|
|
+ pageSizeOptions: ['10', '20', '50', '100'],
|
|
|
+ }}
|
|
|
+ onChange={handleTableChange}
|
|
|
+ bordered
|
|
|
+ size="middle"
|
|
|
+ scroll={{ x: 'max-content' }}
|
|
|
+ rowClassName={(record, index) => index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
|
|
/>
|
|
|
- <Button type="default" onClick={handleSearch} style={{ marginLeft: 8 }}>
|
|
|
- 搜索
|
|
|
- </Button>
|
|
|
- </div>
|
|
|
-
|
|
|
- <Table
|
|
|
- columns={columns}
|
|
|
- dataSource={dataSource}
|
|
|
- rowKey="id"
|
|
|
- loading={loading}
|
|
|
- pagination={pagination}
|
|
|
- onChange={handleTableChange}
|
|
|
- bordered
|
|
|
- />
|
|
|
-
|
|
|
+ </Spin>
|
|
|
+
|
|
|
<Modal
|
|
|
title={editingKey ? "编辑费用记录" : "添加费用记录"}
|
|
|
open={modalVisible}
|
|
|
@@ -308,50 +557,80 @@ const Expenses: React.FC = () => {
|
|
|
</Button>,
|
|
|
]}
|
|
|
width={800}
|
|
|
+ destroyOnClose
|
|
|
+ centered
|
|
|
+ maskClosable={false}
|
|
|
>
|
|
|
- <Form form={form} layout="vertical">
|
|
|
+ <Form
|
|
|
+ form={form}
|
|
|
+ layout="vertical"
|
|
|
+ validateMessages={{
|
|
|
+ required: '${label} 不能为空!',
|
|
|
+ types: {
|
|
|
+ number: '${label} 必须是数字!',
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ >
|
|
|
{!editingKey && (
|
|
|
<Form.Item
|
|
|
name="id"
|
|
|
label="费用记录ID"
|
|
|
rules={[{ required: true, message: '请输入费用记录ID' }]}
|
|
|
>
|
|
|
- <Input placeholder="请输入费用记录ID" />
|
|
|
+ <Input placeholder="请输入费用记录ID" disabled={editingKey !== null} />
|
|
|
</Form.Item>
|
|
|
)}
|
|
|
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
<Form.Item
|
|
|
name="expenseDate"
|
|
|
label="费用发生日期"
|
|
|
rules={[{ required: true, message: '请选择费用发生日期' }]}
|
|
|
>
|
|
|
- <DatePicker format="YYYY-MM-DD" />
|
|
|
+ <DatePicker
|
|
|
+ format="YYYY-MM-DD"
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ placeholder="请选择日期"
|
|
|
+ disabledDate={(current) => current && current > dayjs().endOf('day')}
|
|
|
+ />
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
name="type"
|
|
|
label="费用类型"
|
|
|
- rules={[{ required: true, message: '请输入费用类型' }]}
|
|
|
+ rules={[{ required: true, message: '请选择费用类型' }]}
|
|
|
>
|
|
|
- <Input placeholder="请输入费用类型" />
|
|
|
+ <Select placeholder="请选择费用类型" style={{ width: '100%' }}>
|
|
|
+ {expenseTypeOptions.map(option => (
|
|
|
+ <Select.Option key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
</Form.Item>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+
|
|
|
<Form.Item
|
|
|
name="amount"
|
|
|
label="费用金额"
|
|
|
- rules={[{ required: true, message: '请输入费用金额' }]}
|
|
|
+ rules={[
|
|
|
+ { required: true, message: '请输入费用金额' },
|
|
|
+ { type: 'number', message: '金额必须是数字' },
|
|
|
+ { min: 0.01, message: '金额必须大于0' }
|
|
|
+ ]}
|
|
|
>
|
|
|
- <Input type="number" placeholder="请输入费用金额" />
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ placeholder="请输入费用金额"
|
|
|
+ precision={2}
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ />
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
name="clientId"
|
|
|
label="关联客户"
|
|
|
>
|
|
|
- <Select placeholder="请选择客户">
|
|
|
+ <Select placeholder="请选择客户" style={{ width: '100%' }}>
|
|
|
{clients.map(client => (
|
|
|
<Select.Option key={client.id} value={client.id.toString()}>
|
|
|
{client.companyName}
|
|
|
@@ -359,73 +638,101 @@ const Expenses: React.FC = () => {
|
|
|
))}
|
|
|
</Select>
|
|
|
</Form.Item>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+
|
|
|
<Form.Item
|
|
|
name="status"
|
|
|
label="费用状态"
|
|
|
- rules={[{ required: true, message: '请输入费用状态' }]}
|
|
|
+ rules={[{ required: true, message: '请选择费用状态' }]}
|
|
|
>
|
|
|
- <Input placeholder="请输入费用状态:如审批中、已报销等" />
|
|
|
+ <Select placeholder="请选择费用状态" style={{ width: '100%' }}>
|
|
|
+ {expenseStatusOptions.map(option => (
|
|
|
+ <Select.Option key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
</Form.Item>
|
|
|
|
|
|
- <Form.Item
|
|
|
- name="userId"
|
|
|
- label="操作用户ID"
|
|
|
- rules={[{ required: true, message: '请输入操作用户ID' }]}
|
|
|
- >
|
|
|
- <Input placeholder="请输入操作用户ID" />
|
|
|
- </Form.Item>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
<Form.Item
|
|
|
name="paymentMethod"
|
|
|
label="支付方式"
|
|
|
+ rules={[{ required: true, message: '请选择支付方式' }]}
|
|
|
>
|
|
|
- <Input placeholder="请输入支付方式" />
|
|
|
+ <Select placeholder="请选择支付方式" style={{ width: '100%' }}>
|
|
|
+ {paymentMethodOptions.map(option => (
|
|
|
+ <Select.Option key={option.value} value={option.value}>
|
|
|
+ {option.label}
|
|
|
+ </Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
name="invoiceNumber"
|
|
|
label="发票号码"
|
|
|
>
|
|
|
- <Input placeholder="请输入发票号码" />
|
|
|
+ <Input placeholder="请输入发票号码" style={{ width: '100%' }} />
|
|
|
</Form.Item>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+
|
|
|
+ <Form.Item
|
|
|
+ name="userId"
|
|
|
+ label="操作用户ID"
|
|
|
+ rules={[{ required: true, message: '请输入操作用户ID' }]}
|
|
|
+ >
|
|
|
+ <Input placeholder="请输入操作用户ID" style={{ width: '100%' }} />
|
|
|
+ </Form.Item>
|
|
|
+
|
|
|
<Form.Item
|
|
|
name="currency"
|
|
|
label="货币类型"
|
|
|
initialValue="CNY"
|
|
|
>
|
|
|
- <Input placeholder="请输入货币类型" />
|
|
|
+ <Select placeholder="请选择货币类型" style={{ width: '100%' }}>
|
|
|
+ <Select.Option value="CNY">人民币 (CNY)</Select.Option>
|
|
|
+ <Select.Option value="USD">美元 (USD)</Select.Option>
|
|
|
+ <Select.Option value="EUR">欧元 (EUR)</Select.Option>
|
|
|
+ </Select>
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
name="exchangeRate"
|
|
|
label="汇率"
|
|
|
initialValue={1}
|
|
|
+ rules={[
|
|
|
+ { type: 'number', message: '汇率必须是数字' },
|
|
|
+ { min: 0.0001, message: '汇率必须大于0' }
|
|
|
+ ]}
|
|
|
>
|
|
|
- <Input type="number" step="0.0001" placeholder="请输入汇率" />
|
|
|
+ <Input
|
|
|
+ type="number"
|
|
|
+ step="0.0001"
|
|
|
+ placeholder="请输入汇率"
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ />
|
|
|
</Form.Item>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div className="grid grid-cols-2 gap-4">
|
|
|
+
|
|
|
<Form.Item
|
|
|
name="approveDate"
|
|
|
label="审批日期"
|
|
|
>
|
|
|
- <DatePicker format="YYYY-MM-DD" />
|
|
|
+ <DatePicker
|
|
|
+ format="YYYY-MM-DD"
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ placeholder="请选择审批日期"
|
|
|
+ disabledDate={(current) => current && current > dayjs().endOf('day')}
|
|
|
+ />
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item
|
|
|
name="reimbursementDate"
|
|
|
label="报销日期"
|
|
|
>
|
|
|
- <DatePicker format="YYYY-MM-DD" />
|
|
|
+ <DatePicker
|
|
|
+ format="YYYY-MM-DD"
|
|
|
+ style={{ width: '100%' }}
|
|
|
+ placeholder="请选择报销日期"
|
|
|
+ disabledDate={(current) => current && current > dayjs().endOf('day')}
|
|
|
+ />
|
|
|
</Form.Item>
|
|
|
</div>
|
|
|
|
|
|
@@ -437,8 +744,8 @@ const Expenses: React.FC = () => {
|
|
|
</Form.Item>
|
|
|
</Form>
|
|
|
</Modal>
|
|
|
- </div>
|
|
|
+ </Content>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
-export default Expenses;
|
|
|
+export default Expenses;
|