feat: 完善采购申请流程 - 添加审批、执行、列表筛选排序功能

This commit is contained in:
System Administrator
2026-03-28 00:34:32 +07:00
parent 841f19e3f8
commit d437580500
306 changed files with 42669 additions and 27143 deletions
@@ -0,0 +1,560 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Table, Tag, Modal, Form, Input, InputNumber, DatePicker, Select, message, Radio, Upload, Row, Col, Divider, Popconfirm } from 'antd';
import { PlusOutlined, DeleteOutlined, UploadOutlined, EditOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import { useAuthStore } from '../../store/authStore';
const { Title, Paragraph, Text } = Typography;
const { Option } = Select;
const { TextArea } = Input;
const CURRENCIES = [
{ value: 'CNY', label: '人民币', symbol: '¥' },
{ value: 'USD', label: '美元', symbol: '$' },
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
{ value: 'THB', label: '泰铢', symbol: '฿' },
];
interface PaymentNode {
id?: number;
node_name: string;
percentage: number;
node_amount: number;
trigger_condition: string;
}
interface UnitPriceItem {
id?: number;
item_name: string;
unit: string;
quantity: number;
unit_price: number;
total_price: number;
}
const ProjectsPage: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
const [projects, setProjects] = useState([]);
const [customers, setCustomers] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingProject, setEditingProject] = useState<any>(null);
const [form] = Form.useForm();
const navigate = useNavigate();
// 当前用户信息 - 使用zustand authStore
const { user: currentUser } = useAuthStore();
const isAdmin = currentUser?.role === 'admin';
// 表单监听值
const settlementType = Form.useWatch('settlement_type', form);
const contractAmount = Form.useWatch('contract_amount', form);
const currency = Form.useWatch('currency', form);
const contractDays = Form.useWatch('contract_days', form);
const startDate = Form.useWatch('start_date', form);
// 付款节点和单价列表
const [paymentNodes, setPaymentNodes] = useState<PaymentNode[]>([]);
const [unitPriceList, setUnitPriceList] = useState<UnitPriceItem[]>([]);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
useEffect(() => {
fetchProjects();
fetchCustomers();
fetchUsers();
}, []);
// 自动计算结束日期
useEffect(() => {
if (startDate && contractDays) {
const endDate = startDate.add(contractDays, 'day');
form.setFieldsValue({ expected_end_date: endDate });
}
}, [startDate, contractDays, form]);
const fetchProjects = async () => {
setLoading(true);
try {
const res = await axios.get('/api/projects');
if (res.data.success) setProjects(res.data.data);
} catch (error) {
console.error('获取项目失败:', error);
} finally {
setLoading(false);
}
};
const fetchCustomers = async () => {
try {
const res = await axios.get('/api/customers');
if (res.data.success) setCustomers(res.data.data);
} catch (error) {
console.error('获取客户列表失败:', error);
}
};
const fetchUsers = async () => {
try {
const res = await axios.get('/api/users');
if (res.data.success) setUsers(res.data.data);
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
// 添加付款节点
const addPaymentNode = () => {
setPaymentNodes([...paymentNodes, {
node_name: '',
percentage: 0,
node_amount: 0,
trigger_condition: ''
}]);
};
// 删除付款节点
const removePaymentNode = (index: number) => {
const newNodes = paymentNodes.filter((_, i) => i !== index);
setPaymentNodes(newNodes);
};
// 更新付款节点
const updatePaymentNode = (index: number, field: string, value: any) => {
const newNodes = [...paymentNodes];
newNodes[index] = { ...newNodes[index], [field]: value };
if (field === 'percentage' && contractAmount) {
newNodes[index].node_amount = contractAmount * value / 100;
}
setPaymentNodes(newNodes);
};
// 添加单价项
const addUnitPriceItem = () => {
setUnitPriceList([...unitPriceList, {
item_name: '',
unit: '',
quantity: 0,
unit_price: 0,
total_price: 0
}]);
};
// 删除单价项
const removeUnitPriceItem = (index: number) => {
const newItems = unitPriceList.filter((_, i) => i !== index);
setUnitPriceList(newItems);
};
// 更新单价项
const updateUnitPriceItem = (index: number, field: string, value: any) => {
const newItems = [...unitPriceList];
newItems[index] = { ...newItems[index], [field]: value };
if (field === 'quantity' || field === 'unit_price') {
const item = newItems[index];
item.total_price = (item.quantity || 0) * (item.unit_price || 0);
}
setUnitPriceList(newItems);
};
// 计算单价结算总金额
const totalUnitPrice = unitPriceList.reduce((sum, item) => sum + (item.total_price || 0), 0);
const handleCreate = () => {
setEditingProject(null);
form.resetFields();
setPaymentNodes([]);
setUnitPriceList([]);
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingProject(record);
form.setFieldsValue({
...record,
start_date: record.start_date ? dayjs(record.start_date) : null,
expected_end_date: record.expected_end_date ? dayjs(record.expected_end_date) : null,
});
setPaymentNodes(record.payment_nodes || []);
setUnitPriceList(record.unit_price_list || []);
setModalVisible(true);
};
const handleDelete = async (id: number) => {
try {
const res = await axios.delete('/api/projects/' + id);
if (res.data.success) {
message.success('删除成功');
fetchProjects();
}
} catch (error) {
message.error('删除失败');
}
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const totalPercentage = paymentNodes.reduce((sum, node) => sum + (node.percentage || 0), 0);
if (paymentNodes.length > 0 && totalPercentage > 100) {
message.error('付款节点比例总和不能超过100%');
return;
}
const projectData = {
...values,
start_date: values.start_date?.format('YYYY-MM-DD'),
expected_end_date: values.expected_end_date?.format('YYYY-MM-DD'),
contract_amount: settlementType === 'unit' ? totalUnitPrice : values.contract_amount,
payment_nodes: paymentNodes,
unit_price_list: settlementType === 'unit' ? unitPriceList : [],
status: editingProject ? values.status : 'planning',
};
if (editingProject) {
const res = await axios.put('/api/projects/' + editingProject.id, projectData);
if (res.data.success) {
message.success('更新成功');
setModalVisible(false);
fetchProjects();
}
} else {
const res = await axios.post('/api/projects', projectData);
if (res.data.success) {
message.success('创建成功');
setModalVisible(false);
fetchProjects();
}
}
} catch (error) {
message.error('操作失败');
}
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'default', text: '规划中' },
active: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '已完成' },
suspended: { color: 'warning', text: '已暂停' },
};
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const formatAmount = (val: number, curr: string = 'CNY') => {
const c = CURRENCIES.find(item => item.value === curr);
const symbol = c?.symbol || '¥';
return symbol + ' ' + (val || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
};
const columns = [
{ title: '项目编号', dataIndex: 'project_code', width: 120 },
{ title: '项目名称', dataIndex: 'name', ellipsis: true },
{ title: '客户', dataIndex: 'customer_name', render: (v: string) => v || '-' },
{ title: '项目经理', dataIndex: 'manager_name', render: (v: string) => v || '-' },
{ title: '合同金额', dataIndex: 'contract_amount', render: (v: number, r: any) => formatAmount(v, r.currency) },
{ title: '状态', dataIndex: 'status', render: (status: string) => getStatusTag(status) },
{ title: '开始日期', dataIndex: 'start_date' },
{ title: '操作', key: 'action', width: isAdmin ? 200 : 80, render: (_: any, record: any) => (
<Space>
<Button size="small" onClick={() => navigate('/projects/' + record.id)}></Button>
{isAdmin && (
<>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></Button>
<Popconfirm title="确定删除此项目吗?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</>
)}
</Space>
)}
];
return (
<div style={{ padding: isMobile ? 8 : 24 }}>
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
</div>
<Card
title="项目列表"
extra={isAdmin && <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}></Button>}
>
<Table dataSource={projects} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
</Card>
{isAdmin && (
<Modal
title={editingProject ? '编辑项目' : '新建项目'}
open={modalVisible}
onOk={handleSubmit}
onCancel={() => setModalVisible(false)}
width={1000}
style={{ top: 20 }}
okText="确定"
cancelText="取消"
>
<Form form={form} layout="vertical" initialValues={{ settlement_type: 'total', currency: 'CNY' }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="项目名称" rules={[{ required: true }]}>
<Input placeholder="请输入项目名称" size="large" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="customer_id" label="客户名称" rules={[{ required: true }]}>
<Select placeholder="选择客户" showSearch optionFilterProp="children" size="large">
{customers.map((c: any) => <Option key={c.id} value={c.id}>{c.name}</Option>)}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="project_manager_id" label="项目负责人" rules={[{ required: true }]}>
<Select placeholder="选择项目负责人" showSearch optionFilterProp="children" size="large">
{users.map((u: any) => <Option key={u.id} value={u.id}>{u.name} ({u.department})</Option>)}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="work_quantity" label="工程量">
<Input placeholder="如:10000立方米、5000平方米" size="large" />
</Form.Item>
</Col>
</Row>
<Form.Item name="project_situation" label="项目情况">
<TextArea rows={2} placeholder="描述项目具体情况" />
</Form.Item>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="settlement_type" label="结算方式" rules={[{ required: true }]}>
<Radio.Group onChange={() => { setPaymentNodes([]); setUnitPriceList([]); }}>
<Radio value="total"></Radio>
<Radio value="unit"></Radio>
</Radio.Group>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="currency" label="币种">
<Select size="large">
{CURRENCIES.map(c => <Option key={c.value} value={c.value}>{c.label}</Option>)}
</Select>
</Form.Item>
</Col>
{settlementType === 'total' && (
<Col span={8}>
<Form.Item name="contract_amount" label="合同金额" rules={[{ required: true }]}>
<InputNumber
style={{ width: '100%' }}
size="large"
min={0}
precision={2}
formatter={v => v ? v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''}
parser={v => v ? v.replace(/,/g, '') : ''}
/>
</Form.Item>
</Col>
)}
</Row>
{settlementType === 'unit' && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong style={{ fontSize: 16 }}></Text>
<Button type="dashed" onClick={addUnitPriceItem} icon={<PlusOutlined />}></Button>
</div>
{unitPriceList.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', background: '#fafafa', borderRadius: 8, border: '1px dashed #d9d9d9' }}>
<Text type="secondary">"添加项目"</Text>
</div>
)}
{unitPriceList.map((item, index) => (
<Card
key={index}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={<Text> {index + 1}</Text>}
extra={<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeUnitPriceItem(index)}></Button>}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="项目名称">
<Input
value={item.item_name}
onChange={e => updateUnitPriceItem(index, 'item_name', e.target.value)}
placeholder="如:土方开挖"
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="单位">
<Input
value={item.unit}
onChange={e => updateUnitPriceItem(index, 'unit', e.target.value)}
placeholder="如:m³"
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="数量">
<InputNumber
style={{ width: '100%' }}
value={item.quantity}
onChange={val => updateUnitPriceItem(index, 'quantity', val)}
min={0}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="单价">
<InputNumber
style={{ width: '100%' }}
value={item.unit_price}
onChange={val => updateUnitPriceItem(index, 'unit_price', val)}
min={0}
precision={2}
formatter={v => v ? formatAmount(parseFloat(v.toString()), currency) : ''}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="总价">
<Text strong style={{ fontSize: 16 }}>{formatAmount(item.total_price, currency)}</Text>
</Form.Item>
</Col>
</Row>
</Card>
))}
{unitPriceList.length > 0 && (
<div style={{ padding: 16, background: '#e6f7ff', borderRadius: 8, textAlign: 'right' }}>
<Text strong style={{ fontSize: 16 }}>{formatAmount(totalUnitPrice, currency)}</Text>
</div>
)}
</div>
)}
<Divider orientation="left"></Divider>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong style={{ fontSize: 16 }}></Text>
<Button type="dashed" onClick={addPaymentNode} icon={<PlusOutlined />}></Button>
</div>
{paymentNodes.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', background: '#fafafa', borderRadius: 8, border: '1px dashed #d9d9d9' }}>
<Text type="secondary">"添加节点"</Text>
</div>
)}
{paymentNodes.map((node, index) => (
<Card
key={index}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={<Text> {index + 1}</Text>}
extra={<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removePaymentNode(index)}></Button>}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="节点名称">
<Input
value={node.node_name}
onChange={e => updatePaymentNode(index, 'node_name', e.target.value)}
placeholder="如:预付款、进度款、尾款"
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="比例(%)">
<InputNumber
style={{ width: '100%' }}
value={node.percentage}
onChange={val => updatePaymentNode(index, 'percentage', val)}
min={0}
max={100}
placeholder="如:30"
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="金额">
<Text strong style={{ fontSize: 16 }}>{formatAmount(node.node_amount || 0, currency)}</Text>
</Form.Item>
</Col>
</Row>
<Form.Item label="触发条件">
<Input
value={node.trigger_condition}
onChange={e => updatePaymentNode(index, 'trigger_condition', e.target.value)}
placeholder="如:合同签订后支付、工程完工后支付"
/>
</Form.Item>
</Card>
))}
{paymentNodes.length > 0 && (
<div style={{ padding: 16, background: '#f6ffed', borderRadius: 8, textAlign: 'right' }}>
<Text type="secondary"></Text>
<Text strong style={{ fontSize: 16, marginLeft: 8 }}>{paymentNodes.reduce((sum, n) => sum + (n.percentage || 0), 0)}%</Text>
</div>
)}
</div>
<Divider orientation="left"></Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="contract_days" label="合同工期(天)">
<InputNumber style={{ width: '100%' }} min={1} placeholder="输入天数" size="large" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="start_date" label="开始日期">
<DatePicker style={{ width: '100%' }} size="large" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="expected_end_date" label="结束日期">
<DatePicker style={{ width: '100%' }} size="large" disabled />
</Form.Item>
</Col>
</Row>
<Divider orientation="left"></Divider>
<Form.Item name="contract_file" label="上传合同">
<Upload maxCount={1} accept=".pdf,.doc,.docx,.jpg,.png">
<Button icon={<UploadOutlined />}></Button>
</Upload>
</Form.Item>
</Form>
</Modal>
)}
</div>
);
};
export default ProjectsPage;