备份:修复前完整项目快照 2026-04-19
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, message, Empty, Radio, Popconfirm } from 'antd';
|
||||
import { PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined, DownOutlined, RightOutlined, FileAddOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import QuotationCreateModal from './QuotationCreateModal';
|
||||
|
||||
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 [expandedKeys, setExpandedKeys] = useState<Set<number>>(new Set());
|
||||
const [quotationModalVisible, setQuotationModalVisible] = useState(false);
|
||||
const [selectedProject, setSelectedProject] = useState<BudgetProject | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user: _currentUser } = useAuthStore();
|
||||
// const isAdmin = _currentUser?.role === 'admin';
|
||||
|
||||
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) {
|
||||
setProjects(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预算项目失败:', error);
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProjects = projects.filter(p =>
|
||||
statusFilter === 'all' || p.status === statusFilter
|
||||
);
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
const newSet = new Set(expandedKeys);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
setExpandedKeys(newSet);
|
||||
};
|
||||
|
||||
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 = async (projectId: number) => {
|
||||
try {
|
||||
const res = await axios.put(`/api/budget-projects/${projectId}/sign`);
|
||||
if (res.data.success) {
|
||||
message.success('标记签约成功');
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsigned = async (projectId: number) => {
|
||||
try {
|
||||
const res = await axios.put(`/api/budget-projects/${projectId}/unsigned`);
|
||||
if (res.data.success) {
|
||||
message.success('标记未签约成功');
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteQuotation = async (projectId: number, quotationId: number) => {
|
||||
try {
|
||||
const res = await axios.delete(`/api/budget-projects/${projectId}/quotations/${quotationId}`);
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openQuotationModal = (project: BudgetProject) => {
|
||||
setSelectedProject(project);
|
||||
setQuotationModalVisible(true);
|
||||
};
|
||||
|
||||
const handleQuotationSuccess = () => {
|
||||
setQuotationModalVisible(false);
|
||||
fetchProjects();
|
||||
};
|
||||
|
||||
const goToProjectManagement = (projectId: number) => {
|
||||
navigate(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
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>
|
||||
<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: expandedKeys.has(project.id) ? '1px solid #f0f0f0' : 'none',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => toggleExpand(project.id)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space size="middle">
|
||||
{expandedKeys.has(project.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
<Text strong style={{ fontSize: 16 }}>{project.name}</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{getStatusTag(project.status)}
|
||||
<Text type="secondary">{project.days_in_status}天</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12, marginLeft: 28 }}>
|
||||
<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>
|
||||
|
||||
{/* 展开内容 - 报价版本 */}
|
||||
{expandedKeys.has(project.id) && (
|
||||
<div style={{ padding: '16px 20px', background: '#fff' }}>
|
||||
{project.quotations && project.quotations.length > 0 ? (
|
||||
<div style={{ marginLeft: 28 }}>
|
||||
{project.quotations.map((quotation, index) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 0',
|
||||
borderBottom: index < project.quotations.length - 1 ? '1px solid #f0f0f0' : 'none'
|
||||
}}
|
||||
>
|
||||
<Space size="large">
|
||||
<Text>报价V{quotation.version}</Text>
|
||||
<Text type="secondary">{dayjs(quotation.quotation_date).format('YYYY-MM-DD')}</Text>
|
||||
<Text strong>{formatAmount(quotation.amount, quotation.currency)}</Text>
|
||||
{getQuotationStatusTag(quotation.status)}
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(quotation.file_url, '_blank')}
|
||||
disabled={!quotation.file_url}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
{quotation.status === 'draft' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定删除此报价版本吗?"
|
||||
onConfirm={() => handleDeleteQuotation(project.id, quotation.id)}
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无报价版本" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{project.status === 'negotiating' && (
|
||||
<div style={{ marginTop: 16, marginLeft: 28 }}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<FileAddOutlined />}
|
||||
onClick={() => openQuotationModal(project)}
|
||||
>
|
||||
新增报价版本
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
onClick={() => handleSign(project.id)}
|
||||
>
|
||||
标记签约
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => handleUnsigned(project.id)}
|
||||
>
|
||||
标记未签约
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.status === 'signed' && (
|
||||
<div style={{ marginTop: 16, marginLeft: 28 }}>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
const latestQuotation = project.quotations[project.quotations.length - 1];
|
||||
if (latestQuotation?.file_url) {
|
||||
window.open(latestQuotation.file_url, '_blank');
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => goToProjectManagement(project.id)}
|
||||
>
|
||||
进入项目管理
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 新增报价版本弹窗 */}
|
||||
<QuotationCreateModal
|
||||
visible={quotationModalVisible}
|
||||
project={selectedProject}
|
||||
onCancel={() => setQuotationModalVisible(false)}
|
||||
onSuccess={handleQuotationSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetProjectList;
|
||||
Reference in New Issue
Block a user