Files
yunhaifinance/frontend/src/pages/budget/BudgetProjectDetail.tsx
T
a273825743 706dcc24eb 备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
2026-06-13 12:44:48 +08:00

565 lines
20 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Space, Tag, message, Empty, Divider, Row, Col, List, Descriptions, 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';
import { useLanguageStore } from '../../store/languageStore';
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 { t, currentLanguage } = useLanguageStore();
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(t('budget.getDataFailed'));
} finally {
setLoading(false);
}
};
const getStatusTag = (status: string) => {
const statusMap: Record<string, { color: string; text: string }> = {
negotiating: { color: 'processing', text: t('budget.inNegotiation') },
signed: { color: 'success', text: t('budget.signed') },
unsigned: { color: 'error', text: t('budget.unsigned') },
};
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: t('budget.draft') },
sent: { color: 'processing', text: t('budget.sent') },
approved: { color: 'success', text: t('budget.approved') },
rejected: { color: 'error', text: t('budget.rejected') },
};
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(t('budget.signedSuccess'));
fetchProjectDetail();
}
} catch (error) {
message.error(t('common.operationFailed'));
}
};
const handleDeleteQuotation = (quotationId: number) => {
setQuotationDeleteId(quotationId);
setDeletePassword('');
setQuotationDeleteModalVisible(true);
};
const handleQuotationDeleteConfirm = async () => {
if (!project || !quotationDeleteId) return;
if (deletePassword !== 'X123c321@') {
message.error(t('budget.passwordError'));
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(t('budget.deleteSuccess'));
setQuotationDeleteModalVisible(false);
fetchProjectDetail();
}
} catch (error) {
message.error(t('common.deleteFailed'));
} 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(t('budget.passwordError'));
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(t('budget.deleteSuccess'));
setDeleteModalVisible(false);
navigate('/budget-projects');
}
} catch (error) {
message.error(t('common.deleteFailed'));
} finally {
setDeleteLoading(false);
}
};
if (loading) {
return (
<div style={{ padding: 24 }}>
<Card loading />
</div>
);
}
if (!project) {
return (
<div style={{ padding: 24 }}>
<Card>
<Empty description={t('budget.notFound')} />
<Button type="primary" onClick={() => navigate('/budget-projects')} style={{ marginTop: 16 }}>
{t('budget.return')}
</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')}
>
{t('budget.return')}
</Button>
<Title level={2} style={{ marginBottom: 0 }}>{t('budget.detailTitle')}</Title>
</div>
<Paragraph type="secondary">{t('budget.detailDesc')}</Paragraph>
</div>
<Card style={{ marginBottom: 24 }}>
<Title level={4}>{t('budget.projectInfo')}</Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<Descriptions column={1} bordered>
<Descriptions.Item label={t('budget.projectName')}>{project.name}</Descriptions.Item>
<Descriptions.Item label={t('budget.customer')}>{project.customer_name}</Descriptions.Item>
<Descriptions.Item label={t('budget.businessManager')}>{project.manager_name}</Descriptions.Item>
<Descriptions.Item label={t('budget.projectLocation')}>{project.location || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.surveyDate')}>{project.survey_date || '-'}</Descriptions.Item>
<Descriptions.Item label={t('common.status')}>{getStatusTag(project.status)}</Descriptions.Item>
<Descriptions.Item label={t('common.create')}>{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={t('budget.intermediaryName')}>{project.intermediary || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.intermediaryType')}>
{project.intermediary_fee_type === 'fixed' ? t('budget.fixedAmount') : project.intermediary_fee_type === 'percentage' ? t('budget.percentage') : '-'}
</Descriptions.Item>
<Descriptions.Item label={t('budget.intermediaryAmount')}>
{project.intermediary_fee_value ?
project.intermediary_fee_type === 'percentage' ?
`${project.intermediary_fee_value}%` :
formatAmount(project.intermediary_fee_value, 'CNY')
: '-'}
</Descriptions.Item>
<Descriptions.Item label={t('budget.customerRequirement')}>{project.customer_requirements || '-'}</Descriptions.Item>
<Descriptions.Item label={t('budget.overview')}>{project.project_overview || '-'}</Descriptions.Item>
</Descriptions>
</Col>
</Row>
</Card>
<Card style={{ marginBottom: 24 }}>
<Title level={4}>{t('budget.attachment')}</Title>
<Divider />
<Row gutter={16}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>{t('budget.attachmentUpload')}:</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}
>
{t('common.view')}
</Button>
</Space>
</List.Item>
);
}}
/>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noAttachment')}</Text>
)}
</div>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 16 }}>
<Text strong>{t('budget.surveyPhoto')}:</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' }}>
{t('budget.photos')}{index + 1}
</div>
</div>
))}
</div>
) : (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>{t('budget.noPhotos')}</Text>
)}
</div>
</Col>
</Row>
</Card>
<Card style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={4}>{t('budget.quotationVersions')}</Title>
{isAdmin && project.status === 'negotiating' && (
<Button
type="primary"
icon={<FileAddOutlined />}
onClick={openQuotationModal}
>
{t('budget.addVersion')}
</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}
>
{t('common.view')}
</Button>,
isAdmin && (
<Button
size="small"
danger
onClick={() => handleDeleteQuotation(quotation.id)}
>
{t('common.delete')}
</Button>
)
].filter(Boolean)}
>
<List.Item.Meta
avatar={<div style={{ width: 40, height: 40, borderRadius: '50%', backgroundColor: '#1890ff', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 'bold' }}>V{quotation.version}</div>}
title={
<Space>
<Text strong>{t('budget.version')}V{quotation.version}</Text>
{getQuotationStatusTag(quotation.status)}
</Space>
}
description={
<Space direction="vertical">
<Text>{t('budget.versionDate')}{dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
<Text>{t('budget.versionAmount')}{formatAmount(quotation.amount, quotation.currency)}</Text>
{quotation.remark && <Text>{t('budget.versionRemark')}{quotation.remark}</Text>}
</Space>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty description={t('budget.noVersion')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</Card>
<Card>
<Title level={4}>{t('common.action')}</Title>
<Divider />
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{isAdmin && project.status === 'negotiating' && (
<>
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={handleSign}
>
{t('budget.markSigned')}
</Button>
<Button
danger
icon={<CloseCircleOutlined />}
onClick={handleUnsigned}
>
{t('budget.markUnsigned')}
</Button>
</>
)}
{project.status === 'signed' && (
<Button
type="primary"
onClick={goToProjectManagement}
>
{t('budget.enterProject')}
</Button>
)}
{isAdmin && (
<Button
danger
onClick={handleDeleteProject}
>
{t('budget.deleteProject')}
</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={t('budget.deleteConfirm')}
open={deleteModalVisible}
onOk={handleProjectDeleteConfirm}
onCancel={() => setDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p>{t('budget.deleteConfirmMsg')}</p>
<p>{t('budget.deletePassMsg')}</p>
</div>
<Input.Password
placeholder={t('budget.deletePass')}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
<Modal
title={t('budget.deleteConfirm')}
open={quotationDeleteModalVisible}
onOk={handleQuotationDeleteConfirm}
onCancel={() => setQuotationDeleteModalVisible(false)}
confirmLoading={deleteLoading}
okText={t('common.deleteConfirm')}
cancelText={t('common.cancel')}
>
<div style={{ marginBottom: 16 }}>
<p>{t('budget.versionDeleteConfirm')}</p>
<p>{t('budget.deletePassMsg')}</p>
</div>
<Input.Password
placeholder={t('budget.deletePass')}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
size="large"
/>
</Modal>
</div>
);
};
export default BudgetProjectDetail;