399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
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;
|