561 lines
22 KiB
TypeScript
561 lines
22 KiB
TypeScript
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;
|