574 lines
20 KiB
TypeScript
574 lines
20 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, Avatar, Badge, Modal, Input } from 'antd';
|
|
import { ArrowLeftOutlined, EyeOutlined, FileAddOutlined, FileOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import apiClient from '../../utils/request';
|
|
import dayjs from 'dayjs';
|
|
import QuotationCreateModal from './QuotationCreateModal';
|
|
import ContractCreateModal from './ContractCreateModal';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
|
|
const { Title, Paragraph, Text } = Typography;
|
|
|
|
interface Quotation {
|
|
id: number;
|
|
version: number;
|
|
quotation_date: string;
|
|
amount: number;
|
|
currency: string;
|
|
status: 'draft' | 'sent' | 'approved' | 'rejected';
|
|
file_url?: string;
|
|
remark?: string;
|
|
created_at: string;
|
|
}
|
|
|
|
interface BudgetProject {
|
|
id: number;
|
|
name: string;
|
|
customer_id: number;
|
|
customer_name: string;
|
|
manager_id: number;
|
|
manager_name: string;
|
|
location?: string;
|
|
survey_date?: string;
|
|
intermediary?: string;
|
|
intermediary_fee_type?: 'fixed' | 'percentage';
|
|
intermediary_fee_value?: number;
|
|
customer_requirements?: string;
|
|
project_overview?: string;
|
|
attachments?: string[];
|
|
survey_photos?: string[];
|
|
status: 'negotiating' | 'signed' | 'unsigned';
|
|
days_in_status: number;
|
|
created_at: string;
|
|
quotations: Quotation[];
|
|
}
|
|
|
|
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
|
|
CNY: { label: '人民币', symbol: '¥' },
|
|
USD: { label: '美元', symbol: '$' },
|
|
LAK: { label: '老挝基普', symbol: '₭' },
|
|
THB: { label: '泰铢', symbol: '฿' },
|
|
};
|
|
|
|
const BudgetProjectDetail: React.FC = () => {
|
|
const [project, setProject] = useState<BudgetProject | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [quotationModalVisible, setQuotationModalVisible] = useState(false);
|
|
const [contractModalVisible, setContractModalVisible] = useState(false);
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
|
const [deletePassword, setDeletePassword] = useState('');
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
const [quotationDeleteModalVisible, setQuotationDeleteModalVisible] = useState(false);
|
|
const [quotationDeleteId, setQuotationDeleteId] = useState<number | null>(null);
|
|
const navigate = useNavigate();
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
const { user: currentUser } = useAuthStore();
|
|
const isAdmin = currentUser?.role === 'admin' || false;
|
|
|
|
useEffect(() => {
|
|
if (id) {
|
|
fetchProjectDetail();
|
|
}
|
|
}, [id]);
|
|
|
|
const fetchProjectDetail = async () => {
|
|
if (!id) return;
|
|
|
|
setLoading(true);
|
|
try {
|
|
const res = await apiClient.get(`/budget-projects/${id}`);
|
|
if (res.data.success) {
|
|
const projectData = res.data.data;
|
|
// 后端已经解析了数据,直接使用
|
|
projectData.quotations = Array.isArray(projectData.quotations) ? projectData.quotations : [];
|
|
projectData.attachments = Array.isArray(projectData.attachments) ? projectData.attachments : [];
|
|
projectData.survey_photos = Array.isArray(projectData.survey_photos) ? projectData.survey_photos : [];
|
|
setProject(projectData);
|
|
}
|
|
} catch (error) {
|
|
console.error('获取项目详情失败:', error);
|
|
message.error('获取数据失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getStatusTag = (status: string) => {
|
|
const statusMap: Record<string, { color: string; text: string }> = {
|
|
negotiating: { color: 'processing', text: '商谈中' },
|
|
signed: { color: 'success', text: '已签约' },
|
|
unsigned: { color: 'error', text: '未签约' },
|
|
};
|
|
const config = statusMap[status] || { color: 'default', text: status };
|
|
return <Tag color={config.color}>{config.text}</Tag>;
|
|
};
|
|
|
|
const getQuotationStatusTag = (status: string) => {
|
|
const statusMap: Record<string, { color: string; text: string }> = {
|
|
draft: { color: 'default', text: '草稿' },
|
|
sent: { color: 'processing', text: '已发送' },
|
|
approved: { color: 'success', text: '已通过' },
|
|
rejected: { color: 'error', text: '已拒绝' },
|
|
};
|
|
const config = statusMap[status] || { color: 'default', text: status };
|
|
return <Tag color={config.color}>{config.text}</Tag>;
|
|
};
|
|
|
|
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
|
const c = CURRENCIES[currency];
|
|
const symbol = c?.symbol || '¥';
|
|
return `${symbol}${amount.toLocaleString('zh-CN')}`;
|
|
};
|
|
|
|
const handleSign = () => {
|
|
if (!project) return;
|
|
setContractModalVisible(true);
|
|
};
|
|
|
|
const handleContractSuccess = () => {
|
|
setContractModalVisible(false);
|
|
fetchProjectDetail();
|
|
};
|
|
|
|
const handleUnsigned = async () => {
|
|
if (!project) return;
|
|
|
|
try {
|
|
const res = await apiClient.put(`/budget-projects/${project.id}/unsigned`, {}, {
|
|
headers: {
|
|
'x-user-role': currentUser?.role || 'employee'
|
|
}
|
|
});
|
|
if (res.data.success) {
|
|
message.success('标记未签约成功');
|
|
fetchProjectDetail();
|
|
}
|
|
} catch (error) {
|
|
message.error('操作失败');
|
|
}
|
|
};
|
|
|
|
const handleDeleteQuotation = (quotationId: number) => {
|
|
setQuotationDeleteId(quotationId);
|
|
setDeletePassword('');
|
|
setQuotationDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleQuotationDeleteConfirm = async () => {
|
|
if (!project || !quotationDeleteId) return;
|
|
|
|
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
|
if (deletePassword !== 'X123c321@') {
|
|
message.error('密码错误');
|
|
return;
|
|
}
|
|
|
|
setDeleteLoading(true);
|
|
try {
|
|
const res = await apiClient.delete(`/budget-projects/${project.id}/quotations/${quotationDeleteId}`, {
|
|
headers: {
|
|
'x-user-role': currentUser?.role || 'employee'
|
|
}
|
|
});
|
|
if (res.data.success) {
|
|
message.success('删除成功');
|
|
setQuotationDeleteModalVisible(false);
|
|
fetchProjectDetail();
|
|
}
|
|
} catch (error) {
|
|
message.error('删除失败');
|
|
} finally {
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
const openQuotationModal = () => {
|
|
if (project) {
|
|
setQuotationModalVisible(true);
|
|
}
|
|
};
|
|
|
|
const handleQuotationSuccess = () => {
|
|
setQuotationModalVisible(false);
|
|
fetchProjectDetail();
|
|
};
|
|
|
|
const goToProjectManagement = () => {
|
|
if (project) {
|
|
navigate(`/projects/${project.id}`);
|
|
}
|
|
};
|
|
|
|
const handleDeleteProject = () => {
|
|
if (!project) return;
|
|
setDeletePassword('');
|
|
setDeleteModalVisible(true);
|
|
};
|
|
|
|
const handleProjectDeleteConfirm = async () => {
|
|
if (!project) return;
|
|
|
|
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
|
if (deletePassword !== 'X123c321@') {
|
|
message.error('密码错误');
|
|
return;
|
|
}
|
|
|
|
setDeleteLoading(true);
|
|
try {
|
|
const res = await apiClient.delete(`/budget-projects/${project.id}`, {
|
|
headers: {
|
|
'x-user-role': currentUser?.role || 'employee'
|
|
}
|
|
});
|
|
if (res.data.success) {
|
|
message.success('删除成功');
|
|
setDeleteModalVisible(false);
|
|
navigate('/budget-projects');
|
|
}
|
|
} catch (error) {
|
|
message.error('删除失败');
|
|
} finally {
|
|
setDeleteLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Card loading />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Card>
|
|
<Empty description="项目不存在" />
|
|
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
|
|
返回列表
|
|
</Button>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
|
<Button
|
|
icon={<ArrowLeftOutlined />}
|
|
onClick={() => navigate('/budget-projects')}
|
|
>
|
|
返回列表
|
|
</Button>
|
|
<Title level={2} style={{ marginBottom: 0 }}>预算项目详情</Title>
|
|
</div>
|
|
<Paragraph type="secondary">查看项目详细信息和报价版本</Paragraph>
|
|
</div>
|
|
|
|
{/* 项目基本信息 */}
|
|
<Card style={{ marginBottom: 24 }}>
|
|
<Title level={4}>项目信息</Title>
|
|
<Divider />
|
|
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<Descriptions column={1} bordered>
|
|
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
|
|
<Descriptions.Item label="客户">{project.customer_name}</Descriptions.Item>
|
|
<Descriptions.Item label="业务经理">{project.manager_name}</Descriptions.Item>
|
|
<Descriptions.Item label="项目地点">{project.location || '-'}</Descriptions.Item>
|
|
<Descriptions.Item label="勘察日期">{project.survey_date || '-'}</Descriptions.Item>
|
|
<Descriptions.Item label="状态">{getStatusTag(project.status)}</Descriptions.Item>
|
|
<Descriptions.Item label="创建时间">{dayjs(project.created_at).format('YYYY-MM-DD HH:mm:ss')}</Descriptions.Item>
|
|
</Descriptions>
|
|
</Col>
|
|
|
|
<Col xs={24} md={12}>
|
|
<Descriptions column={1} bordered>
|
|
<Descriptions.Item label="居间人">{project.intermediary || '-'}</Descriptions.Item>
|
|
<Descriptions.Item label="居间费类型">
|
|
{project.intermediary_fee_type === 'fixed' ? '固定金额' : project.intermediary_fee_type === 'percentage' ? '百分比' : '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="居间费">
|
|
{project.intermediary_fee_value ?
|
|
project.intermediary_fee_type === 'percentage' ?
|
|
`${project.intermediary_fee_value}%` :
|
|
formatAmount(project.intermediary_fee_value, 'CNY')
|
|
: '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="客户要求">{project.customer_requirements || '-'}</Descriptions.Item>
|
|
<Descriptions.Item label="工程概况">{project.project_overview || '-'}</Descriptions.Item>
|
|
</Descriptions>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* 附件和照片 */}
|
|
<Card style={{ marginBottom: 24 }}>
|
|
<Title level={4}>附件和照片</Title>
|
|
<Divider />
|
|
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={12}>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text strong>附件上传:</Text>
|
|
{project.attachments && project.attachments.length > 0 ? (
|
|
<List
|
|
style={{ marginTop: 8 }}
|
|
dataSource={project.attachments}
|
|
renderItem={(url, index) => {
|
|
const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(url.split('.').pop()?.toLowerCase() || '');
|
|
const handleView = () => {
|
|
if (isOffice) {
|
|
const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
|
|
window.open(previewUrl, '_blank');
|
|
} else {
|
|
window.open(url, '_blank');
|
|
}
|
|
};
|
|
return (
|
|
<List.Item key={index}>
|
|
<Space>
|
|
<FileOutlined />
|
|
<Text ellipsis>{url.split('/').pop() || `file-${index}`}</Text>
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={handleView}
|
|
>
|
|
查看
|
|
</Button>
|
|
</Space>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无附件</Text>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
|
|
<Col xs={24} md={12}>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text strong>勘察照片:</Text>
|
|
{project.survey_photos && project.survey_photos.length > 0 ? (
|
|
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
{project.survey_photos.map((url, index) => (
|
|
<div key={index} style={{ position: 'relative', width: 100, height: 100, border: '1px solid #f0f0f0', borderRadius: 4, overflow: 'hidden' }}>
|
|
<img
|
|
src={url}
|
|
alt={`survey-${index}`}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
onClick={() => window.open(url, '_blank')}
|
|
/>
|
|
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0, 0, 0, 0.5)', color: '#fff', padding: 4, fontSize: 12, textAlign: 'center' }}>
|
|
照片 {index + 1}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>暂无勘察照片</Text>
|
|
)}
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* 报价版本列表 */}
|
|
<Card style={{ marginBottom: 24 }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
<Title level={4}>报价版本</Title>
|
|
{isAdmin && project.status === 'negotiating' && (
|
|
<Button
|
|
type="primary"
|
|
icon={<FileAddOutlined />}
|
|
onClick={openQuotationModal}
|
|
>
|
|
新增报价版本
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Divider />
|
|
|
|
{Array.isArray(project.quotations) && project.quotations.length > 0 ? (
|
|
<List
|
|
itemLayout="horizontal"
|
|
dataSource={project.quotations}
|
|
renderItem={(quotation, index) => {
|
|
const handleViewFile = () => {
|
|
if (quotation.file_url) {
|
|
const isOffice = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(quotation.file_url.split('.').pop()?.toLowerCase() || '');
|
|
if (isOffice) {
|
|
const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(quotation.file_url)}`;
|
|
window.open(previewUrl, '_blank');
|
|
} else {
|
|
window.open(quotation.file_url, '_blank');
|
|
}
|
|
}
|
|
};
|
|
return (
|
|
<List.Item
|
|
key={quotation.id}
|
|
actions={[
|
|
<Button
|
|
size="small"
|
|
icon={<EyeOutlined />}
|
|
onClick={handleViewFile}
|
|
disabled={!quotation.file_url}
|
|
>
|
|
查看
|
|
</Button>,
|
|
isAdmin && (
|
|
<Button
|
|
size="small"
|
|
danger
|
|
onClick={() => handleDeleteQuotation(quotation.id)}
|
|
>
|
|
删除
|
|
</Button>
|
|
)
|
|
].filter(Boolean)}
|
|
>
|
|
<List.Item.Meta
|
|
avatar={<Avatar style={{ backgroundColor: '#1890ff' }}>V{quotation.version}</Avatar>}
|
|
title={
|
|
<Space>
|
|
<Text strong>报价V{quotation.version}</Text>
|
|
{getQuotationStatusTag(quotation.status)}
|
|
</Space>
|
|
}
|
|
description={
|
|
<Space direction="vertical">
|
|
<Text>报价日期: {dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
|
|
<Text>报价金额: {formatAmount(quotation.amount, quotation.currency)}</Text>
|
|
{quotation.remark && <Text>备注: {quotation.remark}</Text>}
|
|
</Space>
|
|
}
|
|
/>
|
|
</List.Item>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
)}
|
|
</Card>
|
|
|
|
{/* 操作按钮 */}
|
|
<Card>
|
|
<Title level={4}>操作</Title>
|
|
<Divider />
|
|
|
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
{isAdmin && project.status === 'negotiating' && (
|
|
<>
|
|
<Button
|
|
type="primary"
|
|
icon={<CheckCircleOutlined />}
|
|
onClick={handleSign}
|
|
>
|
|
标记签约
|
|
</Button>
|
|
<Button
|
|
danger
|
|
icon={<CloseCircleOutlined />}
|
|
onClick={handleUnsigned}
|
|
>
|
|
标记未签约
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{project.status === 'signed' && (
|
|
<Button
|
|
type="primary"
|
|
onClick={goToProjectManagement}
|
|
>
|
|
进入项目管理
|
|
</Button>
|
|
)}
|
|
|
|
{isAdmin && (
|
|
<Button
|
|
danger
|
|
onClick={handleDeleteProject}
|
|
>
|
|
删除项目
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* 新增报价版本弹窗 */}
|
|
<QuotationCreateModal
|
|
visible={quotationModalVisible}
|
|
project={project}
|
|
onCancel={() => setQuotationModalVisible(false)}
|
|
onSuccess={handleQuotationSuccess}
|
|
/>
|
|
|
|
{/* 合同信息录入弹窗 */}
|
|
<ContractCreateModal
|
|
visible={contractModalVisible}
|
|
projectId={project?.id || 0}
|
|
projectName={project?.name || ''}
|
|
onCancel={() => setContractModalVisible(false)}
|
|
onSuccess={handleContractSuccess}
|
|
/>
|
|
|
|
{/* 删除项目确认模态框 */}
|
|
<Modal
|
|
title="删除确认"
|
|
open={deleteModalVisible}
|
|
onOk={handleProjectDeleteConfirm}
|
|
onCancel={() => setDeleteModalVisible(false)}
|
|
confirmLoading={deleteLoading}
|
|
okText="确认删除"
|
|
cancelText="取消"
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<p>确定要删除这个预算项目吗?此操作不可恢复。</p>
|
|
<p>请输入管理员密码确认删除操作:</p>
|
|
</div>
|
|
<Input.Password
|
|
placeholder="请输入管理员密码"
|
|
value={deletePassword}
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
|
size="large"
|
|
/>
|
|
</Modal>
|
|
|
|
{/* 删除报价版本确认模态框 */}
|
|
<Modal
|
|
title="删除确认"
|
|
open={quotationDeleteModalVisible}
|
|
onOk={handleQuotationDeleteConfirm}
|
|
onCancel={() => setQuotationDeleteModalVisible(false)}
|
|
confirmLoading={deleteLoading}
|
|
okText="确认删除"
|
|
cancelText="取消"
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<p>确定要删除这个报价版本吗?此操作不可恢复。</p>
|
|
<p>请输入管理员密码确认删除操作:</p>
|
|
</div>
|
|
<Input.Password
|
|
placeholder="请输入管理员密码"
|
|
value={deletePassword}
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
|
size="large"
|
|
/>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default BudgetProjectDetail; |