|
@@ -0,0 +1,443 @@
|
|
|
|
|
+import React, { useState } from 'react';
|
|
|
|
|
+import { Table, Button, Space, Input, Modal, Form, message, Select, DatePicker, InputNumber } from 'antd';
|
|
|
|
|
+import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
|
|
|
|
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
+import { hetongClient, clientClient } from '@/client/api';
|
|
|
|
|
+import type { InferResponseType } from 'hono/client';
|
|
|
|
|
+import dayjs from 'dayjs';
|
|
|
|
|
+
|
|
|
|
|
+// 定义类型
|
|
|
|
|
+type HetongItem = InferResponseType<typeof hetongClient.$get, 200>['data'][0];
|
|
|
|
|
+type HetongListResponse = InferResponseType<typeof hetongClient.$get, 200>;
|
|
|
|
|
+type ClientItem = InferResponseType<typeof clientClient.$get, 200>['data'][0];
|
|
|
|
|
+
|
|
|
|
|
+const Contracts: React.FC = () => {
|
|
|
|
|
+ const [form] = Form.useForm();
|
|
|
|
|
+ const [modalVisible, setModalVisible] = useState(false);
|
|
|
|
|
+ const [editingKey, setEditingKey] = useState<string | null>(null);
|
|
|
|
|
+ const [searchText, setSearchText] = useState('');
|
|
|
|
|
+ const [clients, setClients] = useState<ClientItem[]>([]);
|
|
|
|
|
+ const queryClient = useQueryClient();
|
|
|
|
|
+
|
|
|
|
|
+ // 获取客户列表
|
|
|
|
|
+ const { data: clientsData } = useQuery(
|
|
|
|
|
+ ['clients'],
|
|
|
|
|
+ async () => {
|
|
|
|
|
+ const response = await clientClient.$get({ query: { page: 1, pageSize: 1000 } });
|
|
|
|
|
+ if (response.status !== 200) throw new Error('Failed to fetch clients');
|
|
|
|
|
+ return response.json() as Promise<InferResponseType<typeof clientClient.$get, 200>>;
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ onSuccess: (result) => {
|
|
|
|
|
+ setClients(result.data);
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 获取合同列表数据
|
|
|
|
|
+ const fetchContracts = ({ page, pageSize }: { page: number; pageSize: number }): Promise<HetongListResponse> =>
|
|
|
|
|
+ async () => {
|
|
|
|
|
+ const response = await hetongClient.$get({ query: { page, pageSize, keyword: searchText } });
|
|
|
|
|
+ if (response.status !== 200) throw new Error('Failed to fetch contracts');
|
|
|
|
|
+ return response.json() as Promise<HetongListResponse>;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const { data, isLoading: loading, refetch } = useQuery(
|
|
|
|
|
+ ['contracts', pagination.current, pagination.pageSize, searchText],
|
|
|
|
|
+ () => fetchContracts({ page: pagination.current, pageSize: pagination.pageSize }),
|
|
|
|
|
+ {
|
|
|
|
|
+ onSuccess: (result: HetongListResponse) => {
|
|
|
|
|
+ setDataSource(result.data);
|
|
|
|
|
+ setPagination({
|
|
|
|
|
+ ...pagination,
|
|
|
|
|
+ total: result.pagination.total,
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const [dataSource, setDataSource] = useState<HetongItem[]>([]);
|
|
|
|
|
+ const [pagination, setPagination] = useState({
|
|
|
|
|
+ current: 1,
|
|
|
|
|
+ pageSize: 10,
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // 搜索
|
|
|
|
|
+ const handleSearch = () => {
|
|
|
|
|
+ setPagination({ ...pagination, current: 1 });
|
|
|
|
|
+ refetch();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 分页变化
|
|
|
|
|
+ const handleTableChange = (pagination: any) => {
|
|
|
|
|
+ setPagination(pagination);
|
|
|
|
|
+ refetch();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 显示添加/编辑弹窗
|
|
|
|
|
+ const showModal = (record?: HetongItem) => {
|
|
|
|
|
+ setModalVisible(true);
|
|
|
|
|
+ if (record) {
|
|
|
|
|
+ setEditingKey(record.id);
|
|
|
|
|
+ form.setFieldsValue({
|
|
|
|
|
+ id: record.id,
|
|
|
|
|
+ contractDate: record.contractDate ? dayjs(record.contractDate) : null,
|
|
|
|
|
+ userId: record.userId,
|
|
|
|
|
+ clientId: record.clientId,
|
|
|
|
|
+ projectId: record.projectId,
|
|
|
|
|
+ amount: record.amount,
|
|
|
|
|
+ type: record.type,
|
|
|
|
|
+ status: record.status,
|
|
|
|
|
+ startDate: record.startDate ? dayjs(record.startDate) : null,
|
|
|
|
|
+ endDate: record.endDate ? dayjs(record.endDate) : null,
|
|
|
|
|
+ description: record.description,
|
|
|
|
|
+ contractNumber: record.contractNumber,
|
|
|
|
|
+ currency: record.currency,
|
|
|
|
|
+ exchangeRate: record.exchangeRate,
|
|
|
|
|
+ foreignAmount: record.foreignAmount,
|
|
|
|
|
+ filePath: record.filePath,
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setEditingKey(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 关闭弹窗
|
|
|
|
|
+ const handleCancel = () => {
|
|
|
|
|
+ setModalVisible(false);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 创建合同记录
|
|
|
|
|
+ const createContract = useMutation(
|
|
|
|
|
+ async (data: any) => {
|
|
|
|
|
+ const response = await hetongClient.$post({ json: data });
|
|
|
|
|
+ if (!response.ok) throw new Error('Failed to create contract');
|
|
|
|
|
+ return response.json();
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ message.success('合同记录创建成功');
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
|
|
|
+ setModalVisible(false);
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => {
|
|
|
|
|
+ message.error('操作失败,请重试');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 更新合同记录
|
|
|
|
|
+ const updateContract = useMutation(
|
|
|
|
|
+ async ({ id, data }: { id: string; data: any }) => {
|
|
|
|
|
+ const response = await hetongClient[':id'].$put({ param: { id: parseInt(id, 10) }, json: data });
|
|
|
|
|
+ if (!response.ok) throw new Error('Failed to update contract');
|
|
|
|
|
+ return response.json();
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ message.success('合同记录更新成功');
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
|
|
|
+ setModalVisible(false);
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => {
|
|
|
|
|
+ message.error('操作失败,请重试');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 删除合同记录
|
|
|
|
|
+ const deleteContract = useMutation(
|
|
|
|
|
+ async (id: string) => {
|
|
|
|
|
+ const response = await hetongClient[':id'].$delete({ param: { id: parseInt(id, 10) } });
|
|
|
|
|
+ if (!response.ok) throw new Error('Failed to delete contract');
|
|
|
|
|
+ return response.json();
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ onSuccess: () => {
|
|
|
|
|
+ message.success('合同记录删除成功');
|
|
|
|
|
+ queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
|
|
|
|
+ },
|
|
|
|
|
+ onError: () => {
|
|
|
|
|
+ message.error('删除失败,请重试');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 提交表单
|
|
|
|
|
+ const handleSubmit = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const values = await form.validateFields();
|
|
|
|
|
+
|
|
|
|
|
+ // 处理日期字段
|
|
|
|
|
+ if (values.contractDate) values.contractDate = values.contractDate.format('YYYY-MM-DD');
|
|
|
|
|
+ if (values.startDate) values.startDate = values.startDate.format('YYYY-MM-DD');
|
|
|
|
|
+ if (values.endDate) values.endDate = values.endDate.format('YYYY-MM-DD');
|
|
|
|
|
+
|
|
|
|
|
+ if (editingKey) {
|
|
|
|
|
+ // 更新操作
|
|
|
|
|
+ await updateContract.mutateAsync({ id: editingKey, data: values });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 创建操作
|
|
|
|
|
+ await createContract.mutateAsync(values);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ message.error('操作失败,请重试');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 表格列定义
|
|
|
|
|
+ const columns = [
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '合同ID',
|
|
|
|
|
+ dataIndex: 'id',
|
|
|
|
|
+ key: 'id',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '合同编号',
|
|
|
|
|
+ dataIndex: 'contractNumber',
|
|
|
|
|
+ key: 'contractNumber',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '客户',
|
|
|
|
|
+ dataIndex: 'clientId',
|
|
|
|
|
+ key: 'clientId',
|
|
|
|
|
+ render: (clientId: string) => {
|
|
|
|
|
+ const client = clients.find(c => c.id.toString() === clientId);
|
|
|
|
|
+ return client ? client.companyName : '-';
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '合同类型',
|
|
|
|
|
+ dataIndex: 'type',
|
|
|
|
|
+ key: 'type',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '合同金额',
|
|
|
|
|
+ dataIndex: 'amount',
|
|
|
|
|
+ key: 'amount',
|
|
|
|
|
+ render: (amount: number, record: HetongItem) =>
|
|
|
|
|
+ `${record.currency || 'CNY'} ${amount.toFixed(2)}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '状态',
|
|
|
|
|
+ dataIndex: 'status',
|
|
|
|
|
+ key: 'status',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '有效期',
|
|
|
|
|
+ key: 'dateRange',
|
|
|
|
|
+ render: (_: any, record: HetongItem) =>
|
|
|
|
|
+ `${record.startDate ? new Date(record.startDate).toLocaleDateString() : '-'} ~ ${record.endDate ? new Date(record.endDate).toLocaleDateString() : '-'}`
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ title: '操作',
|
|
|
|
|
+ key: 'action',
|
|
|
|
|
+ render: (_: any, record: HetongItem) => (
|
|
|
|
|
+ <Space size="middle">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ icon={<EditOutlined />}
|
|
|
|
|
+ onClick={() => showModal(record)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 编辑
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ danger
|
|
|
|
|
+ icon={<DeleteOutlined />}
|
|
|
|
|
+ onClick={() => deleteContract.mutate(record.id.toString())}
|
|
|
|
|
+ >
|
|
|
|
|
+ 删除
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </Space>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ];
|
|
|
|
|
+
|
|
|
|
|
+ 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>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="mb-4">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ placeholder="搜索合同编号或客户名称"
|
|
|
|
|
+ prefix={<SearchOutlined />}
|
|
|
|
|
+ value={searchText}
|
|
|
|
|
+ onChange={(e) => setSearchText(e.target.value)}
|
|
|
|
|
+ onPressEnter={handleSearch}
|
|
|
|
|
+ style={{ width: 300 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button type="default" onClick={handleSearch} style={{ marginLeft: 8 }}>
|
|
|
|
|
+ 搜索
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Table
|
|
|
|
|
+ columns={columns}
|
|
|
|
|
+ dataSource={dataSource}
|
|
|
|
|
+ rowKey="id"
|
|
|
|
|
+ loading={loading}
|
|
|
|
|
+ pagination={pagination}
|
|
|
|
|
+ onChange={handleTableChange}
|
|
|
|
|
+ bordered
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <Modal
|
|
|
|
|
+ title={editingKey ? "编辑合同记录" : "添加合同记录"}
|
|
|
|
|
+ open={modalVisible}
|
|
|
|
|
+ onCancel={handleCancel}
|
|
|
|
|
+ footer={[
|
|
|
|
|
+ <Button key="cancel" onClick={handleCancel}>
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </Button>,
|
|
|
|
|
+ <Button key="submit" type="primary" onClick={handleSubmit}>
|
|
|
|
|
+ 确定
|
|
|
|
|
+ </Button>,
|
|
|
|
|
+ ]}
|
|
|
|
|
+ width={800}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Form form={form} layout="vertical">
|
|
|
|
|
+ {!editingKey && (
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="id"
|
|
|
|
|
+ label="合同ID"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入合同ID' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入合同ID" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="contractNumber"
|
|
|
|
|
+ label="合同编号"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入合同编号' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入合同编号" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="contractDate"
|
|
|
|
|
+ label="合同日期"
|
|
|
|
|
+ rules={[{ required: true, message: '请选择合同日期' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <DatePicker format="YYYY-MM-DD" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="clientId"
|
|
|
|
|
+ label="客户"
|
|
|
|
|
+ rules={[{ required: true, message: '请选择客户' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Select placeholder="请选择客户">
|
|
|
|
|
+ {clients.map(client => (
|
|
|
|
|
+ <Select.Option key={client.id} value={client.id.toString()}>
|
|
|
|
|
+ {client.companyName}
|
|
|
|
|
+ </Select.Option>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </Select>
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="type"
|
|
|
|
|
+ label="合同类型"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入合同类型' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入合同类型" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="amount"
|
|
|
|
|
+ label="合同金额"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入合同金额' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ style={{ width: '100%' }}
|
|
|
|
|
+ formatter={value => `¥ ${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
|
|
|
|
+ parser={value => value!.replace(/\¥\s?|(,*)/g, '')}
|
|
|
|
|
+ placeholder="请输入合同金额"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="currency"
|
|
|
|
|
+ label="货币类型"
|
|
|
|
|
+ initialValue="CNY"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入货币类型' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入货币类型" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="startDate"
|
|
|
|
|
+ label="开始日期"
|
|
|
|
|
+ rules={[{ required: true, message: '请选择开始日期' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <DatePicker format="YYYY-MM-DD" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="endDate"
|
|
|
|
|
+ label="结束日期"
|
|
|
|
|
+ rules={[{ required: true, message: '请选择结束日期' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <DatePicker format="YYYY-MM-DD" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="status"
|
|
|
|
|
+ label="合同状态"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入合同状态' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入合同状态:如生效中、已结束等" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="userId"
|
|
|
|
|
+ label="关联用户ID"
|
|
|
|
|
+ rules={[{ required: true, message: '请输入关联用户ID' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入关联用户ID" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="description"
|
|
|
|
|
+ label="合同描述"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input.TextArea rows={4} placeholder="请输入合同描述" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="filePath"
|
|
|
|
|
+ label="合同文件路径"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input placeholder="请输入合同文件路径" prefix={<FileTextOutlined />} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </Modal>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+export default Contracts;
|