Files
yunhaifinance/frontend/src/pages/projects/ProjectDetail.tsx
T

1853 lines
76 KiB
TypeScript

import React, { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Tabs, Card, Descriptions, Button, Table, Tag, Progress, Space, message, Spin, Select, Menu, Dropdown, Modal, Form, Input, InputNumber, Switch, Upload, DatePicker } from 'antd'
import dayjs from 'dayjs'
import { DownOutlined, UploadOutlined, ToolOutlined } from '@ant-design/icons'
import {
ArrowLeftOutlined,
EditOutlined,
InfoCircleOutlined,
FileTextOutlined,
TeamOutlined,
DatabaseOutlined,
CheckCircleOutlined,
FileSearchOutlined,
DollarOutlined,
SafetyOutlined
} from '@ant-design/icons'
import apiClient from '../../utils/request'
const { TabPane } = Tabs
interface Project {
id: number
project_code: string
name: string
customer_id: number
customer_name: string
status: string
budget: string
spent: string
start_date: string
end_date: string
description: string
contract_type: string
contract_amount: string
currency: string
contract_days: number
project_manager_id: number
manager_name: string
location: string
work_quantity: string
project_situation: string
settlement_type: string
has_warranty: boolean
warranty_amount: string
warranty_percent: string
warranty_months: number
warranty_start_date: string
warranty_end_date: string
warranty_status: string
}
const ProjectDetail: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [project, setProject] = useState<Project | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('basic')
const [isMobile, setIsMobile] = useState(false)
const [contractEditModalVisible, setContractEditModalVisible] = useState(false)
const [basicInfoEditModalVisible, setBasicInfoEditModalVisible] = useState(false)
const [contractForm] = Form.useForm()
const [basicInfoForm] = Form.useForm()
const [unitPriceItems, setUnitPriceItems] = useState([
{ key: '1', name: '项目1', unit: '个', quantity: 10, price: 100, total: 1000 },
{ key: '2', name: '项目2', unit: '米', quantity: 50, price: 20, total: 1000 }
])
const [contractTotal, setContractTotal] = useState(0)
const [settlementType, setSettlementType] = useState('lump_sum')
const [paymentNodes, setPaymentNodes] = useState([
{ key: '1', name: '预付款', condition: '合同签订后7天内', percentage: 40, amount: 0, status: 'pending' },
{ key: '2', name: '进度款', condition: '所有设备材料达到现场', percentage: 30, amount: 0, status: 'pending' },
{ key: '3', name: '尾款', condition: '通电完工7天内', percentage: 27, amount: 0, status: 'pending' },
{ key: '4', name: '质保金', condition: '', percentage: 3, amount: 0, status: 'pending' }
])
const [contractFile, setContractFile] = useState<string>('')
const [contracts, setContracts] = useState<any[]>([])
const [subcontracts, setSubcontracts] = useState<any[]>([])
const [materials, setMaterials] = useState<any[]>([])
const [milestones, setMilestones] = useState<any[]>([])
const [finances, setFinances] = useState<any[]>([])
const [warrantyDeposits, setWarrantyDeposits] = useState<any[]>([])
const [constructionLogs, setConstructionLogs] = useState<any[]>([])
const [subcontractModalVisible, setSubcontractModalVisible] = useState(false)
const [subcontractForm] = Form.useForm()
const [subcontractors, setSubcontractors] = useState<any[]>([])
const [users, setUsers] = useState<any[]>([])
const [usersLoading, setUsersLoading] = useState(false)
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768)
}
checkMobile()
window.addEventListener('resize', checkMobile)
return () => window.removeEventListener('resize', checkMobile)
}, [])
useEffect(() => {
fetchProject()
}, [id])
// 获取用户列表
useEffect(() => {
const fetchUsers = async () => {
setUsersLoading(true)
try {
const response = await apiClient.get('/users')
const data = response.data
if (data.success) {
setUsers(data.data)
}
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
setUsersLoading(false)
}
}
fetchUsers()
}, [])
// 当project加载完成后,设置合同相关的默认值
useEffect(() => {
if (project) {
setSettlementType(project.settlement_type || 'lump_sum')
setContractTotal(parseFloat(project.contract_amount || '0'))
// 初始化付款节点金额
const initialNodes = paymentNodes.map(node => ({
...node,
amount: Math.round((parseFloat(project.contract_amount || '0') * node.percentage) / 100)
}))
setPaymentNodes(initialNodes)
}
}, [project])
// 当contracts数据加载完成后,设置合同表单默认值
useEffect(() => {
if (project && contracts.length === 0) {
// 使用setTimeout确保Form组件已经渲染
setTimeout(() => {
contractForm.setFieldsValue({
project_overview: project?.project_situation || project?.description || '无',
settlement_type: project.settlement_type || 'lump_sum',
contract_total: parseFloat(project.contract_amount || '0'),
tax_included: false,
other_info: ''
})
}, 0)
}
}, [project, contractForm, contracts.length])
// 当project加载完成后,设置基本信息表单默认值
useEffect(() => {
if (project) {
// 使用setTimeout确保Form组件已经渲染
setTimeout(() => {
// 优先使用project.contract_days作为工期天数
let workDays = project.contract_days || 0;
basicInfoForm.setFieldsValue({
name: project.name,
manager_id: Number(project.manager_id || project.project_manager_id || 0) || undefined,
location: project.location,
start_date: project.start_date ? dayjs(project.start_date) : null,
end_date: project.end_date ? dayjs(project.end_date) : null,
work_days: workDays,
description: project.project_situation || project.description || ''
})
}, 0)
}
}, [project, basicInfoForm])
// 当contracts数据加载完成后,更新表单默认值
useEffect(() => {
if (contracts.length > 0) {
const contract = contracts[0]
setSettlementType(contract.settlement_method || 'lump_sum')
setContractTotal(parseFloat(contract.contract_amount || '0'))
setContractFile(contract.contract_file || '')
// 使用setTimeout确保Form组件已经渲染
setTimeout(() => {
// 更新表单默认值
contractForm.setFieldsValue({
project_overview: project?.project_situation || project?.description || '无',
settlement_type: contract.settlement_method || 'lump_sum',
contract_total: parseFloat(contract.contract_amount || '0'),
tax_included: contract.tax_included || false,
other_info: contract.other_info || ''
})
}, 0)
// 更新付款节点金额
const updatedNodes = paymentNodes.map(node => ({
...node,
amount: Math.round((parseFloat(contract.contract_amount || '0') * node.percentage) / 100)
}))
setPaymentNodes(updatedNodes)
}
}, [contracts, project, contractForm])
// 处理文件上传
const handleFileUpload = async (options: any) => {
const { file, onSuccess, onError } = options
try {
const formData = new FormData()
formData.append('file', file)
const response = await apiClient.post('/upload/single', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.success) {
setContractFile(response.data.data.url)
onSuccess(response.data.data)
} else {
onError(new Error('文件上传失败'))
}
} catch (error) {
console.error('文件上传失败:', error)
onError(error)
}
}
// 打开新增分包模态框
const openSubcontractModal = () => {
subcontractForm.resetFields()
// 设置默认的单价项目列表
setUnitPriceItems([
{ key: '1', name: '线路架设', unit: 'km', quantity: 1, price: 0, total: 0 },
{ key: '2', name: '手续办理', unit: '项', quantity: 1, price: 0, total: 0 }
]);
// 设置开始日期为当天
subcontractForm.setFieldsValue({ start_date: dayjs() });
setSubcontractModalVisible(true)
}
// 关闭新增分包模态框
const closeSubcontractModal = () => {
setSubcontractModalVisible(false)
}
// 添加单价项目
const addUnitPriceItem = () => {
setUnitPriceItems(prevItems => {
const newItem = {
key: (prevItems.length + 1).toString(),
name: '',
unit: '',
quantity: 0,
price: 0,
total: 0
};
return [...prevItems, newItem];
});
};
// 删除单价项目
const removeUnitPriceItem = (index: number) => {
setUnitPriceItems(prevItems => {
const newItems = [...prevItems];
newItems.splice(index, 1);
// 更新key值
newItems.forEach((item, i) => {
item.key = (i + 1).toString();
});
return newItems;
});
};
// 提交新增分包表单
const handleSubcontractSubmit = async () => {
try {
const values = await subcontractForm.validateFields()
let contractAmount = values.contract_amount;
// 如果是单价结算,自动计算合同金额
if (values.settlement_type === 'unit_price') {
const totalAmount = unitPriceItems.reduce((sum, item) => sum + (item.total || 0), 0);
contractAmount = totalAmount;
}
const response = await apiClient.post(`/projects/${id}/subcontracts`, {
subcontractor_id: values.subcontractor_id,
subcontractor_name: values.subcontractor_name,
contract_amount: contractAmount,
currency: values.currency,
settlement_type: values.settlement_type,
other_terms: values.other_terms,
payment_description: values.payment_description,
unit_price_items: values.settlement_type === 'unit_price' ? unitPriceItems : [],
start_date: values.start_date ? values.start_date.toISOString() : null,
end_date: values.end_date ? values.end_date.toISOString() : null,
work_days: values.work_days,
status: values.status
})
if (response.data.success) {
message.success('新增分包成功')
// 重新获取分包列表
fetchProjectData()
closeSubcontractModal()
// 重置单价项目列表
setUnitPriceItems([
{ key: '1', name: '项目1', unit: '个', quantity: 10, price: 100, total: 1000 },
{ key: '2', name: '项目2', unit: '米', quantity: 50, price: 20, total: 1000 }
]);
} else {
message.error('新增分包失败:' + response.data.message)
}
} catch (error) {
console.error('新增分包失败:', error)
message.error('新增分包失败,请检查表单数据')
}
}
const fetchProject = async () => {
try {
const response = await apiClient.get(`/projects/${id}`)
if (response.data.success) {
setProject(response.data.data)
}
} catch (error) {
message.error('获取项目信息失败')
} finally {
setLoading(false)
}
}
const fetchProjectData = async () => {
try {
// 获取合同信息
const contractsResponse = await apiClient.get(`/projects/${id}/contracts`)
if (contractsResponse.data.success) {
setContracts(contractsResponse.data.data)
}
// 获取分包信息
const subcontractsResponse = await apiClient.get(`/projects/${id}/subcontracts`)
if (subcontractsResponse.data.success) {
setSubcontracts(subcontractsResponse.data.data)
}
// 获取材料信息
const materialsResponse = await apiClient.get(`/projects/${id}/materials`)
if (materialsResponse.data.success) {
setMaterials(materialsResponse.data.data)
}
// 获取施工节点
const milestonesResponse = await apiClient.get(`/projects/${id}/milestones`)
if (milestonesResponse.data.success) {
setMilestones(milestonesResponse.data.data)
}
// 获取财务信息
const financesResponse = await apiClient.get(`/projects/${id}/finances`)
if (financesResponse.data.success) {
setFinances(financesResponse.data.data)
}
// 获取质保金信息
const warrantyDepositsResponse = await apiClient.get(`/projects/${id}/warranty-deposits`)
if (warrantyDepositsResponse.data.success) {
setWarrantyDeposits(warrantyDepositsResponse.data.data)
}
// 获取施工日志
const constructionLogsResponse = await apiClient.get(`/projects/${id}/construction-logs`)
if (constructionLogsResponse.data.success) {
setConstructionLogs(constructionLogsResponse.data.data)
}
} catch (error) {
console.error('获取项目数据失败:', error)
}
}
useEffect(() => {
if (id) {
fetchProject()
fetchSubcontractors()
fetchProjectData()
}
}, [id])
// 获取分包商列表
const fetchSubcontractors = async () => {
try {
const response = await apiClient.get('/subcontractors')
if (response.data.success) {
setSubcontractors(response.data.data)
}
} catch (error) {
console.error('获取分包商列表失败:', error)
}
}
// 监听单价项目变更,更新合同总价
useEffect(() => {
calculateContractTotal()
}, [unitPriceItems])
// 监听结算方式变更,更新合同总价字段状态
useEffect(() => {
if (settlementType === 'unit_price') {
calculateContractTotal()
}
}, [settlementType])
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
planning: { color: 'blue', text: '规划中' },
in_progress: { color: 'processing', 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 calculateContractTotal = () => {
const total = unitPriceItems.reduce((sum, item) => sum + (item.total || 0), 0)
setContractTotal(total)
}
// 处理单价项目变更
const handleUnitPriceItemChange = (index: number, field: string, value: any) => {
const newItems = [...unitPriceItems]
newItems[index] = { ...newItems[index], [field]: value }
// 计算单项总价
if (field === 'quantity' || field === 'price') {
newItems[index].total = (newItems[index].quantity || 0) * (newItems[index].price || 0)
}
setUnitPriceItems(newItems)
calculateContractTotal()
// 当单价项目变化时,自动更新合同金额字段
const settlementType = subcontractForm.getFieldValue('settlement_type');
if (settlementType === 'unit_price') {
const totalAmount = newItems.reduce((sum, item) => sum + (item.total || 0), 0);
subcontractForm.setFieldsValue({ contract_amount: totalAmount });
}
}
// 处理结算方式变更
const handleSettlementTypeChange = (value: string) => {
setSettlementType(value)
contractForm.setFieldsValue({ settlement_type: value })
}
// 处理付款节点比例变化
const handlePaymentNodePercentageChange = (index: number, value: number) => {
const newNodes = [...paymentNodes]
newNodes[index].percentage = value
newNodes[index].amount = Math.round((contractTotal * value) / 100)
setPaymentNodes(newNodes)
const total = newNodes.reduce((sum, n) => sum + (n.percentage || 0), 0)
if (total > 100) {
message.warning(`付款比例合计为 ${total}%,已超过 100%,请调整`)
}
}
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
<Spin size="large" />
</div>
)
}
if (!project) {
return (
<Card>
<div style={{ textAlign: 'center', padding: 40 }}>
项目不存在或已被删除
<Button type="link" onClick={() => navigate('/projects')}>返回列表</Button>
</div>
</Card>
)
}
// 基本信息 Tab
const BasicInfoTab = () => {
// 查找项目经理名称
const getManagerName = () => {
// 优先使用manager_name
if (project.manager_name) return project.manager_name;
// 尝试使用project_manager_id
if (project.project_manager_id) {
const user = users.find(u => Number(u.id) === Number(project.project_manager_id));
return user ? user.name : '未知经理';
}
// 尝试使用manager_id(后端使用的字段)
if (project.manager_id) {
const user = users.find(u => Number(u.id) === Number(project.manager_id));
return user ? user.name : '未知经理';
}
return '未知经理';
};
return (
<Card title="项目基本信息" extra={<Button icon={<EditOutlined />} onClick={() => setBasicInfoEditModalVisible(true)}>编辑</Button>}>
<Descriptions column={2} bordered>
<Descriptions.Item label="项目编号">{project.project_code}</Descriptions.Item>
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
<Descriptions.Item label="项目经理">{getManagerName()}</Descriptions.Item>
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label="项目状态">{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label="开工日期">{project.start_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="完工日期">{project.end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="合同工期">{project.contract_days} </Descriptions.Item>
<Descriptions.Item label="结算方式">
{project.settlement_type === 'lump_sum' ? '总价包干' : '单价结算'}
</Descriptions.Item>
<Descriptions.Item label="工程概况" span={2}>
{project.project_situation || project.description || '-'}
</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>
{new Date().toLocaleDateString()}
</Descriptions.Item>
</Descriptions>
</Card>
);
}
// 合同详情 Tab
const ContractTab = () => {
// 获取最新的合同信息
const latestContract = contracts.length > 0 ? contracts[0] : null;
return (
<Card title="合同详情" extra={<Button icon={<EditOutlined />} onClick={() => {
const latestContract = contracts.length > 0 ? contracts[0] : null;
const savedSettlement = latestContract?.settlement_method || 'lump_sum';
const savedTotal = parseFloat(latestContract?.contract_amount || project?.contract_amount || '0');
setSettlementType(savedSettlement);
setContractTotal(savedTotal);
setContractFile(latestContract?.contract_file || '');
if (milestones.length > 0) {
setPaymentNodes(milestones.map((m: any) => ({
key: String(m.id),
name: m.milestone_name || '',
condition: m.condition || '',
percentage: m.percentage || 0,
amount: m.amount || 0,
status: m.status || 'pending'
})));
} else {
setPaymentNodes([
{ key: '1', name: '预付款', condition: '合同签订后7天内', percentage: 40, amount: Math.round(savedTotal * 0.4), status: 'pending' },
{ key: '2', name: '进度款', condition: '所有设备材料达到现场', percentage: 30, amount: Math.round(savedTotal * 0.3), status: 'pending' },
{ key: '3', name: '尾款', condition: '通电完工7天内', percentage: 27, amount: Math.round(savedTotal * 0.27), status: 'pending' },
{ key: '4', name: '质保金', condition: '', percentage: 3, amount: Math.round(savedTotal * 0.03), status: 'pending' }
]);
}
setContractEditModalVisible(true);
setTimeout(() => {
contractForm.setFieldsValue({
project_overview: project?.project_situation || project?.description || '',
settlement_type: savedSettlement,
contract_total: savedTotal,
tax_included: latestContract?.tax_included || false,
other_info: latestContract?.other_info || ''
});
}, 100);
}}>合同细节录入</Button>}>
<Descriptions column={2} bordered style={{ marginBottom: 24 }}>
<Descriptions.Item label="合同金额">
{project.currency} {parseFloat(latestContract?.contract_amount || project.contract_amount || '0').toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="币种">{project.currency}</Descriptions.Item>
<Descriptions.Item label="合同类型">
{project.contract_type === 'lump_sum' ? '总价包干' : '单价合同'}
</Descriptions.Item>
<Descriptions.Item label="结算方式">
{latestContract?.settlement_method === 'lump_sum' ? '总价包干' : '单价结算'}
</Descriptions.Item>
<Descriptions.Item label="是否含税">
{latestContract?.tax_included ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="合同编号">
{latestContract?.contract_code || '-'}
</Descriptions.Item>
</Descriptions>
<Card type="inner" title="付款节点" style={{ marginBottom: 24 }}>
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '节点条件', dataIndex: 'condition', key: 'condition', render: (v: string) => v || '-' },
{ title: '比例', dataIndex: 'percentage', key: 'percentage', render: (v: number) => `${v}%` },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => ${v.toLocaleString()}` },
{ title: '完成进度', dataIndex: 'completion_progress', key: 'completion_progress', render: (v: number) => <Progress percent={v} size="small" /> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
]}
locale={{ emptyText: '暂无节点记录' }}
/>
</Card>
<Card type="inner" title="合同附件" style={{ marginBottom: 24 }}>
<div>
{latestContract ? (
<div style={{ marginBottom: 16 }}>
<Descriptions column={1}>
<Descriptions.Item label="合同编号">{latestContract.contract_code}</Descriptions.Item>
<Descriptions.Item label="合同文件">
{latestContract.contract_file ? (
<a href={latestContract.contract_file} target="_blank" rel="noopener noreferrer">
查看合同文件
</a>
) : (
'-'
)}
</Descriptions.Item>
</Descriptions>
</div>
) : (
<div>暂无合同附件</div>
)}
</div>
</Card>
<Card type="inner" title="其他合同信息" style={{ marginBottom: 24 }}>
<Descriptions column={1}>
<Descriptions.Item label="其他信息">
{latestContract?.other_info || '-'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card type="inner" title="质保金设置">
<Descriptions column={2}>
<Descriptions.Item label="是否有质保金">
{milestones.find(m => m.milestone_name === '质保金') ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="质保金比例">
{milestones.find(m => m.milestone_name === '质保金')?.percentage || 0}%
</Descriptions.Item>
<Descriptions.Item label="质保金金额">
¥{(milestones.find(m => m.milestone_name === '质保金')?.amount || 0).toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="质保期限">{project.warranty_months} 个月</Descriptions.Item>
<Descriptions.Item label="到期日期">{project.warranty_end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="质保金状态">
<Tag color={project.warranty_status === 'released' ? 'success' : 'default'}>
{project.warranty_status === 'released' ? '已释放' : '待释放'}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
</Card>
)
}
// 分包管理 Tab
const SubcontractTab = () => {
const [subcontractDetailModalVisible, setSubcontractDetailModalVisible] = useState(false);
const [selectedSubcontract, setSelectedSubcontract] = useState<any>(null);
const openSubcontractDetail = (subcontract: any) => {
setSelectedSubcontract(subcontract);
setSubcontractDetailModalVisible(true);
};
const closeSubcontractDetail = () => {
setSubcontractDetailModalVisible(false);
setSelectedSubcontract(null);
};
const getCurrencySymbol = (currency: string) => {
const symbols: Record<string, string> = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿'
};
return symbols[currency] || '';
};
return (
<Card title="分包管理" extra={<Button type="primary" onClick={openSubcontractModal}>新增分包</Button>}>
<Table
dataSource={subcontracts.map(item => ({
...item,
key: item.id
}))}
columns={[
{
title: '分包商',
dataIndex: 'subcontractor_name',
key: 'subcontractor_name',
render: (text: string, record: any) => (
<a onClick={() => openSubcontractDetail(record)}>{text}</a>
)
},
{
title: '合同金额',
dataIndex: 'contract_amount',
key: 'contract_amount',
render: (v: number, record: any) => {
const symbol = getCurrencySymbol(record.currency);
return `${symbol}${v.toLocaleString()}`;
}
},
{
title: '已付款',
dataIndex: 'paid_amount',
key: 'paid_amount',
render: (v: number, record: any) => {
const symbol = getCurrencySymbol(record.currency);
return `${symbol}${v.toLocaleString()}`;
}
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (v: string) => <Tag color={v === 'completed' ? 'success' : 'default'}>{v === 'completed' ? '已完成' : '进行中'}</Tag>
},
]}
locale={{ emptyText: '暂无分包记录' }}
/>
{/* 分包详情模态框 */}
<Modal
title="分包详情"
open={subcontractDetailModalVisible}
onCancel={closeSubcontractDetail}
width={800}
footer={[
<Button key="close" onClick={closeSubcontractDetail}>关闭</Button>
]}
>
{selectedSubcontract && (
<div style={{ padding: 20 }}>
<Descriptions column={2} bordered>
<Descriptions.Item label="分包商">{selectedSubcontract.subcontractor_name}</Descriptions.Item>
<Descriptions.Item label="合同金额">
{getCurrencySymbol(selectedSubcontract.currency)}{selectedSubcontract.contract_amount.toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="币种">{selectedSubcontract.currency}</Descriptions.Item>
<Descriptions.Item label="结算方式">
{selectedSubcontract.settlement_type === 'lump_sum' ? '总价包干' : '单价结算'}
</Descriptions.Item>
<Descriptions.Item label="已付款">
{getCurrencySymbol(selectedSubcontract.currency)}{selectedSubcontract.paid_amount.toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedSubcontract.status === 'completed' ? 'success' : 'default'}>
{selectedSubcontract.status === 'completed' ? '已完成' : '进行中'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="开始日期">
{selectedSubcontract.start_date ? selectedSubcontract.start_date.split('T')[0] : '-'}
</Descriptions.Item>
<Descriptions.Item label="结束日期">
{selectedSubcontract.end_date ? selectedSubcontract.end_date.split('T')[0] : '-'}
</Descriptions.Item>
<Descriptions.Item label="其他约定" span={2}>
{selectedSubcontract.other_terms || '-'}
</Descriptions.Item>
<Descriptions.Item label="付款说明" span={2}>
{selectedSubcontract.payment_description || '-'}
</Descriptions.Item>
</Descriptions>
{/* 单价项目列表 */}
{selectedSubcontract.settlement_type === 'unit_price' && selectedSubcontract.unit_price_items && selectedSubcontract.unit_price_items.length > 0 && (
<Card type="inner" title="项目单项价" style={{ marginTop: 20 }}>
<Table
dataSource={selectedSubcontract.unit_price_items.map((item: any, index: number) => ({
...item,
key: index
}))}
columns={[
{ title: '项目名称', dataIndex: 'name', key: 'name' },
{ title: '单位', dataIndex: 'unit', key: 'unit' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
{
title: '单价',
dataIndex: 'price',
key: 'price',
render: (v: number) => {
const symbol = getCurrencySymbol(selectedSubcontract.currency);
return `${symbol}${v.toLocaleString()}`;
}
},
{
title: '总价',
dataIndex: 'total',
key: 'total',
render: (v: number) => {
const symbol = getCurrencySymbol(selectedSubcontract.currency);
return `${symbol}${v.toLocaleString()}`;
}
},
]}
pagination={false}
/>
</Card>
)}
</div>
)}
</Modal>
</Card>
);
}
// 材料管理 Tab
const MaterialTab = () => (
<Card title="材料管理">
<Table
dataSource={materials.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '材料名称', dataIndex: 'product_name', key: 'product_name' },
{ title: '单位', dataIndex: 'unit', key: 'unit' },
{ title: '预算量', dataIndex: 'budget_quantity', key: 'budget_quantity' },
{ title: '采购量', dataIndex: 'purchase_quantity', key: 'purchase_quantity' },
{ title: '使用量', dataIndex: 'used_quantity', key: 'used_quantity' },
{ title: '均价', dataIndex: 'average_price', key: 'average_price', render: (v: number) => ${v.toLocaleString()}` },
{ title: '总价', dataIndex: 'total_amount', key: 'total_amount', render: (v: number) => ${v.toLocaleString()}` },
]}
locale={{ emptyText: '暂无材料记录' }}
/>
</Card>
)
// 施工节点 Tab
const MilestoneTab = () => (
<Card title="施工节点">
<Card type="inner" title="合同付款节点" style={{ marginBottom: 16 }}>
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '节点条件', dataIndex: 'condition', key: 'condition', render: (v: string) => v || '-' },
{ title: '比例', dataIndex: 'percentage', key: 'percentage', render: (v: number) => `${v}%` },
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number) => ${v.toLocaleString()}` },
{ title: '完成进度', dataIndex: 'completion_progress', key: 'completion_progress', render: (v: number) => <Progress percent={v} size="small" /> },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
]}
locale={{ emptyText: '暂无节点记录' }}
/>
</Card>
<Card type="inner" title="重要节点完成情况">
<Table
dataSource={milestones.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '节点名称', dataIndex: 'milestone_name', key: 'milestone_name' },
{ title: '计划日期', dataIndex: 'expected_date', key: 'expected_date' },
{ title: '实际日期', dataIndex: 'actual_date', key: 'actual_date' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => <Tag color={v === 'completed' ? 'success' : v === 'in_progress' ? 'processing' : 'default'}>{v === 'completed' ? '已完成' : v === 'in_progress' ? '进行中' : '待开始'}</Tag> },
{ title: '操作', key: 'action', render: () => <Button size="small">上传凭证</Button> },
]}
locale={{ emptyText: '暂无记录' }}
/>
</Card>
</Card>
)
// 施工日志 Tab
const LogTab = () => (
<Card title="施工日志" extra={<Button type="primary">新增日志</Button>}>
<Table
dataSource={constructionLogs.map(item => ({
...item,
key: item.id
}))}
columns={[
{ title: '日期', dataIndex: 'log_date', key: 'log_date' },
{ title: '天气', dataIndex: 'weather', key: 'weather' },
{ title: '记录人', dataIndex: 'recorder', key: 'recorder', render: () => '系统管理员' },
{ title: '今日工作', dataIndex: 'work_content', key: 'work_content' },
{ title: '照片', dataIndex: 'photos', key: 'photos', render: (v: string) => v ? '查看照片' : '-' },
]}
locale={{ emptyText: '暂无日志记录' }}
/>
</Card>
)
const FinanceTab = () => {
const [profitData, setProfitData] = React.useState<any>(null);
const [profitLoading, setProfitLoading] = React.useState(false);
React.useEffect(() => {
if (!project?.id) return;
setProfitLoading(true);
apiClient.get(`/financial-records/project-profit/${project.id}`)
.then(res => { if (res.data.success) setProfitData(res.data.data); })
.catch(() => {})
.finally(() => setProfitLoading(false));
}, [project?.id]);
const contractAmount = parseFloat(project?.contract_amount || '0');
const totalIncome = profitData?.total_income || 0;
const totalExpense = profitData?.total_expense || 0;
const grossProfit = profitData?.gross_profit || 0;
const grossMargin = profitData?.gross_margin || 0;
const LEVEL2_LABELS: Record<string, string> = {
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
contract_payment: '合同收款', deposit_refund: '质保金退回'
};
return (
<Spin spinning={profitLoading}>
<Card title="财务信息">
<Descriptions column={2} bordered>
<Descriptions.Item label="合同金额">¥{contractAmount.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="已收款">
<span style={{ color: '#3f8600', fontWeight: 'bold' }}>¥{totalIncome.toLocaleString()}</span>
</Descriptions.Item>
<Descriptions.Item label="支出合计">
<span style={{ color: '#cf1322' }}>¥{totalExpense.toLocaleString()}</span>
</Descriptions.Item>
<Descriptions.Item label="毛利润">
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold', fontSize: 16 }}>
¥{grossProfit.toLocaleString()}
</span>
</Descriptions.Item>
<Descriptions.Item label="毛利率" span={2}>
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
{grossMargin}%
</span>
</Descriptions.Item>
</Descriptions>
</Card>
{profitData?.expense_by_category?.length > 0 && (
<Card title="支出分类明细" size="small" style={{ marginTop: 16 }}>
<Table
dataSource={profitData.expense_by_category}
rowKey="category_level2"
pagination={false}
size="small"
columns={[
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
{
title: '占比', render: (_: unknown, r: any) => {
const pct = totalExpense > 0 ? (parseFloat(r.total_amount) / totalExpense * 100).toFixed(1) : '0';
return `${pct}%`;
}
},
]}
/>
</Card>
)}
{profitData?.expense_by_user?.length > 0 && (
<Card title="人员支出明细" size="small" style={{ marginTop: 16 }}>
<Table
dataSource={profitData.expense_by_user}
rowKey="user_name"
pagination={false}
size="small"
columns={[
{ title: '人员', dataIndex: 'user_name' },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
]}
/>
</Card>
)}
</Spin>
)
}
// 质保金 Tab
const WarrantyTab = () => {
// 从付款节点中查找质保金节点
const warrantyMilestone = milestones.find(m => m.milestone_name === '质保金');
// 如果找到质保金节点,使用该节点的数据;否则使用0
const warrantyAmount = warrantyMilestone?.amount || 0;
const warrantyPercent = warrantyMilestone?.percentage || 0;
return (
<Card title="质保金管理">
<Descriptions column={2} bordered>
<Descriptions.Item label="质保金金额">
¥{warrantyAmount.toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="质保比例">{warrantyPercent}%</Descriptions.Item>
<Descriptions.Item label="质保期限">{project.warranty_months} 个月</Descriptions.Item>
<Descriptions.Item label="起算日期">{project.warranty_start_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="到期日期">{project.warranty_end_date?.split('T')[0] || '-'}</Descriptions.Item>
<Descriptions.Item label="质保金状态">
<Tag color={project.warranty_status === 'released' ? 'success' : 'default'}>
{project.warranty_status === 'released' ? '已释放' : '待释放'}
</Tag>
</Descriptions.Item>
</Descriptions>
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Space>
<Button type="primary">标记已释放</Button>
<Button>延期</Button>
</Space>
</div>
</Card>
)
}
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/projects')}>
返回列表
</Button>
</div>
<Card style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0 }}>{project.name}</h2>
<Space style={{ marginTop: 8 }}>
{getStatusTag(project.status)}
{project.current_phase && <Tag color="blue">当前: {project.current_phase}</Tag>}
{project.phase_progress > 0 && <span>进度: {project.phase_progress}%</span>}
<span>质保金: ¥{(milestones.find(m => m.milestone_name === '质保金')?.amount || 0).toLocaleString()}</span>
</Space>
</div>
<Space>
<Button icon={<ToolOutlined />} type="primary" onClick={() => navigate(`/construction/progress/${id}`)}>进入施工管理</Button>
<Button icon={<EditOutlined />} onClick={() => setBasicInfoEditModalVisible(true)}>编辑项目</Button>
</Space>
</div>
{project.phase_progress > 0 && (
<Progress percent={project.phase_progress} style={{ marginTop: 12 }} strokeColor="#1890ff" />
)}
</Card>
{isMobile ? (
<div style={{ marginBottom: 16 }}>
<Select
style={{ width: '100%' }}
value={activeTab}
onChange={setActiveTab}
options={[
{ value: 'basic', label: '基本信息' },
{ value: 'contract', label: '合同与收款' },
{ value: 'subcontract', label: '分包管理' },
{ value: 'material', label: '材料管理' },
{ value: 'finance', label: '财务收支' },
{ value: 'warranty', label: '质保金' },
{ value: 'log', label: '施工日志' }
]}
/>
<div style={{ marginTop: 16 }}>
{activeTab === 'basic' && <BasicInfoTab />}
{activeTab === 'contract' && <ContractTab />}
{activeTab === 'subcontract' && <SubcontractTab />}
{activeTab === 'material' && <MaterialTab />}
{activeTab === 'finance' && <FinanceTab />}
{activeTab === 'warranty' && <WarrantyTab />}
{activeTab === 'log' && <LogTab />}
</div>
</div>
) : (
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
type="card"
items={[
{
key: 'basic',
label: <span><InfoCircleOutlined /> 基本信息</span>,
children: <BasicInfoTab />
},
{
key: 'contract',
label: <span><FileTextOutlined /> 合同与收款</span>,
children: <ContractTab />
},
{
key: 'subcontract',
label: <span><TeamOutlined /> 分包管理</span>,
children: <SubcontractTab />
},
{
key: 'material',
label: <span><DatabaseOutlined /> 材料管理</span>,
children: <MaterialTab />
},
{
key: 'finance',
label: <span><DollarOutlined /> 财务收支</span>,
children: <FinanceTab />
},
{
key: 'warranty',
label: <span><SafetyOutlined /> 质保金</span>,
children: <WarrantyTab />
},
{
key: 'log',
label: <span><FileSearchOutlined /> 施工日志</span>,
children: <LogTab />
}
]}
/>
)}
{/* 基本信息编辑模态框 */}
<Modal
title="编辑项目基本信息"
open={basicInfoEditModalVisible}
onCancel={() => setBasicInfoEditModalVisible(false)}
onOk={async () => {
try {
const values = await basicInfoForm.validateFields();
// 构建保存数据
const saveData = {
name: values.name,
manager_id: values.manager_id,
location: values.location,
start_date: values.start_date ? values.start_date.toISOString() : null,
end_date: values.end_date ? values.end_date.toISOString() : null,
description: values.description
};
// 发送保存请求
const response = await apiClient.put(`/projects/${id}`, saveData);
if (response.data.success) {
message.success('基本信息保存成功');
// 重新获取项目信息
fetchProject();
setBasicInfoEditModalVisible(false);
} else {
message.error('保存失败:' + response.data.message);
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请检查表单数据');
}
}}
width={600}
okText="保存"
cancelText="取消"
>
<div style={{ padding: 20 }}>
<Form layout="vertical" form={basicInfoForm}>
<Form.Item
label="项目名称"
name="name"
rules={[{ required: true, message: '请输入项目名称' }]}
>
<Input placeholder="请输入项目名称" />
</Form.Item>
<Form.Item
label="项目经理"
name="manager_id"
rules={[{ required: true, message: '请选择项目经理' }]}
>
<Select placeholder="请选择项目经理" loading={usersLoading}>
{users.map(user => (
<Select.Option key={user.id} value={user.id}>
{user.name} ({user.username})
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="项目地点"
name="location"
>
<Input placeholder="请输入项目地点" />
</Form.Item>
<Form.Item
label="开工日期"
name="start_date"
rules={[{ required: true, message: '请选择开工日期' }]}
>
<DatePicker
style={{ width: '100%' }}
onChange={(date) => {
const values = basicInfoForm.getFieldsValue();
if (date && values.work_days) {
// 计算完工日期(减去1天,因为后端计算时会加1天)
const endDate = dayjs(date).add(values.work_days - 1, 'day');
basicInfoForm.setFieldsValue({ end_date: endDate });
}
}}
/>
</Form.Item>
<Form.Item
label="工期天数"
name="work_days"
rules={[{ required: true, message: '请输入工期天数' }]}
>
<InputNumber
min={1}
placeholder="请输入工期天数"
onChange={(value) => {
const values = basicInfoForm.getFieldsValue();
if (value && values.start_date) {
// 计算完工日期(减去1天,因为后端计算时会加1天)
const endDate = dayjs(values.start_date).add(value - 1, 'day');
basicInfoForm.setFieldsValue({ end_date: endDate });
}
}}
/>
</Form.Item>
<Form.Item
label="完工日期"
name="end_date"
rules={[{ required: true, message: '请选择完工日期' }]}
>
<DatePicker
style={{ width: '100%' }}
onChange={(date) => {
const values = basicInfoForm.getFieldsValue();
if (date && values.start_date) {
// 计算工期天数(加上1天,与后端计算逻辑一致)
const startDate = dayjs(values.start_date);
const endDate = dayjs(date);
const diffDays = endDate.diff(startDate, 'day') + 1;
basicInfoForm.setFieldsValue({ work_days: diffDays });
}
}}
/>
</Form.Item>
<Form.Item
label="工程概况"
name="description"
>
<Input.TextArea rows={4} placeholder="请输入工程概况" />
</Form.Item>
</Form>
</div>
</Modal>
{/* 新增分包模态框 */}
<Modal
title="新增分包"
open={subcontractModalVisible}
onCancel={closeSubcontractModal}
onOk={handleSubcontractSubmit}
width={800}
okText="保存"
cancelText="取消"
>
<div style={{ padding: 20 }}>
<Form layout="vertical" form={subcontractForm}>
<Form.Item
label="分包商"
name="subcontractor_id"
rules={[{ required: true, message: '请选择分包商' }]}
>
<Select
placeholder="请选择分包商"
onChange={(value) => {
const selectedSubcontractor = subcontractors.find(s => s.id === value);
if (selectedSubcontractor) {
subcontractForm.setFieldsValue({ subcontractor_name: selectedSubcontractor.name });
}
}}
>
{subcontractors.map(subcontractor => (
<Select.Option key={subcontractor.id} value={subcontractor.id}>
{subcontractor.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="分包商名称"
name="subcontractor_name"
rules={[{ required: true, message: '请输入分包商名称' }]}
>
<Input placeholder="请输入分包商名称" />
</Form.Item>
<Form.Item
label="币种"
name="currency"
>
<Select
placeholder="请选择币种"
defaultValue="CNY"
>
<Select.Option value="CNY">人民币</Select.Option>
<Select.Option value="USD">美元</Select.Option>
<Select.Option value="LAK">老挝基普</Select.Option>
<Select.Option value="THB">泰铢</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="合同金额"
name="contract_amount"
rules={[{ required: true, message: '请输入合同金额' }]}
>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.currency !== currentValues.currency}>
{({ getFieldValue }) => {
const currency = getFieldValue('currency') || 'CNY';
const currencySymbol = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿'
}[currency] || '';
return (
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请输入合同金额"
formatter={(value) => {
if (value === undefined || value === null) return '';
return `${currencySymbol} ${value}`;
}}
parser={(value) => {
if (!value) return 0;
// 移除所有非数字字符,保留小数点
return parseFloat(value.replace(/[^0-9.]/g, '')) || 0;
}}
/>
);
}}
</Form.Item>
</Form.Item>
<Form.Item
label="结算方式"
name="settlement_type"
rules={[{ required: true, message: '请选择结算方式' }]}
>
<Select
placeholder="请选择结算方式"
defaultValue="lump_sum"
onChange={(value) => {
if (value === 'unit_price') {
// 当选择单价结算时,自动计算并设置合同金额
const totalAmount = unitPriceItems.reduce((sum, item) => sum + (item.total || 0), 0);
subcontractForm.setFieldsValue({ contract_amount: totalAmount });
}
}}
>
<Select.Option value="lump_sum">总价包干</Select.Option>
<Select.Option value="unit_price">单价结算</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="其他约定"
name="other_terms"
>
<Input.TextArea rows={3} placeholder="请输入其他约定" />
</Form.Item>
<Form.Item
label="付款说明"
name="payment_description"
>
<Input.TextArea rows={3} placeholder="请输入付款说明" />
</Form.Item>
{/* 单价结算项目列表 - 只在单价结算时显示 */}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.settlement_type !== currentValues.settlement_type || prevValues.currency !== currentValues.currency}
>
{({ getFieldValue }) => {
const settlementType = getFieldValue('settlement_type');
const currency = getFieldValue('currency') || 'CNY';
const currencySymbol = {
CNY: '¥',
USD: '$',
LAK: '₭',
THB: '฿'
}[currency] || '';
if (settlementType === 'unit_price') {
// 计算总合计
const totalAmount = unitPriceItems.reduce((sum, item) => sum + (item.total || 0), 0);
return (
<Form.Item label="项目单项价">
<div style={{ marginBottom: 16, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8, backgroundColor: '#fafafa' }}>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 0.5fr', gap: 12, marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #e8e8e8' }}>
<div style={{ fontWeight: 'bold' }}>项目名称</div>
<div style={{ fontWeight: 'bold' }}>单位</div>
<div style={{ fontWeight: 'bold' }}>数量</div>
<div style={{ fontWeight: 'bold' }}>单价 ({currencySymbol})</div>
<div style={{ fontWeight: 'bold' }}>总价 ({currencySymbol})</div>
<div style={{ fontWeight: 'bold' }}>操作</div>
</div>
{unitPriceItems.map((item, index) => (
<div key={item.key} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 0.5fr', gap: 12, marginBottom: 12, alignItems: 'center' }}>
<Input
placeholder="请输入项目名称"
value={item.name}
onChange={(e) => handleUnitPriceItemChange(index, 'name', e.target.value)}
/>
<Input
placeholder="请输入单位"
value={item.unit}
onChange={(e) => handleUnitPriceItemChange(index, 'unit', e.target.value)}
/>
<InputNumber
min={0}
placeholder="数量"
value={item.quantity}
onChange={(value) => handleUnitPriceItemChange(index, 'quantity', value)}
/>
<InputNumber
min={0}
placeholder="单价"
value={item.price}
onChange={(value) => handleUnitPriceItemChange(index, 'price', value)}
style={{ width: '100%' }}
/>
<InputNumber
min={0}
disabled
value={item.total}
formatter={(value) => `${currencySymbol} ${value}`}
/>
<Button danger size="small" onClick={() => removeUnitPriceItem(index)}>删除</Button>
</div>
))}
{/* 合计行 */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 0.5fr', gap: 12, marginTop: 12, paddingTop: 12, borderTop: '1px solid #e8e8e8' }}>
<div style={{ fontWeight: 'bold' }}>合计</div>
<div></div>
<div></div>
<div></div>
<div style={{ fontWeight: 'bold', color: '#888' }}>{currencySymbol} {totalAmount.toLocaleString()}</div>
<div></div>
</div>
</div>
<Button type="dashed" style={{ width: '100%' }} onClick={() => addUnitPriceItem()}>
+ 添加项目单项
</Button>
</Form.Item>
);
}
return null;
}}
</Form.Item>
<Form.Item
label="开始日期"
name="start_date"
rules={[{ required: true, message: '请选择开始日期' }]}
>
<DatePicker
style={{ width: '100%' }}
onChange={(date) => {
const values = subcontractForm.getFieldsValue();
if (date && values.work_days) {
// 计算结束日期(减去1天,因为后端计算时会加1天)
const endDate = dayjs(date).add(values.work_days - 1, 'day');
subcontractForm.setFieldsValue({ end_date: endDate });
}
}}
/>
</Form.Item>
<Form.Item
label="工期天数"
name="work_days"
rules={[{ required: true, message: '请输入工期天数' }]}
>
<InputNumber
min={1}
placeholder="请输入工期天数"
onChange={(value) => {
const values = subcontractForm.getFieldsValue();
if (value && values.start_date) {
// 计算结束日期(减去1天,因为后端计算时会加1天)
const endDate = dayjs(values.start_date).add(value - 1, 'day');
subcontractForm.setFieldsValue({ end_date: endDate });
}
}}
/>
</Form.Item>
<Form.Item
label="结束日期"
name="end_date"
rules={[{ required: true, message: '请选择结束日期' }]}
>
<DatePicker
style={{ width: '100%' }}
onChange={(date) => {
const values = subcontractForm.getFieldsValue();
if (date && values.start_date) {
// 计算工期天数(加上1天,与后端计算逻辑一致)
const startDate = dayjs(values.start_date);
const endDate = dayjs(date);
const diffDays = endDate.diff(startDate, 'day') + 1;
subcontractForm.setFieldsValue({ work_days: diffDays });
}
}}
/>
</Form.Item>
<Form.Item
label="状态"
name="status"
>
<Select
placeholder="请选择状态"
defaultValue="active"
>
<Select.Option value="active">进行中</Select.Option>
<Select.Option value="completed">已完成</Select.Option>
</Select>
</Form.Item>
</Form>
</div>
</Modal>
{/* 合同编辑模态框 */}
<Modal
title="合同细节录入"
open={contractEditModalVisible}
onCancel={() => setContractEditModalVisible(false)}
onOk={async () => {
try {
// 手动设置合同总价字段,确保在单价结算时也有值
if (settlementType === 'unit_price') {
await contractForm.setFieldsValue({ contract_total: contractTotal });
}
const values = await contractForm.validateFields();
const actualSettlementType = values.settlement_type || settlementType;
const finalContractTotal = actualSettlementType === 'unit_price' ? contractTotal : (values.contract_total || 0);
const saveData = {
project_id: id,
project_overview: values.project_overview,
settlement_type: actualSettlementType,
contract_total: finalContractTotal,
tax_included: values.tax_included || false,
unit_price_items: unitPriceItems,
payment_nodes: paymentNodes,
other_info: values.other_info || '',
contract_file: contractFile
};
// 发送保存请求
const response = await apiClient.put(`/projects/${id}/contract`, saveData);
if (response.data.success) {
message.success('合同细节保存成功');
// 重新获取项目信息
fetchProject();
fetchProjectData();
setContractEditModalVisible(false);
} else {
message.error('保存失败:' + response.data.message);
}
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请检查表单数据');
}
}}
width={900}
okText="保存"
cancelText="取消"
>
<div style={{ padding: 20 }}>
<Form layout="vertical" form={contractForm}>
{/* 工程概况 */}
<Form.Item
label="工程概况"
name="project_overview"
rules={[{ required: true, message: '请输入工程概况' }]}
>
<Input.TextArea rows={4} placeholder="请输入工程概况" />
</Form.Item>
{/* 结算方式 */}
<Form.Item
label="结算方式"
name="settlement_type"
rules={[{ required: true, message: '请选择结算方式' }]}
>
<Select
placeholder="请选择结算方式"
options={[
{ value: 'lump_sum', label: '总价包干' },
{ value: 'unit_price', label: '单价结算' }
]}
onChange={(val) => {
setSettlementType(val);
if (val === 'lump_sum') {
const formTotal = contractForm.getFieldValue('contract_total') || 0;
setContractTotal(formTotal);
}
}}
/>
</Form.Item>
{/* 合同总价 - 只在总价包干时显示 */}
{settlementType === 'lump_sum' && (
<Form.Item
label="合同总价"
name="contract_total"
rules={[{ required: true, message: '请输入合同总价' }]}
>
<InputNumber
min={0}
placeholder="请输入合同总价"
formatter={(value) => ${value}`}
parser={(value) => value?.replace(/¥\s?/, '')}
onChange={(val) => setContractTotal(val || 0)}
/>
</Form.Item>
)}
{/* 隐藏的合同总价字段 - 用于单价结算时的验证 */}
{settlementType === 'unit_price' && (
<Form.Item
name="contract_total"
hidden
>
<InputNumber
min={0}
/>
</Form.Item>
)}
{/* 合同是否含税 */}
<Form.Item
label="合同是否含税"
name="tax_included"
valuePropName="checked"
>
<Switch checkedChildren="是" unCheckedChildren="否" />
</Form.Item>
{/* 单价结算项目 - 只在单价结算时显示 */}
{settlementType === 'unit_price' && (
<Form.Item label="项目单项价">
<Table
dataSource={unitPriceItems}
columns={[
{
title: '项目名称',
dataIndex: 'name',
key: 'name',
render: (_, record, index) => (
<Input
placeholder="请输入项目名称"
value={unitPriceItems[index].name}
onChange={(e) => handleUnitPriceItemChange(index, 'name', e.target.value)}
/>
)
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
render: (_, record, index) => (
<Input
placeholder="请输入单位"
value={unitPriceItems[index].unit}
onChange={(e) => handleUnitPriceItemChange(index, 'unit', e.target.value)}
/>
)
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入数量"
value={unitPriceItems[index].quantity}
onChange={(value) => handleUnitPriceItemChange(index, 'quantity', value)}
/>
)
},
{
title: '单价',
dataIndex: 'price',
key: 'price',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入单价"
value={unitPriceItems[index].price}
onChange={(value) => handleUnitPriceItemChange(index, 'price', value)}
/>
)
},
{
title: '总价',
dataIndex: 'total',
key: 'total',
render: (_, record, index) => (
<InputNumber
min={0}
disabled
value={unitPriceItems[index].total}
/>
)
},
{ title: '操作', key: 'action', render: (_, record, index) => <Button danger onClick={() => removeUnitPriceItem(index)}>删除</Button> }
]}
pagination={false}
locale={{ emptyText: '暂无项目单项' }}
/>
<Button type="dashed" style={{ marginTop: 16 }} onClick={() => addUnitPriceItem()}>添加项目单项</Button>
</Form.Item>
)}
{/* 付款节点 */}
<Form.Item label="付款节点">
<Table
dataSource={paymentNodes}
columns={[
{
title: '节点名称',
dataIndex: 'name',
key: 'name',
render: (_, record, index) => (
<Input
placeholder="请输入节点名称"
value={paymentNodes[index].name}
onChange={(e) => {
const newNodes = [...paymentNodes]
newNodes[index].name = e.target.value
setPaymentNodes(newNodes)
}}
/>
)
},
{
title: '节点条件',
dataIndex: 'condition',
key: 'condition',
render: (_, record, index) => (
<Input
placeholder="请输入节点条件"
value={paymentNodes[index].condition}
onChange={(e) => {
const newNodes = [...paymentNodes]
newNodes[index].condition = e.target.value
setPaymentNodes(newNodes)
}}
/>
)
},
{
title: '比例(%)',
dataIndex: 'percentage',
key: 'percentage',
render: (_, record, index) => (
<InputNumber
min={0}
max={100}
placeholder="请输入比例"
value={paymentNodes[index].percentage}
onChange={(value) => handlePaymentNodePercentageChange(index, value || 0)}
/>
)
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (_, record, index) => (
<InputNumber
min={0}
placeholder="请输入金额"
value={paymentNodes[index].amount}
disabled
/>
)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: () => <Tag color="default">未到达付款节点</Tag>
},
{ title: '操作', key: 'action', render: (_, record, index) => <Button danger onClick={() => { const newNodes = [...paymentNodes]; newNodes.splice(index, 1); setPaymentNodes(newNodes); }}>删除</Button> }
]}
pagination={false}
locale={{ emptyText: '暂无付款节点' }}
/>
<Button type="dashed" style={{ marginTop: 16 }} onClick={() => { setPaymentNodes([...paymentNodes, { key: String(Date.now()), name: '', condition: '', percentage: 0, amount: 0, status: 'pending' }]); }}>添加付款节点</Button>
</Form.Item>
{/* 合同附件 */}
<Form.Item label="合同附件">
<Upload
name="file"
customRequest={handleFileUpload}
listType="text"
maxCount={1}
fileList={contractFile ? [{ uid: '1', name: contractFile.split('/').pop() || '', status: 'done', url: contractFile }] : []}
onRemove={() => setContractFile('')}
>
<Button icon={<UploadOutlined />}>{contractFile ? '更换文件' : '点击上传'}</Button>
</Upload>
</Form.Item>
{/* 其他信息 */}
<Form.Item
label="其他合同信息"
name="other_info"
>
<Input.TextArea rows={4} placeholder="请输入其他合同相关信息" />
</Form.Item>
</Form>
</div>
</Modal>
</div>
)
}
export default ProjectDetail