备份:修复前完整项目快照 2026-04-19

This commit is contained in:
root
2026-04-19 19:15:01 +08:00
parent 5266d7732b
commit d00f41a120
449 changed files with 89577 additions and 23251 deletions
@@ -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;