备份:修复前完整项目快照 2026-04-19
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Form, Input, Select, DatePicker, InputNumber, Radio, Space, message, Divider, Row, Col } from 'antd';
|
||||
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import FileUpload from '../../components/FileUpload';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
const BudgetProjectCreate: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [form] = Form.useForm();
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [surveyPhotos, setSurveyPhotos] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
// 检查权限,如果不是管理员,重定向到列表页面
|
||||
useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
message.error('您没有权限访问此页面');
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
}, [isAdmin, navigate]);
|
||||
|
||||
// const { user: currentUser } = useAuthStore();
|
||||
|
||||
// 表单监听值
|
||||
const intermediaryFeeType = Form.useWatch('intermediary_fee_type', form);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
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 handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const projectData = {
|
||||
...values,
|
||||
attachments,
|
||||
survey_photos: surveyPhotos,
|
||||
survey_date: values.survey_date?.format('YYYY-MM-DD'),
|
||||
status: 'negotiating',
|
||||
};
|
||||
|
||||
const res = await axios.post('/api/budget-projects', projectData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 创建预算项目需要管理员权限
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('创建成功');
|
||||
navigate('/budget-projects');
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/budget-projects')}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 0 }}>新建商谈项目</Title>
|
||||
</div>
|
||||
<Paragraph type="secondary">创建新的商谈项目,添加项目基本信息</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
intermediary_fee_type: 'fixed',
|
||||
survey_date: dayjs(), // 勘察日期默认为当天
|
||||
attachments: [],
|
||||
survey_photos: []
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Divider orientation="left">基本信息</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="customer_id"
|
||||
label="客户"
|
||||
rules={[{ required: true, message: '请选择客户' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择客户"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
>
|
||||
{customers.map((c) => (
|
||||
<Option key={c.id} value={c.id}>{c.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
name="manager_id"
|
||||
label="业务经理"
|
||||
rules={[{ required: true, message: '请选择业务经理' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择业务经理"
|
||||
showSearch
|
||||
optionFilterProp="children"
|
||||
size="large"
|
||||
>
|
||||
{users.map((u) => (
|
||||
<Option key={u.id} value={u.id}>{u.name} ({u.department || '未知部门'})</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="location" label="项目地点">
|
||||
<Input placeholder="请输入项目地点" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="survey_date" label="勘察日期">
|
||||
<DatePicker style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 居间人信息 */}
|
||||
<Divider orientation="left">居间人信息</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary" label="居间人">
|
||||
<Input placeholder="请输入居间人姓名" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name="intermediary_fee_type" label="居间费类型">
|
||||
<Radio.Group>
|
||||
<Radio value="fixed">固定金额</Radio>
|
||||
<Radio value="percentage">百分比</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item
|
||||
name="intermediary_fee_value"
|
||||
label={intermediaryFeeType === 'percentage' ? '居间费比例(%)' : '居间费金额'}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
min={0}
|
||||
precision={intermediaryFeeType === 'percentage' ? 2 : 0}
|
||||
placeholder={intermediaryFeeType === 'percentage' ? '输入比例,如:5' : '输入金额'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 项目详情 */}
|
||||
<Divider orientation="left">项目详情</Divider>
|
||||
|
||||
<Form.Item name="customer_requirements" label="客户要求">
|
||||
<TextArea rows={4} placeholder="请输入客户的具体要求" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="project_overview" label="工程概况">
|
||||
<TextArea rows={4} placeholder="请输入工程概况描述" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 附件上传 */}
|
||||
<Divider orientation="left">附件</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="附件上传">
|
||||
<FileUpload
|
||||
value={attachments}
|
||||
onChange={setAttachments}
|
||||
accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="勘察照片">
|
||||
<FileUpload
|
||||
value={surveyPhotos}
|
||||
onChange={setSurveyPhotos}
|
||||
accept="image/*"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => navigate('/budget-projects')}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectCreate;
|
||||
@@ -0,0 +1,574 @@
|
||||
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 axios from 'axios';
|
||||
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 axios.get(`/api/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 axios.put(`/api/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 axios.delete(`/api/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 axios.delete(`/api/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;
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Modal, Input } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined } 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;
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
type StatusFilter = 'all' | 'negotiating' | 'signed' | 'unsigned';
|
||||
|
||||
const CURRENCIES: Record<string, { label: string; symbol: string }> = {
|
||||
CNY: { label: '人民币', symbol: '¥' },
|
||||
USD: { label: '美元', symbol: '$' },
|
||||
LAK: { label: '老挝基普', symbol: '₭' },
|
||||
THB: { label: '泰铢', symbol: '฿' },
|
||||
};
|
||||
|
||||
const BudgetProjectList: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<BudgetProject[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
|
||||
const [deletePassword, setDeletePassword] = useState('');
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin' || false;
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/budget-projects');
|
||||
if (res.data.success) {
|
||||
// 后端已经解析了数据,直接使用
|
||||
const projectsWithParsedData = res.data.data.map((project: any) => {
|
||||
return {
|
||||
...project,
|
||||
quotations: Array.isArray(project.quotations) ? project.quotations : [],
|
||||
attachments: Array.isArray(project.attachments) ? project.attachments : [],
|
||||
survey_photos: Array.isArray(project.survey_photos) ? project.survey_photos : []
|
||||
};
|
||||
});
|
||||
setProjects(projectsWithParsedData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算项目失败:', error);
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(p =>
|
||||
statusFilter === 'all' || p.status === statusFilter
|
||||
);
|
||||
|
||||
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 handleDeleteProject = (projectId: number) => {
|
||||
setDeleteProjectId(projectId);
|
||||
setDeletePassword('');
|
||||
setDeleteModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteProjectId) return;
|
||||
|
||||
// 验证密码(这里简单验证,实际项目中应该使用更安全的验证方式)
|
||||
if (deletePassword !== 'X123c321@') {
|
||||
message.error('密码错误');
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const res = await axios.delete(`/api/budget-projects/${deleteProjectId}`, {
|
||||
headers: {
|
||||
'x-user-role': currentUser?.role || 'employee'
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
setDeleteModalVisible(false);
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>预算报价管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>管理商谈项目及报价版本</Paragraph>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => navigate('/budget-projects/create')}
|
||||
size={isMobile ? 'middle' : 'large'}
|
||||
>
|
||||
新建商谈项目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Text strong>状态筛选:</Text>
|
||||
<Radio.Group
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">全部</Radio.Button>
|
||||
<Radio.Button value="negotiating">商谈中</Radio.Button>
|
||||
<Radio.Button value="signed">已签约</Radio.Button>
|
||||
<Radio.Button value="unsigned">未签约</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 项目列表 */}
|
||||
<Card loading={loading}>
|
||||
{filteredProjects.length === 0 ? (
|
||||
<Empty description="暂无数据" />
|
||||
) : (
|
||||
<div>
|
||||
{filteredProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: '#fafafa',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => navigate(`/budget-projects/${project.id}`)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space size="middle">
|
||||
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{getStatusTag(project.status)}
|
||||
<Text type="secondary">{project.days_in_status}天</Text>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteProject(project.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary">客户: {project.customer_name}</Text>
|
||||
<Text type="secondary">业务经理: {project.manager_name}</Text>
|
||||
{project.intermediary && (
|
||||
<Text type="secondary">
|
||||
居间人: {project.intermediary}
|
||||
{project.intermediary_fee_value && (
|
||||
<span> 居间费: {formatAmount(project.intermediary_fee_value, 'CNY')}</span>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
<Modal
|
||||
title="删除确认"
|
||||
open={deleteModalVisible}
|
||||
onOk={handleDeleteConfirm}
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectList;
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, Select, Space, message } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ContractCreateModalProps {
|
||||
visible: boolean;
|
||||
projectId: number;
|
||||
projectName: string;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ContractCreateModal: React.FC<ContractCreateModalProps> = ({
|
||||
visible,
|
||||
projectId,
|
||||
projectName,
|
||||
onCancel,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [contractAmount, setContractAmount] = useState(0);
|
||||
|
||||
// 生成默认的合同编号(包含时间戳确保唯一性)
|
||||
const today = dayjs();
|
||||
const dateStr = today.format('YYYYMMDD');
|
||||
const timeStr = today.format('HHmmss');
|
||||
const defaultContractCode = `CONTRACT-${dateStr}-${timeStr}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.setFieldsValue({
|
||||
contract_code: defaultContractCode,
|
||||
project_name: projectName,
|
||||
contract_method: 'lump_sum',
|
||||
currency: 'CNY',
|
||||
contract_amount: 0,
|
||||
contract_period: 180
|
||||
});
|
||||
setContractAmount(0);
|
||||
}
|
||||
}, [visible, form, projectName]);
|
||||
|
||||
// 处理工期变化
|
||||
const handlePeriodChange = (value: number) => {
|
||||
// 只需要设置工期天数,不需要计算开始和结束日期
|
||||
};
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async (values: any) => {
|
||||
// 构建提交数据(简化版)
|
||||
const submitData = {
|
||||
contract_code: values.contract_code,
|
||||
project_name: values.project_name,
|
||||
contract_method: values.contract_method || 'lump_sum',
|
||||
currency: values.currency || 'CNY',
|
||||
contract_amount: values.contract_amount || 0,
|
||||
contract_period: values.contract_period || 180,
|
||||
warranty_deposit_percentage: 5, // 默认5%
|
||||
warranty_period: 12, // 默认12个月
|
||||
// 其他字段留空,后续在项目管理中补充
|
||||
project_overview: '',
|
||||
other_requirements: '',
|
||||
contract_file: null,
|
||||
payment_nodes: [],
|
||||
unit_price_items: []
|
||||
};
|
||||
|
||||
console.log('提交的合同信息:', submitData);
|
||||
|
||||
try {
|
||||
const res = await axios.put(`/api/budget-projects/${projectId}/sign`, submitData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 签约操作需要管理员权限
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API响应:', res);
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('签约成功,项目已自动创建');
|
||||
onSuccess();
|
||||
onCancel();
|
||||
} else {
|
||||
message.error(res.data.message || '操作失败');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('签约失败:', error);
|
||||
console.error('错误响应:', error.response);
|
||||
const errorMessage = error.response?.data?.message || error.message || '操作失败';
|
||||
message.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="快速签约"
|
||||
open={visible}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
okText="确认签约"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<Form.Item
|
||||
name="contract_code"
|
||||
label="合同编号"
|
||||
rules={[{ required: true, message: '请输入合同编号' }]}
|
||||
>
|
||||
<Input placeholder="请输入合同编号" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="project_name"
|
||||
label="项目名称"
|
||||
rules={[{ required: true, message: '请输入项目名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入项目名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contract_method"
|
||||
label="承包方式"
|
||||
rules={[{ required: true, message: '请选择承包方式' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择承包方式"
|
||||
options={[
|
||||
{ value: 'lump_sum', label: '总价包干' },
|
||||
{ value: 'unit_price', label: '单价结算' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择币种"
|
||||
options={[
|
||||
{ value: 'CNY', label: '人民币' },
|
||||
{ value: 'USD', label: '美元' },
|
||||
{ value: 'LAK', label: '老挝基普' },
|
||||
{ value: 'THB', label: '泰铢' }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="contract_amount"
|
||||
label="总价"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: '请输入总价'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
placeholder="请输入总价"
|
||||
formatter={(value) => `¥ ${value}`}
|
||||
parser={(value) => value.replace(/¥\s?|(,*)/g, '')}
|
||||
onChange={(value) => setContractAmount(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* 工期 */}
|
||||
<Form.Item
|
||||
name="contract_period"
|
||||
label="工期(天)"
|
||||
rules={[{ required: true, message: '请输入工期' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
placeholder="请输入工期(天)"
|
||||
onChange={handlePeriodChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 16, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<p style={{ margin: 0, fontSize: 14, color: '#666' }}>
|
||||
注:此为快速签约流程,仅录入基本信息。详细的合同信息可在项目管理中补充。
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContractCreateModal;
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, DatePicker, InputNumber, Select, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined, DeleteOutlined, FileOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import axios from 'axios';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Quotation {
|
||||
id: number;
|
||||
version: number;
|
||||
quotation_date: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: 'draft' | 'sent' | 'approved' | 'rejected';
|
||||
file_url?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface BudgetProject {
|
||||
id: number;
|
||||
name: string;
|
||||
quotations: Quotation[];
|
||||
}
|
||||
|
||||
interface QuotationCreateModalProps {
|
||||
visible: boolean;
|
||||
project: BudgetProject | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'CNY', label: '人民币', symbol: '¥' },
|
||||
{ value: 'USD', label: '美元', symbol: '$' },
|
||||
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
|
||||
{ value: 'THB', label: '泰铢', symbol: '฿' },
|
||||
];
|
||||
|
||||
const QuotationCreateModal: React.FC<QuotationCreateModalProps> = ({
|
||||
visible,
|
||||
project,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
// 计算下一个版本号
|
||||
const nextVersion = project?.quotations && Array.isArray(project.quotations) && project.quotations.length > 0
|
||||
? Math.max(...project.quotations.map(q => q.version || 0)) + 1
|
||||
: 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
quotation_date: dayjs(),
|
||||
currency: 'CNY',
|
||||
version: nextVersion,
|
||||
});
|
||||
setUploadedFile(null);
|
||||
}
|
||||
}, [visible, nextVersion, form]);
|
||||
|
||||
const handleUpload = async (options: any) => {
|
||||
const { file, onSuccess: onUploadSuccess, onError } = options;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/single', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success('上传成功');
|
||||
setUploadedFile({ url: result.data.url, name: file.name });
|
||||
onUploadSuccess(result.data, file);
|
||||
} else {
|
||||
message.error(result.error || '上传失败');
|
||||
onError?.(new Error(result.error));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setUploadedFile(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const quotationData = {
|
||||
...values,
|
||||
quotation_date: values.quotation_date.format('YYYY-MM-DD'),
|
||||
file_url: uploadedFile?.url,
|
||||
version: nextVersion,
|
||||
};
|
||||
|
||||
const res = await axios.post(`/api/budget-projects/${project.id}/quotations`, quotationData, {
|
||||
headers: {
|
||||
'x-user-role': 'admin' // 创建报价版本需要管理员权限
|
||||
}
|
||||
});
|
||||
if (res.data.success) {
|
||||
message.success('新增报价版本成功');
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.error) {
|
||||
message.error(error.response.data.error);
|
||||
} else {
|
||||
message.error('创建失败');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<FileOutlined style={{ fontSize: 24, color: '#1890ff' }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新增报价版本"
|
||||
open={visible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{/* 项目信息展示 */}
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
marginBottom: 24
|
||||
}}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: '#666' }}>项目名称: </span>
|
||||
<span style={{ fontWeight: 500 }}>{project?.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: '#666' }}>当前版本: </span>
|
||||
<span style={{ fontWeight: 500 }}>V{nextVersion - 1}</span>
|
||||
<span style={{ color: '#999', marginLeft: 8 }}>
|
||||
(新创建将为 V{nextVersion})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="quotation_date"
|
||||
label="报价日期"
|
||||
rules={[{ required: true, message: '请选择报价日期' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="报价金额"
|
||||
rules={[{ required: true, message: '请输入报价金额' }]}
|
||||
>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
precision={2}
|
||||
placeholder="请输入报价金额"
|
||||
addonAfter="元"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="currency"
|
||||
label="币种"
|
||||
rules={[{ required: true, message: '请选择币种' }]}
|
||||
>
|
||||
<Select placeholder="请选择币种">
|
||||
{CURRENCIES.map((c) => (
|
||||
<Option key={c.value} value={c.value}>
|
||||
{c.label} ({c.symbol})
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="报价文件">
|
||||
{uploadedFile ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{getFileIcon()}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>{uploadedFile.name}</div>
|
||||
<a href={uploadedFile.url} target="_blank" rel="noopener noreferrer">
|
||||
查看文件
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleRemoveFile}
|
||||
size="small"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept=".pdf,.doc,.docx,.xlsx,.xls,.jpg,.jpeg,.png"
|
||||
customRequest={handleUpload}
|
||||
showUploadList={false}
|
||||
maxCount={1}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} placeholder="请输入备注信息" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotationCreateModal;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as BudgetProjectList } from "./BudgetProjectList";
|
||||
export { default as BudgetProjectCreate } from "./BudgetProjectCreate";
|
||||
export { default as QuotationCreateModal } from "./QuotationCreateModal";
|
||||
export { default } from "./BudgetProjectList";
|
||||
Reference in New Issue
Block a user