备份:修复前完整项目快照 2026-04-19
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Spin, Layout } from 'antd'
|
||||
|
||||
const { Content } = Layout
|
||||
|
||||
// 懒加载页面组件
|
||||
const ProjectsPage = React.lazy(() => import('./pages/projects/ProjectsPage'))
|
||||
const ProjectDetail = React.lazy(() => import('./pages/projects/ProjectDetail'))
|
||||
|
||||
// 施工管理页面
|
||||
const ConstructionList = React.lazy(() => import('./pages/construction/ConstructionList'))
|
||||
const ConstructionLog = React.lazy(() => import('./pages/construction/ConstructionLog'))
|
||||
const ConstructionMilestones = React.lazy(() => import('./pages/construction/ConstructionMilestones'))
|
||||
const BudgetProjectList = React.lazy(() => import('./pages/budget/BudgetProjectList'))
|
||||
const BudgetProjectCreate = React.lazy(() => import('./pages/budget/BudgetProjectCreate'))
|
||||
|
||||
// 加载中组件
|
||||
const LoadingFallback = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh'
|
||||
}}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
|
||||
// 简单布局
|
||||
const SimpleLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Content style={{ background: '#f0f2f5' }}>
|
||||
{children}
|
||||
</Content>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<SimpleLayout>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* 项目管理路由 */}
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||
|
||||
{/* 预算报价路由 */}
|
||||
<Route path="/budget-projects" element={<BudgetProjectList />} />
|
||||
<Route path="/budget-projects/create" element={<BudgetProjectCreate />} />
|
||||
<Route path="/budget-projects/:id" element={<BudgetProjectList />} />
|
||||
|
||||
{/* 施工管理路由 */}
|
||||
<Route path="/construction" element={<ConstructionList />} />
|
||||
<Route path="/construction/:id/logs" element={<ConstructionLog />} />
|
||||
<Route path="/construction/:id/milestones" element={<ConstructionMilestones />} />
|
||||
|
||||
{/* 默认重定向到项目列表 */}
|
||||
<Route path="/" element={<Navigate to="/projects" replace />} />
|
||||
|
||||
{/* 404页面 */}
|
||||
<Route path="*" element={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: 16
|
||||
}}>
|
||||
<h1>404 - 页面未找到</h1>
|
||||
<p>您访问的页面不存在或已被移除。</p>
|
||||
<a href="/">返回首页</a>
|
||||
</div>
|
||||
} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</SimpleLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Upload, Button, message, Image, Spin } from 'antd';
|
||||
import { UploadOutlined, FileOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
|
||||
interface FileUploadProps {
|
||||
value?: string;
|
||||
onChange?: (url: string) => void;
|
||||
accept?: string;
|
||||
maxSize?: number; // MB
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const FileUpload: React.FC<FileUploadProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
accept = '.pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls',
|
||||
maxSize = 10,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
|
||||
const beforeUpload = (file: File) => {
|
||||
const isLt = file.size / 1024 / 1024 < maxSize;
|
||||
if (!isLt) {
|
||||
message.error(`文件大小不能超过 ${maxSize}MB`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleUpload = async (options: any) => {
|
||||
const { file, onSuccess, onError } = options;
|
||||
setLoading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
message.success('上传成功');
|
||||
onChange?.(result.data.url);
|
||||
onSuccess(result.data, file);
|
||||
} else {
|
||||
message.error(result.error || '上传失败');
|
||||
onError?.(new Error(result.error));
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error('上传失败');
|
||||
onError?.(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange?.('');
|
||||
setFileList([]);
|
||||
};
|
||||
|
||||
// 判断文件类型
|
||||
const getFileType = (url: string) => {
|
||||
const ext = url.split('.').pop()?.toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) {
|
||||
return 'image';
|
||||
}
|
||||
return 'file';
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{value ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{getFileType(value) === 'image' ? (
|
||||
<Image src={value} width={100} height={100} style={{ objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 100,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<FileOutlined style={{ fontSize: 32, color: '#1890ff' }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<a href={value} target="_blank" rel="noopener noreferrer">
|
||||
查看文件
|
||||
</a>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Button danger icon={<DeleteOutlined />} onClick={handleRemove}>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Upload
|
||||
accept={accept}
|
||||
beforeUpload={beforeUpload}
|
||||
customRequest={handleUpload}
|
||||
fileList={fileList}
|
||||
showUploadList={false}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} disabled={disabled}>
|
||||
{loading ? <Spin size="small" /> : '选择文件'}
|
||||
</Button>
|
||||
</Upload>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
@@ -0,0 +1,39 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-card {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-descriptions-bordered .ant-descriptions-item-label {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.ant-btn {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const AdvanceList: React.FC = () => {
|
||||
const [advances, setAdvances] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdvances();
|
||||
fetchProjects();
|
||||
fetchExchangeRates();
|
||||
}, []);
|
||||
|
||||
const fetchAdvances = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/advances');
|
||||
if (res.data.success) setAdvances(res.data.data);
|
||||
} catch (error) {
|
||||
message.error('获取预支列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/projects');
|
||||
if (res.data.success) setProjects(res.data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/latest');
|
||||
if (res.data.success) {
|
||||
const rates: Record<string, number> = {};
|
||||
Object.keys(res.data.data).forEach(key => {
|
||||
rates[key] = parseFloat(res.data.data[key]) || 1;
|
||||
});
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await axios.delete('/api/advances/' + id);
|
||||
message.success('删除成功');
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const data = {
|
||||
...values,
|
||||
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
};
|
||||
if (editingId) {
|
||||
await axios.put('/api/advances/' + editingId, data);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await axios.post('/api/advances', data);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchAdvances();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 汇率换算 - 将外币转换为人民币
|
||||
const convertToCNY = (amount: number, currency: string): number => {
|
||||
if (currency === 'CNY') return amount;
|
||||
// 外币转人民币:需要知道 1外币 = ?人民币
|
||||
// 数据库存的是 CNY_XXX,即 1人民币 = ?外币
|
||||
// 所以 1外币 = 1/rate 人民币
|
||||
const rateKey = 'CNY_' + currency;
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount / rate;
|
||||
};
|
||||
|
||||
// 监听金额和币种变化
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const amountCNY = amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
settled: { color: 'blue', 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 symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '预支编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||||
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||||
{ title: '支出类型', dataIndex: 'expense_type', key: 'expense_type', render: (v: string) => v === 'project' ? '项目支出' : '公用支出' },
|
||||
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '等价人民币', dataIndex: 'amount_cny', key: 'amount_cny', render: (v: number) => <span style={{ color: '#888' }}>{formatAmount(v)}</span> },
|
||||
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (status: string) => getStatusTag(status) },
|
||||
{ title: '操作', key: 'action', width: 180, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>预支管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理员工预支申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建预支</Button>}>
|
||||
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingId ? '编辑预支' : '新建预支'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={600}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
|
||||
<Option value="public">公用支出</Option>
|
||||
<Option value="project">项目支出</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="金额" required>
|
||||
<Space>
|
||||
<Form.Item name="currency" noStyle initialValue="CNY">
|
||||
<Select style={{ width: 120 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<InputNumber
|
||||
style={{ width: 200 }}
|
||||
min={0}
|
||||
precision={2}
|
||||
formatter={v => v ? v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''}
|
||||
parser={v => v ? v.replace(/,/g, '') : ''}
|
||||
placeholder="输入金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
{amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder="请输入预支事由" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvanceList;
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, InputNumber, message, Typography, Divider, Spin, Button, Table, Space, Tag } from 'antd';
|
||||
import { CheckOutlined, HistoryOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const RATE_PAIRS = [
|
||||
{ key: 'CNY_LAK', label: '中老汇率', from: 'CNY', to: 'LAK', fromLabel: '人民币', toLabel: '老挝基普' },
|
||||
{ key: 'CNY_USD', label: '中美汇率', from: 'CNY', to: 'USD', fromLabel: '人民币', toLabel: '美元' },
|
||||
{ key: 'CNY_THB', label: '中泰汇率', from: 'CNY', to: 'THB', fromLabel: '人民币', toLabel: '泰铢' },
|
||||
{ key: 'USD_LAK', label: '美老汇率', from: 'USD', to: 'LAK', fromLabel: '美元', toLabel: '老挝基普' },
|
||||
{ key: 'THB_LAK', label: '泰老汇率', from: 'THB', to: 'LAK', fromLabel: '泰铢', toLabel: '老挝基普' },
|
||||
];
|
||||
|
||||
interface RateItem {
|
||||
inputValue: number;
|
||||
inputSide: 'left' | 'right';
|
||||
}
|
||||
|
||||
interface HistoryRate {
|
||||
id: number;
|
||||
pair_key: string;
|
||||
rate: number;
|
||||
effective_date: string;
|
||||
created_at: string;
|
||||
created_by_name?: string;
|
||||
}
|
||||
|
||||
const ExchangeRateList: React.FC = () => {
|
||||
const [rates, setRates] = useState<Record<string, RateItem>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [historyRates, setHistoryRates] = useState<HistoryRate[]>([]);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const fetchRates = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/latest');
|
||||
if (res.data.success) {
|
||||
const data = res.data.data;
|
||||
const newRates: Record<string, RateItem> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const rate = parseFloat(data[pair.key]) || 1;
|
||||
newRates[pair.key] = { inputValue: rate, inputSide: 'right' };
|
||||
});
|
||||
setRates(newRates);
|
||||
|
||||
// 获取最后更新时间
|
||||
if (res.data.updated_at) {
|
||||
setLastUpdateTime(res.data.updated_at);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取汇率失败');
|
||||
const defaultRates: Record<string, RateItem> = {};
|
||||
RATE_PAIRS.forEach(pair => {
|
||||
const defaultRate = pair.key === 'CNY_LAK' ? 3000 : pair.key === 'CNY_USD' ? 0.14 : pair.key === 'CNY_THB' ? 4.5 : pair.key === 'USD_LAK' ? 21000 : 670;
|
||||
defaultRates[pair.key] = { inputValue: defaultRate, inputSide: 'right' };
|
||||
});
|
||||
setRates(defaultRates);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/history?limit=20');
|
||||
if (res.data.success) {
|
||||
setHistoryRates(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取历史汇率失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 左侧输入 - 右侧保持1
|
||||
const handleLeftChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
setRates(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], inputValue: value, inputSide: 'left' }
|
||||
}));
|
||||
};
|
||||
|
||||
// 右侧输入 - 左侧保持1
|
||||
const handleRightChange = (key: string, value: number | null) => {
|
||||
if (value === null || value <= 0) return;
|
||||
setRates(prev => ({
|
||||
...prev,
|
||||
[key]: { ...prev[key], inputValue: value, inputSide: 'right' }
|
||||
}));
|
||||
};
|
||||
|
||||
// 确认保存
|
||||
const handleConfirm = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 批量保存所有汇率
|
||||
const savePromises = RATE_PAIRS.map(pair => {
|
||||
const item = rates[pair.key];
|
||||
if (!item) return null;
|
||||
|
||||
// 计算实际汇率:1 from = ? to
|
||||
let actualRate: number;
|
||||
if (item.inputSide === 'right') {
|
||||
actualRate = item.inputValue;
|
||||
} else {
|
||||
actualRate = 1 / item.inputValue;
|
||||
}
|
||||
|
||||
return axios.post('/api/exchange-rates', {
|
||||
pair_key: pair.key,
|
||||
rate: actualRate,
|
||||
effective_date: dayjs().format('YYYY-MM-DD')
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(savePromises.filter(Boolean));
|
||||
|
||||
message.success('汇率保存成功');
|
||||
setLastUpdateTime(dayjs().format('YYYY-MM-DD HH:mm:ss'));
|
||||
fetchHistory(); // 刷新历史记录
|
||||
} catch (error) {
|
||||
message.error('保存汇率失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算实际汇率显示
|
||||
const getActualRateDisplay = (item: RateItem) => {
|
||||
let actualRate: number;
|
||||
if (item.inputSide === 'right') {
|
||||
actualRate = item.inputValue;
|
||||
} else {
|
||||
actualRate = 1 / item.inputValue;
|
||||
}
|
||||
|
||||
if (actualRate >= 1) {
|
||||
return '1 : ' + actualRate.toFixed(2);
|
||||
} else {
|
||||
return '1 : ' + actualRate.toFixed(6);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取左侧显示值
|
||||
const getLeftValue = (item: RateItem) => {
|
||||
return item.inputSide === 'left' ? item.inputValue : 1;
|
||||
};
|
||||
|
||||
// 获取右侧显示值
|
||||
const getRightValue = (item: RateItem) => {
|
||||
return item.inputSide === 'right' ? item.inputValue : 1;
|
||||
};
|
||||
|
||||
// 历史汇率表格列
|
||||
const historyColumns = [
|
||||
{
|
||||
title: '汇率对',
|
||||
dataIndex: 'pair_key',
|
||||
key: 'pair_key',
|
||||
render: (key: string) => {
|
||||
const pair = RATE_PAIRS.find(p => p.key === key);
|
||||
return pair?.label || key;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '汇率',
|
||||
dataIndex: 'rate',
|
||||
key: 'rate',
|
||||
render: (rate: number, record: HistoryRate) => {
|
||||
const pair = RATE_PAIRS.find(p => p.key === record.pair_key);
|
||||
return `1 ${pair?.from || ''} = ${parseFloat(rate).toFixed(pair?.key === 'CNY_USD' ? 4 : 2)} ${pair?.to || ''}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '生效日期',
|
||||
dataIndex: 'effective_date',
|
||||
key: 'effective_date',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD')
|
||||
},
|
||||
{
|
||||
title: '设置时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
},
|
||||
{
|
||||
title: '设置人',
|
||||
dataIndex: 'created_by_name',
|
||||
key: 'created_by_name',
|
||||
render: (name: string) => name || '-'
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>汇率管理</Title>
|
||||
<Space>
|
||||
<Text type="secondary">设置各币种汇率,输入任意一侧,另一侧自动为1</Text>
|
||||
{lastUpdateTime && (
|
||||
<Tag color="blue">上次更新: {lastUpdateTime}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{RATE_PAIRS.map(pair => {
|
||||
const item = rates[pair.key];
|
||||
if (!item) return null;
|
||||
return (
|
||||
<Col xs={24} sm={12} lg={8} key={pair.key}>
|
||||
<Card title={pair.label} size="small" style={{ background: '#fafafa' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.fromLabel}</div>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={getLeftValue(item)}
|
||||
onChange={(v) => handleLeftChange(pair.key, v)}
|
||||
precision={6}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ padding: '20px 8px 0', fontSize: 18, color: '#1890ff' }}>:</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ marginBottom: 4, fontSize: 12, color: '#888' }}>{pair.toLabel}</div>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={getRightValue(item)}
|
||||
onChange={(v) => handleRightChange(pair.key, v)}
|
||||
precision={6}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
实际汇率: {getActualRateDisplay(item)}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={handleConfirm}
|
||||
loading={saving}
|
||||
style={{ minWidth: 200 }}
|
||||
>
|
||||
确认保存汇率
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 历史汇率表 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HistoryOutlined />
|
||||
<span>历史汇率记录</span>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginTop: 24 }}
|
||||
>
|
||||
<Table
|
||||
dataSource={historyRates}
|
||||
columns={historyColumns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 16, background: '#fffbe6', borderColor: '#ffe58f' }}>
|
||||
<Text type="warning">
|
||||
提示:输入左侧数值时右侧自动变为1,输入右侧数值时左侧自动变为1。实际汇率显示为 1左侧币种 = X右侧币种。点击"确认保存汇率"按钮保存当前设置。
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExchangeRateList;
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message, Upload } from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const ReimbursementList: React.FC = () => {
|
||||
const [reimbursements, setReimbursements] = useState([]);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReimbursements();
|
||||
fetchProjects();
|
||||
fetchExchangeRates();
|
||||
}, []);
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/reimbursements');
|
||||
if (res.data.success) setReimbursements(res.data.data);
|
||||
} catch (error) {
|
||||
message.error('获取报销列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/projects');
|
||||
if (res.data.success) setProjects(res.data.data);
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/exchange-rates/latest');
|
||||
if (res.data.success) {
|
||||
const rates: Record<string, number> = {};
|
||||
Object.keys(res.data.data).forEach(key => {
|
||||
rates[key] = parseFloat(res.data.data[key]) || 1;
|
||||
});
|
||||
setExchangeRates(rates);
|
||||
}
|
||||
} catch (error) {}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingId(record.id);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
reimbursement_date: record.reimbursement_date ? dayjs(record.reimbursement_date) : null,
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await axios.delete('/api/reimbursements/' + id);
|
||||
message.success('删除成功');
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const data = {
|
||||
...values,
|
||||
reimbursement_date: values.reimbursement_date?.format('YYYY-MM-DD'),
|
||||
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||||
};
|
||||
if (editingId) {
|
||||
await axios.put('/api/reimbursements/' + editingId, data);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await axios.post('/api/reimbursements', data);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalVisible(false);
|
||||
fetchReimbursements();
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 汇率换算
|
||||
const convertToCNY = (amount: number, currency: string): number => {
|
||||
if (currency === 'CNY') return amount;
|
||||
const rateKey = 'CNY_' + currency;
|
||||
const rate = exchangeRates[rateKey] || 1;
|
||||
return amount / rate;
|
||||
};
|
||||
|
||||
// 监听金额和币种变化
|
||||
const amount = Form.useWatch('amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const expenseType = Form.useWatch('expense_type', form);
|
||||
const amountCNY = amount && currency ? convertToCNY(amount, currency) : 0;
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已批准' },
|
||||
rejected: { color: 'error', text: '已拒绝' },
|
||||
paid: { color: 'blue', 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 symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||||
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '报销编号', dataIndex: 'reimbursement_code', key: 'reimbursement_code', width: 120 },
|
||||
{ title: '报销日期', dataIndex: 'reimbursement_date', key: 'reimbursement_date', width: 100 },
|
||||
{ title: '支出类型', dataIndex: 'expense_type', key: 'expense_type', render: (v: string) => v === 'project' ? '项目支出' : '公用支出' },
|
||||
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
|
||||
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '等价人民币', dataIndex: 'amount_cny', key: 'amount_cny', render: (v: number) => <span style={{ color: '#888' }}>{formatAmount(v)}</span> },
|
||||
{ title: '摘要', dataIndex: 'description', key: 'description', ellipsis: true },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (status: string) => getStatusTag(status) },
|
||||
{ title: '操作', key: 'action', width: 180, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||||
</Space>
|
||||
)}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<h2 style={{ marginBottom: 8 }}>报销管理</h2>
|
||||
<p style={{ color: '#888', marginBottom: 0 }}>管理员工报销申请</p>
|
||||
</div>
|
||||
|
||||
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建报销</Button>}>
|
||||
<Table dataSource={reimbursements} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
<Modal title={editingId ? '编辑报销' : '新建报销'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={600}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="reimbursement_date" label="报销日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
|
||||
<Option value="public">公用支出</Option>
|
||||
<Option value="project">项目支出</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{expenseType === 'project' && (
|
||||
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||||
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label="金额" required>
|
||||
<Space>
|
||||
<Form.Item name="currency" noStyle initialValue="CNY">
|
||||
<Select style={{ width: 120 }}>
|
||||
<Option value="CNY">人民币 (CNY)</Option>
|
||||
<Option value="USD">美元 (USD)</Option>
|
||||
<Option value="LAK">老挝基普 (LAK)</Option>
|
||||
<Option value="THB">泰铢 (THB)</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||||
<InputNumber
|
||||
style={{ width: 200 }}
|
||||
min={0}
|
||||
precision={2}
|
||||
formatter={v => v ? v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''}
|
||||
parser={v => v ? v.replace(/,/g, '') : ''}
|
||||
placeholder="输入金额"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
{amountCNY > 0 && (
|
||||
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||||
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="摘要" rules={[{ required: true }]}>
|
||||
<TextArea rows={3} placeholder="请输入报销摘要" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remarks" label="备注">
|
||||
<TextArea rows={2} placeholder="请输入备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReimbursementList;
|
||||
@@ -0,0 +1,262 @@
|
||||
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';
|
||||
|
||||
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 navigate = useNavigate();
|
||||
|
||||
// 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,
|
||||
survey_date: values.survey_date?.format('YYYY-MM-DD'),
|
||||
status: 'negotiating',
|
||||
};
|
||||
|
||||
const res = await axios.post('/api/budget-projects', projectData);
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<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 name="attachments" label="附件上传">
|
||||
<Input type="file" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.xlsx,.xls" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item name="survey_photos" label="勘察照片">
|
||||
<Input type="file" multiple 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,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;
|
||||
@@ -0,0 +1,253 @@
|
||||
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?.length
|
||||
? Math.max(...project.quotations.map(q => q.version)) + 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', {
|
||||
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);
|
||||
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,3 @@
|
||||
export { default as BudgetProjectList } from './BudgetProjectList';
|
||||
export { default as BudgetProjectCreate } from './BudgetProjectCreate';
|
||||
export { default as QuotationCreateModal } from './QuotationCreateModal';
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Tag, Progress, Empty, Spin, message, Row, Col, Divider } from 'antd';
|
||||
import { FileTextOutlined, CameraOutlined, ScheduleOutlined, PlusOutlined, ReloadOutlined } 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;
|
||||
|
||||
// 天气图标映射
|
||||
const WEATHER_ICONS: Record<string, string> = {
|
||||
sunny: '☀️ 晴',
|
||||
cloudy: '⛅ 多云',
|
||||
rainy: '🌧️ 雨',
|
||||
stormy: '⛈️ 雷暴',
|
||||
windy: '💨 大风',
|
||||
};
|
||||
|
||||
// 项目状态映射
|
||||
const STATUS_CONFIG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待开始' },
|
||||
active: { color: 'processing', text: '施工中' },
|
||||
completed: { color: 'success', text: '完工' },
|
||||
suspended: { color: 'warning', text: '暂停' },
|
||||
cancelled: { color: 'error', text: '已取消' },
|
||||
};
|
||||
|
||||
interface Project {
|
||||
id: number;
|
||||
project_code: string;
|
||||
name: string;
|
||||
customer_name: string;
|
||||
status: string;
|
||||
start_date: string;
|
||||
expected_end_date: string;
|
||||
contract_amount: number;
|
||||
currency: string;
|
||||
manager_name: string;
|
||||
progress_percentage: number;
|
||||
latest_log?: {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
work_content: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ConstructionList: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
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/construction/my-projects');
|
||||
if (res.data.success) {
|
||||
setProjects(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目列表失败:', error);
|
||||
message.error('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string = 'CNY') => {
|
||||
const symbols: Record<string, string> = {
|
||||
CNY: '¥',
|
||||
USD: '$',
|
||||
LAK: '₭',
|
||||
THB: '฿',
|
||||
};
|
||||
const symbol = symbols[currency] || '¥';
|
||||
return `${symbol}${(amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0 })}`;
|
||||
};
|
||||
|
||||
const isToday = (dateStr: string) => {
|
||||
return dayjs(dateStr).isSame(dayjs(), 'day');
|
||||
};
|
||||
|
||||
const renderProjectCard = (project: Project) => {
|
||||
const statusConfig = STATUS_CONFIG[project.status] || STATUS_CONFIG.pending;
|
||||
const hasTodayLog = project.latest_log && isToday(project.latest_log.log_date);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={project.id}
|
||||
style={{
|
||||
marginBottom: isMobile ? 12 : 16,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 项目头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 20 }}>🎯</span>
|
||||
<Text strong style={{ fontSize: isMobile ? 15 : 16 }}>{project.name}</Text>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
客户: {project.customer_name || '未指定'}
|
||||
</Text>
|
||||
</div>
|
||||
<Tag color={statusConfig.color} style={{ marginLeft: 8 }}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>施工进度</Text>
|
||||
<Text strong style={{ fontSize: 12 }}>{Math.round(project.progress_percentage)}%</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.round(project.progress_percentage)}
|
||||
showInfo={false}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 最新日志状态 */}
|
||||
{project.status === 'active' && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: hasTodayLog ? '#f6ffed' : '#fff7e6',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
{hasTodayLog ? (
|
||||
<>
|
||||
<span>✅</span>
|
||||
<Text style={{ fontSize: 13 }}>
|
||||
今日日志: {project.latest_log?.work_content?.substring(0, 30)}...
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>⚠️</span>
|
||||
<Text type="warning" style={{ fontSize: 13 }}>今日日志: 未填写</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
type={project.status === 'active' && !hasTodayLog ? 'primary' : 'default'}
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{project.status === 'active' && !hasTodayLog ? '📝 写今日日志' : '📝 施工日志'}
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<CameraOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/logs`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📷 上传照片
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Button
|
||||
icon={<ScheduleOutlined />}
|
||||
onClick={() => navigate(`/construction/${project.id}/milestones`)}
|
||||
block
|
||||
size={isMobile ? 'large' : 'middle'}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
📋 节点进度
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面标题 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 0 }}>施工管理</Title>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={fetchProjects}
|
||||
loading={loading}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||
查看和管理您的施工项目
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* 项目列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
<Paragraph type="secondary" style={{ marginTop: 16 }}>加载中...</Paragraph>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="暂无施工项目"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Text type="secondary">请联系管理员为您分配施工项目</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginBottom: 12, display: 'block' }}>
|
||||
我的施工项目 ({projects.length})
|
||||
</Text>
|
||||
{projects.map(project => renderProjectCard(project))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionList;
|
||||
@@ -0,0 +1,442 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Typography, Button, Space, Modal, Form, Input, DatePicker, Select,
|
||||
Upload, message, Spin, Empty, Image, Tag, Divider, Popconfirm, Row, Col
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined, ArrowLeftOutlined, DeleteOutlined,
|
||||
CameraOutlined, CalendarOutlined, CloudOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
// 天气选项
|
||||
const WEATHER_OPTIONS = [
|
||||
{ value: 'sunny', label: '☀️ 晴', icon: '☀️' },
|
||||
{ value: 'cloudy', label: '⛅ 多云', icon: '⛅' },
|
||||
{ value: 'rainy', label: '🌧️ 雨', icon: '🌧️' },
|
||||
{ value: 'stormy', label: '⛈️ 雷暴', icon: '⛈️' },
|
||||
{ value: 'windy', label: '💨 大风', icon: '💨' },
|
||||
];
|
||||
|
||||
interface Log {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
work_content: string;
|
||||
next_plan: string;
|
||||
issues: string;
|
||||
recorder_name: string;
|
||||
photos: Photo[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: number;
|
||||
photo_url: string;
|
||||
photo_name: string;
|
||||
photo_type: string;
|
||||
file_size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ConstructionLog: React.FC = () => {
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchLogs();
|
||||
fetchProjectInfo();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/construction/projects/${projectId}/logs`);
|
||||
if (res.data.success) {
|
||||
setLogs(res.data.data.list);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取日志列表失败:', error);
|
||||
message.error('获取日志列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId}`);
|
||||
if (res.data.success) {
|
||||
setProjectInfo(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
const res = await axios.post(`/api/construction/projects/${projectId}/logs`, {
|
||||
log_date: values.log_date.format('YYYY-MM-DD'),
|
||||
weather: values.weather,
|
||||
work_content: values.work_content,
|
||||
next_plan: values.next_plan,
|
||||
issues: values.issues,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
message.success('日志添加成功');
|
||||
setModalVisible(false);
|
||||
form.resetFields();
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加日志失败:', error);
|
||||
message.error('添加日志失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteLog = async (logId: number) => {
|
||||
try {
|
||||
const res = await axios.delete(`/api/construction/logs/${logId}`);
|
||||
if (res.data.success) {
|
||||
message.success('日志删除成功');
|
||||
fetchLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除日志失败:', error);
|
||||
message.error('删除日志失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 按日期分组
|
||||
const groupedLogs = logs.reduce((acc, log) => {
|
||||
const month = dayjs(log.log_date).format('YYYY年MM月');
|
||||
if (!acc[month]) {
|
||||
acc[month] = [];
|
||||
}
|
||||
acc[month].push(log);
|
||||
return acc;
|
||||
}, {} as Record<string, Log[]>);
|
||||
|
||||
const getWeatherLabel = (value: string) => {
|
||||
const option = WEATHER_OPTIONS.find(o => o.value === value);
|
||||
return option ? option.label : value;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const renderLogCard = (log: Log) => (
|
||||
<Card
|
||||
key={log.id}
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
styles={{ body: { padding: isMobile ? 16 : 20 } }}
|
||||
>
|
||||
{/* 日志头部 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<CalendarOutlined style={{ color: '#1890ff' }} />
|
||||
<Text strong style={{ fontSize: 15 }}>{dayjs(log.log_date).format('MM月DD日')}</Text>
|
||||
<Tag color="blue">{getWeatherLabel(log.weather)}</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>记录人: {log.recorder_name || '未知'}</Text>
|
||||
<Popconfirm
|
||||
title="确定删除此日志?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => handleDeleteLog(log.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 工作内容 */}
|
||||
{log.work_content && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>今日工作:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.work_content}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 明日计划 */}
|
||||
{log.next_plan && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>明日计划:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap' }}>
|
||||
{log.next_plan}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 问题记录 */}
|
||||
{log.issues && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>问题记录:</Text>
|
||||
<Paragraph style={{ margin: '4px 0 0', color: '#fa8c16', whiteSpace: 'pre-wrap' }}>
|
||||
{log.issues}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 照片展示 */}
|
||||
{log.photos && log.photos.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
||||
施工照片 ({log.photos.length}张):
|
||||
</Text>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{log.photos.map(photo => (
|
||||
<Image
|
||||
key={photo.id}
|
||||
src={photo.photo_url}
|
||||
width={isMobile ? 80 : 100}
|
||||
height={isMobile ? 80 : 100}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
placeholder={
|
||||
<div style={{
|
||||
width: isMobile ? 80 : 100,
|
||||
height: isMobile ? 80 : 100,
|
||||
background: '#f0f0f0',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
<CameraOutlined style={{ fontSize: 24, color: '#bfbfbf' }} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/construction')}
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
施工日志
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{projectInfo.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志列表 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工日志">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalVisible(true)}>
|
||||
添加第一条日志
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
{Object.entries(groupedLogs).map(([month, monthLogs]) => (
|
||||
<div key={month}>
|
||||
<Divider orientation="left" style={{ margin: '16px 0' }}>
|
||||
<Text strong style={{ fontSize: 14 }}>{month}</Text>
|
||||
</Divider>
|
||||
{monthLogs.map(log => renderLogCard(log))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部添加按钮 */}
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 100
|
||||
}}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
size="large"
|
||||
onClick={() => setModalVisible(true)}
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
height: 48,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)'
|
||||
}}
|
||||
>
|
||||
新增日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 新增日志弹窗 */}
|
||||
<Modal
|
||||
title="新增施工日志"
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
confirmLoading={submitting}
|
||||
okText="提交"
|
||||
cancelText="取消"
|
||||
width={isMobile ? '95%' : 500}
|
||||
style={{ top: 20 }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
log_date: dayjs(),
|
||||
weather: 'sunny'
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="log_date"
|
||||
label="日期"
|
||||
rules={[{ required: true, message: '请选择日期' }]}
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
disabledDate={(current) => current && current > dayjs().endOf('day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="weather"
|
||||
label="天气"
|
||||
rules={[{ required: true, message: '请选择天气' }]}
|
||||
>
|
||||
<Select size="large">
|
||||
{WEATHER_OPTIONS.map(opt => (
|
||||
<Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item
|
||||
name="work_content"
|
||||
label="今日工作"
|
||||
rules={[{ required: true, message: '请填写今日工作内容' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="描述今日完成的施工工作..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="next_plan" label="明日计划">
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="明日工作计划..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="issues" label="问题记录">
|
||||
<TextArea
|
||||
rows={2}
|
||||
placeholder="遇到的问题或需要协调的事项..."
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="上传照片">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
multiple
|
||||
maxCount={9}
|
||||
accept="image/*"
|
||||
beforeUpload={() => false}
|
||||
>
|
||||
<div>
|
||||
<CameraOutlined style={{ fontSize: 20 }} />
|
||||
<div style={{ marginTop: 4, fontSize: 12 }}>添加照片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
支持上传多张照片,最多9张
|
||||
</Text>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionLog;
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Typography, Button, Space, Tag, Spin, Empty, Timeline, Progress, Divider
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, CheckCircleOutlined, ClockCircleOutlined,
|
||||
SyncOutlined, CloseCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 节点状态配置
|
||||
const STATUS_CONFIG: Record<string, {
|
||||
color: string;
|
||||
text: string;
|
||||
icon: React.ReactNode;
|
||||
timelineColor: string;
|
||||
}> = {
|
||||
pending: {
|
||||
color: 'default',
|
||||
text: '待开始',
|
||||
icon: <ClockCircleOutlined />,
|
||||
timelineColor: 'gray'
|
||||
},
|
||||
in_progress: {
|
||||
color: 'processing',
|
||||
text: '进行中',
|
||||
icon: <SyncOutlined spin />,
|
||||
timelineColor: 'blue'
|
||||
},
|
||||
completed: {
|
||||
color: 'success',
|
||||
text: '已完成',
|
||||
icon: <CheckCircleOutlined />,
|
||||
timelineColor: 'green'
|
||||
},
|
||||
cancelled: {
|
||||
color: 'error',
|
||||
text: '已取消',
|
||||
icon: <CloseCircleOutlined />,
|
||||
timelineColor: 'red'
|
||||
},
|
||||
};
|
||||
|
||||
interface Milestone {
|
||||
id: number;
|
||||
node_name: string;
|
||||
node_type: string;
|
||||
status: string;
|
||||
due_date: string;
|
||||
trigger_condition: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const ConstructionMilestones: React.FC = () => {
|
||||
const { id: projectId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [projectInfo, setProjectInfo] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
fetchMilestones();
|
||||
fetchProjectInfo();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const fetchMilestones = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get(`/api/construction/projects/${projectId}/milestones`);
|
||||
if (res.data.success) {
|
||||
setMilestones(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取节点列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProjectInfo = async () => {
|
||||
try {
|
||||
const res = await axios.get(`/api/projects/${projectId}`);
|
||||
if (res.data.success) {
|
||||
setProjectInfo(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目信息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算进度
|
||||
const completedCount = milestones.filter(m => m.status === 'completed').length;
|
||||
const totalCount = milestones.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
const renderTimelineItem = (milestone: Milestone, index: number) => {
|
||||
const statusConfig = STATUS_CONFIG[milestone.status] || STATUS_CONFIG.pending;
|
||||
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={milestone.id}
|
||||
color={statusConfig.timelineColor}
|
||||
dot={
|
||||
<span style={{ fontSize: 16 }}>
|
||||
{statusConfig.icon}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
background: milestone.status === 'completed' ? '#f6ffed' : '#fff',
|
||||
}}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 14 }}>{milestone.node_name}</Text>
|
||||
{milestone.trigger_condition && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ margin: '4px 0 0', fontSize: 12 }}
|
||||
>
|
||||
{milestone.trigger_condition}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Tag color={statusConfig.color} icon={statusConfig.icon}>
|
||||
{statusConfig.text}
|
||||
</Tag>
|
||||
</div>
|
||||
{milestone.due_date && (
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
计划完成: {dayjs(milestone.due_date).format('YYYY-MM-DD')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Timeline.Item>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: isMobile ? 12 : 24,
|
||||
maxWidth: 800,
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: isMobile ? 16 : 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/construction')}
|
||||
/>
|
||||
<div>
|
||||
<Title level={isMobile ? 4 : 3} style={{ margin: 0 }}>
|
||||
节点进度
|
||||
</Title>
|
||||
{projectInfo && (
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{projectInfo.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度概览 */}
|
||||
{!loading && milestones.length > 0 && (
|
||||
<Card style={{ marginBottom: 16, borderRadius: 12 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Text type="secondary">整体进度</Text>
|
||||
<Title level={2} style={{ margin: '8px 0 0' }}>{progressPercent}%</Title>
|
||||
</div>
|
||||
<Progress
|
||||
percent={progressPercent}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginTop: 16 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已完成</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount - completedCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>进行中</Text>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text strong style={{ fontSize: 20 }}>{totalCount}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>总节点</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 节点时间线 */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : milestones.length === 0 ? (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Empty description="暂无施工节点">
|
||||
<Text type="secondary">节点由项目经理在项目设置中配置</Text>
|
||||
</Empty>
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ borderRadius: 12 }}>
|
||||
<Timeline style={{ marginTop: 16 }}>
|
||||
{milestones.map((milestone, index) => renderTimelineItem(milestone, index))}
|
||||
</Timeline>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConstructionMilestones;
|
||||
@@ -0,0 +1,11 @@
|
||||
// 页面导出
|
||||
export { default as AdvanceList } from './AdvanceList'
|
||||
export { default as ReimbursementList } from './ReimbursementList'
|
||||
export { default as ExchangeRateList } from './ExchangeRateList'
|
||||
export { default as ProjectsPage } from './projects/ProjectsPage'
|
||||
export { default as ProjectDetail } from './projects/ProjectDetail'
|
||||
|
||||
// 施工管理页面
|
||||
export { default as ConstructionList } from './construction/ConstructionList'
|
||||
export { default as ConstructionLog } from './construction/ConstructionLog'
|
||||
export { default as ConstructionMilestones } from './construction/ConstructionMilestones'
|
||||
@@ -0,0 +1,883 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Tabs, Typography, Button, Space, Table, Tag, Spin, Descriptions, message,
|
||||
Row, Col, Divider, Modal, Form, Input, InputNumber, DatePicker, Select, Upload,
|
||||
Image, Empty, Statistic, Progress, Popconfirm
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined, PlusOutlined, EditOutlined, UploadOutlined, DeleteOutlined,
|
||||
FileOutlined, PictureOutlined, CloudOutlined, SunOutlined, CloudFilled
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'CNY', label: '人民币', symbol: '¥' },
|
||||
{ value: 'USD', label: '美元', symbol: '$' },
|
||||
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
|
||||
{ value: 'THB', label: '泰铢', symbol: '฿' },
|
||||
];
|
||||
|
||||
// 材料管理接口
|
||||
interface Material {
|
||||
id: number;
|
||||
product_name: string;
|
||||
unit: string;
|
||||
budget_quantity: number;
|
||||
purchase_quantity: number;
|
||||
used_quantity: number;
|
||||
avg_price: number;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
// 施工节点接口
|
||||
interface Milestone {
|
||||
id: number;
|
||||
node_name: string;
|
||||
percentage: number;
|
||||
node_amount: number;
|
||||
trigger_condition: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
completed_date?: string;
|
||||
voucher_url?: string;
|
||||
}
|
||||
|
||||
// 施工日志接口
|
||||
interface ConstructionLog {
|
||||
id: number;
|
||||
log_date: string;
|
||||
weather: string;
|
||||
recorder_name: string;
|
||||
work_content: string;
|
||||
photos: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 工作项接口
|
||||
interface WorkItem {
|
||||
id: number;
|
||||
item_name: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
// 项目详情接口
|
||||
interface ProjectDetail {
|
||||
id: number;
|
||||
project_code: string;
|
||||
name: string;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
project_manager_id: number;
|
||||
manager_name: string;
|
||||
status: string;
|
||||
settlement_type: 'total' | 'unit';
|
||||
currency: string;
|
||||
contract_amount: number;
|
||||
work_quantity: string;
|
||||
project_situation: string;
|
||||
customer_requirements: string;
|
||||
start_date: string;
|
||||
expected_end_date: string;
|
||||
contract_days: number;
|
||||
contract_file: string;
|
||||
attachments: { name: string; url: string }[];
|
||||
payment_nodes: Milestone[];
|
||||
unit_price_list: WorkItem[];
|
||||
warranty_rate: number;
|
||||
warranty_amount: number;
|
||||
warranty_status: string;
|
||||
// 财务汇总
|
||||
total_income: number;
|
||||
total_expense: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
const ProjectDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [project, setProject] = useState<ProjectDetail | null>(null);
|
||||
const [materials, setMaterials] = useState<Material[]>([]);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [constructionLogs, setConstructionLogs] = useState<ConstructionLog[]>([]);
|
||||
const [workItems, setWorkItems] = useState<WorkItem[]>([]);
|
||||
|
||||
// Modal 状态
|
||||
const [logModalVisible, setLogModalVisible] = useState(false);
|
||||
const [voucherModalVisible, setVoucherModalVisible] = useState(false);
|
||||
const [selectedMilestone, setSelectedMilestone] = useState<Milestone | null>(null);
|
||||
const [logForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchProjectData();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchProjectData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 并行获取所有数据
|
||||
const [projectRes, materialsRes, logsRes, milestonesRes, workItemsRes] = await Promise.all([
|
||||
axios.get(`/api/projects/${id}`),
|
||||
axios.get(`/api/projects/${id}/materials`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/api/projects/${id}/construction-logs`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/api/projects/${id}/milestones`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
axios.get(`/api/projects/${id}/work-items`).catch(() => ({ data: { success: false, data: [] } })),
|
||||
]);
|
||||
|
||||
if (projectRes.data.success) {
|
||||
setProject(projectRes.data.data);
|
||||
// 如果项目包含付款节点,使用项目数据
|
||||
if (projectRes.data.data.payment_nodes) {
|
||||
setMilestones(projectRes.data.data.payment_nodes);
|
||||
}
|
||||
if (projectRes.data.data.unit_price_list) {
|
||||
setWorkItems(projectRes.data.data.unit_price_list);
|
||||
}
|
||||
}
|
||||
|
||||
if (materialsRes.data.success) {
|
||||
setMaterials(materialsRes.data.data);
|
||||
}
|
||||
|
||||
if (logsRes.data.success) {
|
||||
setConstructionLogs(logsRes.data.data);
|
||||
}
|
||||
|
||||
if (milestonesRes.data.success && milestonesRes.data.data.length > 0) {
|
||||
setMilestones(milestonesRes.data.data);
|
||||
}
|
||||
|
||||
if (workItemsRes.data.success && workItemsRes.data.data.length > 0) {
|
||||
setWorkItems(workItemsRes.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取项目数据失败:', error);
|
||||
message.error('获取项目数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatAmount = (val: number, curr: string = 'CNY') => {
|
||||
const c = CURRENCIES.find(item => item.value === curr);
|
||||
const symbol = c?.symbol || '¥';
|
||||
return symbol + ' ' + (val || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'default', text: '规划中' },
|
||||
active: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
suspended: { color: 'warning', text: '已暂停' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getMilestoneStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待开始' },
|
||||
in_progress: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const getWeatherIcon = (weather: string) => {
|
||||
const weatherMap: Record<string, React.ReactNode> = {
|
||||
'晴': <SunOutlined style={{ color: '#faad14' }} />,
|
||||
'多云': <CloudOutlined style={{ color: '#1890ff' }} />,
|
||||
'阴': <CloudFilled style={{ color: '#8c8c8c' }} />,
|
||||
'雨': <CloudFilled style={{ color: '#52c41a' }} />,
|
||||
};
|
||||
return weatherMap[weather] || <CloudOutlined />;
|
||||
};
|
||||
|
||||
// 上传凭证
|
||||
const handleUploadVoucher = async (file: File) => {
|
||||
if (!selectedMilestone) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
// 更新节点凭证
|
||||
await axios.put(`/api/projects/${id}/milestones/${selectedMilestone.id}`, {
|
||||
voucher_url: res.data.data.url,
|
||||
status: 'completed',
|
||||
completed_date: dayjs().format('YYYY-MM-DD')
|
||||
});
|
||||
message.success('凭证上传成功');
|
||||
setVoucherModalVisible(false);
|
||||
fetchProjectData();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 提交施工日志
|
||||
const handleSubmitLog = async () => {
|
||||
try {
|
||||
const values = await logForm.validateFields();
|
||||
const logData = {
|
||||
...values,
|
||||
log_date: values.log_date.format('YYYY-MM-DD'),
|
||||
project_id: id,
|
||||
};
|
||||
|
||||
const res = await axios.post(`/api/projects/${id}/construction-logs`, logData);
|
||||
if (res.data.success) {
|
||||
message.success('日志添加成功');
|
||||
setLogModalVisible(false);
|
||||
logForm.resetFields();
|
||||
fetchProjectData();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('添加失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除施工日志
|
||||
const handleDeleteLog = async (logId: number) => {
|
||||
try {
|
||||
const res = await axios.delete(`/api/projects/${id}/construction-logs/${logId}`);
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
fetchProjectData();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 材料管理表格列
|
||||
const materialColumns = [
|
||||
{ title: '商品名称', dataIndex: 'product_name', key: 'product_name' },
|
||||
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 80 },
|
||||
{
|
||||
title: '预算量',
|
||||
dataIndex: 'budget_quantity',
|
||||
key: 'budget_quantity',
|
||||
render: (v: number) => v?.toLocaleString() || '-'
|
||||
},
|
||||
{
|
||||
title: '采购量',
|
||||
dataIndex: 'purchase_quantity',
|
||||
key: 'purchase_quantity',
|
||||
render: (v: number) => v?.toLocaleString() || '-'
|
||||
},
|
||||
{
|
||||
title: '使用量',
|
||||
dataIndex: 'used_quantity',
|
||||
key: 'used_quantity',
|
||||
render: (v: number) => v?.toLocaleString() || '-'
|
||||
},
|
||||
{
|
||||
title: '均价',
|
||||
dataIndex: 'avg_price',
|
||||
key: 'avg_price',
|
||||
render: (v: number, r: Material) => formatAmount(v, project?.currency)
|
||||
},
|
||||
{
|
||||
title: '总价',
|
||||
dataIndex: 'total_price',
|
||||
key: 'total_price',
|
||||
render: (v: number, r: Material) => <Text strong>{formatAmount(v, project?.currency)}</Text>
|
||||
},
|
||||
];
|
||||
|
||||
// 施工节点表格列
|
||||
const milestoneColumns = [
|
||||
{ title: '节点名称', dataIndex: 'node_name', key: 'node_name' },
|
||||
{
|
||||
title: '比例',
|
||||
dataIndex: 'percentage',
|
||||
key: 'percentage',
|
||||
render: (v: number) => `${v}%`
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'node_amount',
|
||||
key: 'node_amount',
|
||||
render: (v: number) => formatAmount(v, project?.currency)
|
||||
},
|
||||
{ title: '触发条件', dataIndex: 'trigger_condition', key: 'trigger_condition', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => getMilestoneStatusTag(status)
|
||||
},
|
||||
{
|
||||
title: '完成日期',
|
||||
dataIndex: 'completed_date',
|
||||
key: 'completed_date',
|
||||
render: (v: string) => v || '-'
|
||||
},
|
||||
{
|
||||
title: '凭证',
|
||||
dataIndex: 'voucher_url',
|
||||
key: 'voucher_url',
|
||||
render: (url: string, record: Milestone) => (
|
||||
<Space>
|
||||
{url ? (
|
||||
<Button size="small" type="link" href={url} target="_blank">查看凭证</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
type="dashed"
|
||||
onClick={() => {
|
||||
setSelectedMilestone(record);
|
||||
setVoucherModalVisible(true);
|
||||
}}
|
||||
>
|
||||
上传凭证
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
// 施工日志表格列
|
||||
const logColumns = [
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'log_date',
|
||||
key: 'log_date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '天气',
|
||||
dataIndex: 'weather',
|
||||
key: 'weather',
|
||||
width: 80,
|
||||
render: (v: string) => (
|
||||
<Space>
|
||||
{getWeatherIcon(v)}
|
||||
<span>{v}</span>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{ title: '记录人', dataIndex: 'recorder_name', key: 'recorder_name', width: 100 },
|
||||
{
|
||||
title: '工作内容',
|
||||
dataIndex: 'work_content',
|
||||
key: 'work_content',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '照片',
|
||||
dataIndex: 'photos',
|
||||
key: 'photos',
|
||||
width: 100,
|
||||
render: (photos: string[]) => (
|
||||
photos && photos.length > 0 ? (
|
||||
<Image.PreviewGroup>
|
||||
{photos.slice(0, 3).map((url, idx) => (
|
||||
<Image
|
||||
key={idx}
|
||||
src={url}
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ objectFit: 'cover', marginRight: 4, borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
{photos.length > 3 && <Text type="secondary">+{photos.length - 3}</Text>}
|
||||
</Image.PreviewGroup>
|
||||
) : '-'
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_: any, record: ConstructionLog) => (
|
||||
isAdmin && (
|
||||
<Popconfirm title="确定删除此日志吗?" onConfirm={() => handleDeleteLog(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
// 单价明细表格列
|
||||
const workItemColumns = [
|
||||
{ title: '项目内容', dataIndex: 'item_name', key: 'item_name' },
|
||||
{ title: '单位', dataIndex: 'unit', key: 'unit', width: 80 },
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'unit_price',
|
||||
key: 'unit_price',
|
||||
render: (v: number) => formatAmount(v, project?.currency)
|
||||
},
|
||||
{
|
||||
title: '暂定工程量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
render: (v: number) => v?.toLocaleString() || '-'
|
||||
},
|
||||
{
|
||||
title: '暂定总价',
|
||||
dataIndex: 'total_price',
|
||||
key: 'total_price',
|
||||
render: (v: number) => <Text strong>{formatAmount(v, project?.currency)}</Text>
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="项目不存在" />
|
||||
<Button type="primary" onClick={() => navigate('/projects')}>返回项目列表</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算财务汇总
|
||||
const totalWorkItemPrice = workItems.reduce((sum, item) => sum + (item.total_price || 0), 0);
|
||||
const completedMilestoneAmount = milestones
|
||||
.filter(m => m.status === 'completed')
|
||||
.reduce((sum, m) => sum + (m.node_amount || 0), 0);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
{/* 页面头部 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/projects')}>返回</Button>
|
||||
</Space>
|
||||
<Title level={2} style={{ marginBottom: 8 }}>{project.name}</Title>
|
||||
<Space>
|
||||
<Text type="secondary">项目编号: {project.project_code}</Text>
|
||||
<Divider type="vertical" />
|
||||
<Text type="secondary">客户: {project.customer_name}</Text>
|
||||
<Divider type="vertical" />
|
||||
<Text type="secondary">项目经理: {project.manager_name}</Text>
|
||||
<Divider type="vertical" />
|
||||
{getStatusTag(project.status)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Tab 内容 */}
|
||||
<Tabs defaultActiveKey="basic" type="card" size="large">
|
||||
{/* 基本信息 Tab */}
|
||||
<TabPane tab="基本信息" key="basic">
|
||||
<Card>
|
||||
<Descriptions bordered column={{ xs: 1, sm: 2, md: 3 }}>
|
||||
<Descriptions.Item label="项目名称">{project.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目编号">{project.project_code}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户名称">{project.customer_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目经理">{project.manager_name}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目状态">{getStatusTag(project.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算方式">
|
||||
{project.settlement_type === 'total' ? '总价包干' : '单价结算'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="币种">
|
||||
{CURRENCIES.find(c => c.value === project.currency)?.label || project.currency}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合同金额">
|
||||
<Text strong>{formatAmount(project.contract_amount, project.currency)}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="工程量">{project.work_quantity || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始日期">{project.start_date || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="预计结束日期">{project.expected_end_date || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合同工期">{project.contract_days ? `${project.contract_days}天` : '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left">项目情况</Divider>
|
||||
<Paragraph>{project.project_situation || '暂无项目情况描述'}</Paragraph>
|
||||
|
||||
<Divider orientation="left">客户要求</Divider>
|
||||
<Paragraph>{project.customer_requirements || '暂无客户要求'}</Paragraph>
|
||||
|
||||
<Divider orientation="left">附件文件</Divider>
|
||||
{project.attachments && project.attachments.length > 0 ? (
|
||||
<div>
|
||||
{project.attachments.map((file, idx) => (
|
||||
<div key={idx} style={{ marginBottom: 8 }}>
|
||||
<FileOutlined style={{ marginRight: 8 }} />
|
||||
<a href={file.url} target="_blank" rel="noopener noreferrer">{file.name}</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">暂无附件</Text>
|
||||
)}
|
||||
|
||||
{project.contract_file && (
|
||||
<>
|
||||
<Divider orientation="left">合同文件</Divider>
|
||||
<a href={project.contract_file} target="_blank" rel="noopener noreferrer">
|
||||
<FileOutlined style={{ marginRight: 8 }} />查看合同文件
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 合同详情 Tab */}
|
||||
<TabPane tab="合同详情" key="contract">
|
||||
<Card>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="结算方式">
|
||||
{project.settlement_type === 'total' ? '总价包干' : '单价结算'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合同金额">
|
||||
<Text strong>{formatAmount(project.contract_amount, project.currency)}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{project.settlement_type === 'unit' && workItems.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="left">单价明细</Divider>
|
||||
<Table
|
||||
dataSource={workItems}
|
||||
columns={workItemColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
summary={() => (
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4}>
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<Text strong style={{ color: '#1890ff' }}>
|
||||
{formatAmount(totalWorkItemPrice, project.currency)}
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">质保金设置</Divider>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="质保金比例">
|
||||
{project.warranty_rate ? `${project.warranty_rate}%` : '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="质保金金额">
|
||||
{project.warranty_amount ? formatAmount(project.warranty_amount, project.currency) : '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="质保金状态">
|
||||
{project.warranty_status === 'pending' ? '待收取' :
|
||||
project.warranty_status === 'collected' ? '已收取' :
|
||||
project.warranty_status === 'returned' ? '已退还' : '未设置'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 分包管理 Tab */}
|
||||
<TabPane tab="分包管理" key="subcontract">
|
||||
<Card>
|
||||
<Empty description="分包管理功能开发中" />
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 材料管理 Tab - 新增 */}
|
||||
<TabPane tab="材料管理" key="materials">
|
||||
<Card
|
||||
title="材料使用情况"
|
||||
extra={
|
||||
<Space>
|
||||
<Statistic
|
||||
title="材料总成本"
|
||||
value={materials.reduce((sum, m) => sum + (m.total_price || 0), 0)}
|
||||
precision={2}
|
||||
prefix={CURRENCIES.find(c => c.value === project.currency)?.symbol}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{materials.length > 0 ? (
|
||||
<Table
|
||||
dataSource={materials}
|
||||
columns={materialColumns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无材料数据" />
|
||||
)}
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 施工节点 Tab - 新增 */}
|
||||
<TabPane tab="施工节点" key="milestones">
|
||||
<Card
|
||||
title="合同付款节点进度"
|
||||
extra={
|
||||
<Space>
|
||||
<Progress
|
||||
percent={Math.round(
|
||||
milestones.filter(m => m.status === 'completed').length /
|
||||
(milestones.length || 1) * 100
|
||||
)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
已完成: {formatAmount(completedMilestoneAmount, project.currency)} /
|
||||
{formatAmount(project.contract_amount, project.currency)}
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{milestones.length > 0 ? (
|
||||
<Table
|
||||
dataSource={milestones}
|
||||
columns={milestoneColumns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无施工节点数据" />
|
||||
)}
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 施工日志 Tab - 新增 */}
|
||||
<TabPane tab="施工日志" key="logs">
|
||||
<Card
|
||||
title="施工日志列表"
|
||||
extra={
|
||||
isAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setLogModalVisible(true)}
|
||||
>
|
||||
新增日志
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{constructionLogs.length > 0 ? (
|
||||
<Table
|
||||
dataSource={constructionLogs}
|
||||
columns={logColumns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 10 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无施工日志" />
|
||||
)}
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 财务信息 Tab */}
|
||||
<TabPane tab="财务信息" key="finance">
|
||||
<Card>
|
||||
{/* 财务汇总 */}
|
||||
<Row gutter={24} style={{ marginBottom: 24 }}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="总收入"
|
||||
value={project.total_income || 0}
|
||||
precision={2}
|
||||
prefix={CURRENCIES.find(c => c.value === project.currency)?.symbol}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="总支出"
|
||||
value={project.total_expense || 0}
|
||||
precision={2}
|
||||
prefix={CURRENCIES.find(c => c.value === project.currency)?.symbol}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="利润"
|
||||
value={project.profit || 0}
|
||||
precision={2}
|
||||
prefix={CURRENCIES.find(c => c.value === project.currency)?.symbol}
|
||||
valueStyle={{ color: (project.profit || 0) >= 0 ? '#3f8600' : '#cf1322' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left">总价包干 vs 单价结算对比</Divider>
|
||||
<Table
|
||||
dataSource={[
|
||||
{
|
||||
key: 'compare',
|
||||
type: '总价包干',
|
||||
contract_amount: project.contract_amount,
|
||||
actual_amount: project.settlement_type === 'total' ? project.contract_amount : totalWorkItemPrice,
|
||||
difference: project.settlement_type === 'total'
|
||||
? 0
|
||||
: totalWorkItemPrice - project.contract_amount
|
||||
}
|
||||
]}
|
||||
columns={[
|
||||
{ title: '结算类型', dataIndex: 'type', key: 'type' },
|
||||
{
|
||||
title: '合同金额',
|
||||
dataIndex: 'contract_amount',
|
||||
key: 'contract_amount',
|
||||
render: (v: number) => formatAmount(v, project.currency)
|
||||
},
|
||||
{
|
||||
title: '实际金额',
|
||||
dataIndex: 'actual_amount',
|
||||
key: 'actual_amount',
|
||||
render: (v: number) => formatAmount(v, project.currency)
|
||||
},
|
||||
{
|
||||
title: '差额',
|
||||
dataIndex: 'difference',
|
||||
key: 'difference',
|
||||
render: (v: number) => (
|
||||
<Text style={{ color: v >= 0 ? '#3f8600' : '#cf1322' }}>
|
||||
{v >= 0 ? '+' : ''}{formatAmount(v, project.currency)}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
]}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<Divider orientation="left">收款/支出明细</Divider>
|
||||
<Empty description="财务明细功能开发中" />
|
||||
</Card>
|
||||
</TabPane>
|
||||
|
||||
{/* 质保金 Tab */}
|
||||
<TabPane tab="质保金" key="warranty">
|
||||
<Card>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="质保金比例">
|
||||
{project.warranty_rate ? `${project.warranty_rate}%` : '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="质保金金额">
|
||||
{project.warranty_amount ? formatAmount(project.warranty_amount, project.currency) : '未设置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="质保金状态">
|
||||
{project.warranty_status === 'pending' ? '待收取' :
|
||||
project.warranty_status === 'collected' ? '已收取' :
|
||||
project.warranty_status === 'returned' ? '已退还' : '未设置'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider />
|
||||
<Empty description="质保金管理功能开发中" />
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
{/* 新增施工日志弹窗 */}
|
||||
<Modal
|
||||
title="新增施工日志"
|
||||
open={logModalVisible}
|
||||
onOk={handleSubmitLog}
|
||||
onCancel={() => {
|
||||
setLogModalVisible(false);
|
||||
logForm.resetFields();
|
||||
}}
|
||||
okText="提交"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={logForm} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="log_date" label="日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="weather" label="天气" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择天气">
|
||||
<Option value="晴">晴</Option>
|
||||
<Option value="多云">多云</Option>
|
||||
<Option value="阴">阴</Option>
|
||||
<Option value="雨">雨</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="recorder_name" label="记录人" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入记录人姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="work_content" label="工作内容" rules={[{ required: true }]}>
|
||||
<TextArea rows={4} placeholder="请输入当日工作内容" />
|
||||
</Form.Item>
|
||||
<Form.Item name="photos" label="照片">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
accept="image/*"
|
||||
action="/api/upload"
|
||||
multiple
|
||||
>
|
||||
<div>
|
||||
<PictureOutlined />
|
||||
<div style={{ marginTop: 8 }}>上传照片</div>
|
||||
</div>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* 上传凭证弹窗 */}
|
||||
<Modal
|
||||
title="上传节点凭证"
|
||||
open={voucherModalVisible}
|
||||
onCancel={() => setVoucherModalVisible(false)}
|
||||
footer={null}
|
||||
>
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<Upload.Dragger
|
||||
accept="image/*,.pdf"
|
||||
beforeUpload={(file) => {
|
||||
handleUploadVoucher(file);
|
||||
return false;
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined style={{ fontSize: 48, color: '#1890ff' }} />
|
||||
</p>
|
||||
<p className="ant-upload-text">点击或拖拽文件到此区域上传</p>
|
||||
<p className="ant-upload-hint">支持图片或PDF文件</p>
|
||||
</Upload.Dragger>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetail;
|
||||
@@ -0,0 +1,560 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Typography, Button, Space, Table, Tag, Modal, Form, Input, InputNumber, DatePicker, Select, message, Radio, Upload, Row, Col, Divider, Popconfirm } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, UploadOutlined, EditOutlined } 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;
|
||||
const { Option } = Select;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'CNY', label: '人民币', symbol: '¥' },
|
||||
{ value: 'USD', label: '美元', symbol: '$' },
|
||||
{ value: 'LAK', label: '老挝基普', symbol: '₭' },
|
||||
{ value: 'THB', label: '泰铢', symbol: '฿' },
|
||||
];
|
||||
|
||||
interface PaymentNode {
|
||||
id?: number;
|
||||
node_name: string;
|
||||
percentage: number;
|
||||
node_amount: number;
|
||||
trigger_condition: string;
|
||||
}
|
||||
|
||||
interface UnitPriceItem {
|
||||
id?: number;
|
||||
item_name: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
}
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [customers, setCustomers] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<any>(null);
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 当前用户信息 - 使用zustand authStore
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
|
||||
// 表单监听值
|
||||
const settlementType = Form.useWatch('settlement_type', form);
|
||||
const contractAmount = Form.useWatch('contract_amount', form);
|
||||
const currency = Form.useWatch('currency', form);
|
||||
const contractDays = Form.useWatch('contract_days', form);
|
||||
const startDate = Form.useWatch('start_date', form);
|
||||
|
||||
// 付款节点和单价列表
|
||||
const [paymentNodes, setPaymentNodes] = useState<PaymentNode[]>([]);
|
||||
const [unitPriceList, setUnitPriceList] = useState<UnitPriceItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
fetchCustomers();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
// 自动计算结束日期
|
||||
useEffect(() => {
|
||||
if (startDate && contractDays) {
|
||||
const endDate = startDate.add(contractDays, 'day');
|
||||
form.setFieldsValue({ expected_end_date: endDate });
|
||||
}
|
||||
}, [startDate, contractDays, form]);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await axios.get('/api/projects');
|
||||
if (res.data.success) setProjects(res.data.data);
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 addPaymentNode = () => {
|
||||
setPaymentNodes([...paymentNodes, {
|
||||
node_name: '',
|
||||
percentage: 0,
|
||||
node_amount: 0,
|
||||
trigger_condition: ''
|
||||
}]);
|
||||
};
|
||||
|
||||
// 删除付款节点
|
||||
const removePaymentNode = (index: number) => {
|
||||
const newNodes = paymentNodes.filter((_, i) => i !== index);
|
||||
setPaymentNodes(newNodes);
|
||||
};
|
||||
|
||||
// 更新付款节点
|
||||
const updatePaymentNode = (index: number, field: string, value: any) => {
|
||||
const newNodes = [...paymentNodes];
|
||||
newNodes[index] = { ...newNodes[index], [field]: value };
|
||||
if (field === 'percentage' && contractAmount) {
|
||||
newNodes[index].node_amount = contractAmount * value / 100;
|
||||
}
|
||||
setPaymentNodes(newNodes);
|
||||
};
|
||||
|
||||
// 添加单价项
|
||||
const addUnitPriceItem = () => {
|
||||
setUnitPriceList([...unitPriceList, {
|
||||
item_name: '',
|
||||
unit: '',
|
||||
quantity: 0,
|
||||
unit_price: 0,
|
||||
total_price: 0
|
||||
}]);
|
||||
};
|
||||
|
||||
// 删除单价项
|
||||
const removeUnitPriceItem = (index: number) => {
|
||||
const newItems = unitPriceList.filter((_, i) => i !== index);
|
||||
setUnitPriceList(newItems);
|
||||
};
|
||||
|
||||
// 更新单价项
|
||||
const updateUnitPriceItem = (index: number, field: string, value: any) => {
|
||||
const newItems = [...unitPriceList];
|
||||
newItems[index] = { ...newItems[index], [field]: value };
|
||||
if (field === 'quantity' || field === 'unit_price') {
|
||||
const item = newItems[index];
|
||||
item.total_price = (item.quantity || 0) * (item.unit_price || 0);
|
||||
}
|
||||
setUnitPriceList(newItems);
|
||||
};
|
||||
|
||||
// 计算单价结算总金额
|
||||
const totalUnitPrice = unitPriceList.reduce((sum, item) => sum + (item.total_price || 0), 0);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingProject(null);
|
||||
form.resetFields();
|
||||
setPaymentNodes([]);
|
||||
setUnitPriceList([]);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (record: any) => {
|
||||
setEditingProject(record);
|
||||
form.setFieldsValue({
|
||||
...record,
|
||||
start_date: record.start_date ? dayjs(record.start_date) : null,
|
||||
expected_end_date: record.expected_end_date ? dayjs(record.expected_end_date) : null,
|
||||
});
|
||||
setPaymentNodes(record.payment_nodes || []);
|
||||
setUnitPriceList(record.unit_price_list || []);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
const res = await axios.delete('/api/projects/' + id);
|
||||
if (res.data.success) {
|
||||
message.success('删除成功');
|
||||
fetchProjects();
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
const totalPercentage = paymentNodes.reduce((sum, node) => sum + (node.percentage || 0), 0);
|
||||
if (paymentNodes.length > 0 && totalPercentage > 100) {
|
||||
message.error('付款节点比例总和不能超过100%');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectData = {
|
||||
...values,
|
||||
start_date: values.start_date?.format('YYYY-MM-DD'),
|
||||
expected_end_date: values.expected_end_date?.format('YYYY-MM-DD'),
|
||||
contract_amount: settlementType === 'unit' ? totalUnitPrice : values.contract_amount,
|
||||
payment_nodes: paymentNodes,
|
||||
unit_price_list: settlementType === 'unit' ? unitPriceList : [],
|
||||
status: editingProject ? values.status : 'planning',
|
||||
};
|
||||
|
||||
if (editingProject) {
|
||||
const res = await axios.put('/api/projects/' + editingProject.id, projectData);
|
||||
if (res.data.success) {
|
||||
message.success('更新成功');
|
||||
setModalVisible(false);
|
||||
fetchProjects();
|
||||
}
|
||||
} else {
|
||||
const res = await axios.post('/api/projects', projectData);
|
||||
if (res.data.success) {
|
||||
message.success('创建成功');
|
||||
setModalVisible(false);
|
||||
fetchProjects();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusTag = (status: string) => {
|
||||
const statusMap: Record<string, { color: string; text: string }> = {
|
||||
planning: { color: 'default', text: '规划中' },
|
||||
active: { color: 'processing', text: '进行中' },
|
||||
completed: { color: 'success', text: '已完成' },
|
||||
suspended: { color: 'warning', text: '已暂停' },
|
||||
};
|
||||
const config = statusMap[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
const formatAmount = (val: number, curr: string = 'CNY') => {
|
||||
const c = CURRENCIES.find(item => item.value === curr);
|
||||
const symbol = c?.symbol || '¥';
|
||||
return symbol + ' ' + (val || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '项目编号', dataIndex: 'project_code', width: 120 },
|
||||
{ title: '项目名称', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '客户', dataIndex: 'customer_name', render: (v: string) => v || '-' },
|
||||
{ title: '项目经理', dataIndex: 'manager_name', render: (v: string) => v || '-' },
|
||||
{ title: '合同金额', dataIndex: 'contract_amount', render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||||
{ title: '状态', dataIndex: 'status', render: (status: string) => getStatusTag(status) },
|
||||
{ title: '开始日期', dataIndex: 'start_date' },
|
||||
{ title: '操作', key: 'action', width: isAdmin ? 200 : 80, render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate('/projects/' + record.id)}>查看</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除此项目吗?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||||
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||||
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>项目管理</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>管理项目信息、进度和预算</Paragraph>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="项目列表"
|
||||
extra={isAdmin && <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建项目</Button>}
|
||||
>
|
||||
<Table dataSource={projects} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 1200 }} />
|
||||
</Card>
|
||||
|
||||
{isAdmin && (
|
||||
<Modal
|
||||
title={editingProject ? '编辑项目' : '新建项目'}
|
||||
open={modalVisible}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalVisible(false)}
|
||||
width={1000}
|
||||
style={{ top: 20 }}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ settlement_type: 'total', currency: 'CNY' }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入项目名称" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="customer_id" label="客户名称" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择客户" showSearch optionFilterProp="children" size="large">
|
||||
{customers.map((c: any) => <Option key={c.id} value={c.id}>{c.name}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="project_manager_id" label="项目负责人" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择项目负责人" showSearch optionFilterProp="children" size="large">
|
||||
{users.map((u: any) => <Option key={u.id} value={u.id}>{u.name} ({u.department})</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="work_quantity" label="工程量">
|
||||
<Input placeholder="如:10000立方米、5000平方米" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="project_situation" label="项目情况">
|
||||
<TextArea rows={2} placeholder="描述项目具体情况" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">结算方式</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="settlement_type" label="结算方式" rules={[{ required: true }]}>
|
||||
<Radio.Group onChange={() => { setPaymentNodes([]); setUnitPriceList([]); }}>
|
||||
<Radio value="total">总价包干</Radio>
|
||||
<Radio value="unit">单价结算</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="currency" label="币种">
|
||||
<Select size="large">
|
||||
{CURRENCIES.map(c => <Option key={c.value} value={c.value}>{c.label}</Option>)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{settlementType === 'total' && (
|
||||
<Col span={8}>
|
||||
<Form.Item name="contract_amount" label="合同金额" rules={[{ required: true }]}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
size="large"
|
||||
min={0}
|
||||
precision={2}
|
||||
formatter={v => v ? v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''}
|
||||
parser={v => v ? v.replace(/,/g, '') : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{settlementType === 'unit' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>单价结算明细</Text>
|
||||
<Button type="dashed" onClick={addUnitPriceItem} icon={<PlusOutlined />}>添加项目</Button>
|
||||
</div>
|
||||
|
||||
{unitPriceList.length === 0 && (
|
||||
<div style={{ padding: 24, textAlign: 'center', background: '#fafafa', borderRadius: 8, border: '1px dashed #d9d9d9' }}>
|
||||
<Text type="secondary">点击上方"添加项目"按钮添加明细</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unitPriceList.map((item, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
size="small"
|
||||
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||
title={<Text>项目 {index + 1}</Text>}
|
||||
extra={<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeUnitPriceItem(index)}>删除</Button>}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="项目名称">
|
||||
<Input
|
||||
value={item.item_name}
|
||||
onChange={e => updateUnitPriceItem(index, 'item_name', e.target.value)}
|
||||
placeholder="如:土方开挖"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="单位">
|
||||
<Input
|
||||
value={item.unit}
|
||||
onChange={e => updateUnitPriceItem(index, 'unit', e.target.value)}
|
||||
placeholder="如:m³"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="数量">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={item.quantity}
|
||||
onChange={val => updateUnitPriceItem(index, 'quantity', val)}
|
||||
min={0}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="单价">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={item.unit_price}
|
||||
onChange={val => updateUnitPriceItem(index, 'unit_price', val)}
|
||||
min={0}
|
||||
precision={2}
|
||||
formatter={v => v ? formatAmount(parseFloat(v.toString()), currency) : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="总价">
|
||||
<Text strong style={{ fontSize: 16 }}>{formatAmount(item.total_price, currency)}</Text>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{unitPriceList.length > 0 && (
|
||||
<div style={{ padding: 16, background: '#e6f7ff', borderRadius: 8, textAlign: 'right' }}>
|
||||
<Text strong style={{ fontSize: 16 }}>合计金额:{formatAmount(totalUnitPrice, currency)}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">付款节点</Divider>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Text strong style={{ fontSize: 16 }}>付款节点设置</Text>
|
||||
<Button type="dashed" onClick={addPaymentNode} icon={<PlusOutlined />}>添加节点</Button>
|
||||
</div>
|
||||
|
||||
{paymentNodes.length === 0 && (
|
||||
<div style={{ padding: 24, textAlign: 'center', background: '#fafafa', borderRadius: 8, border: '1px dashed #d9d9d9' }}>
|
||||
<Text type="secondary">点击上方"添加节点"按钮添加付款节点</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentNodes.map((node, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
size="small"
|
||||
style={{ marginBottom: 12, background: '#fafafa' }}
|
||||
title={<Text>节点 {index + 1}</Text>}
|
||||
extra={<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removePaymentNode(index)}>删除</Button>}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="节点名称">
|
||||
<Input
|
||||
value={node.node_name}
|
||||
onChange={e => updatePaymentNode(index, 'node_name', e.target.value)}
|
||||
placeholder="如:预付款、进度款、尾款"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="比例(%)">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={node.percentage}
|
||||
onChange={val => updatePaymentNode(index, 'percentage', val)}
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder="如:30"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="金额">
|
||||
<Text strong style={{ fontSize: 16 }}>{formatAmount(node.node_amount || 0, currency)}</Text>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="触发条件">
|
||||
<Input
|
||||
value={node.trigger_condition}
|
||||
onChange={e => updatePaymentNode(index, 'trigger_condition', e.target.value)}
|
||||
placeholder="如:合同签订后支付、工程完工后支付"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{paymentNodes.length > 0 && (
|
||||
<div style={{ padding: 16, background: '#f6ffed', borderRadius: 8, textAlign: 'right' }}>
|
||||
<Text type="secondary">总比例:</Text>
|
||||
<Text strong style={{ fontSize: 16, marginLeft: 8 }}>{paymentNodes.reduce((sum, n) => sum + (n.percentage || 0), 0)}%</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider orientation="left">工期要求</Divider>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="contract_days" label="合同工期(天)">
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="输入天数" size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="start_date" label="开始日期">
|
||||
<DatePicker style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="expected_end_date" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} size="large" disabled />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider orientation="left">合同附件</Divider>
|
||||
|
||||
<Form.Item name="contract_file" label="上传合同">
|
||||
<Upload maxCount={1} accept=".pdf,.doc,.docx,.jpg,.png">
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
@@ -0,0 +1,101 @@
|
||||
// 认证状态管理 - 使用Zustand
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
name: string
|
||||
role: string
|
||||
department?: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
setToken: (token: string) => void
|
||||
setUser: (user: User) => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
login: async (username: string, password: string) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
set({
|
||||
token: result.data.token,
|
||||
user: result.data.user,
|
||||
isAuthenticated: true,
|
||||
loading: false
|
||||
})
|
||||
} else {
|
||||
set({
|
||||
error: result.error || '登录失败',
|
||||
loading: false
|
||||
})
|
||||
throw new Error(result.error || '登录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : '登录失败',
|
||||
loading: false
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
set({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
error: null
|
||||
})
|
||||
},
|
||||
|
||||
setToken: (token: string) => {
|
||||
set({ token, isAuthenticated: true })
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user })
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null })
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
partialize: (state) => ({
|
||||
token: state.token,
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export { useAuthStore } from './authStore'
|
||||
Reference in New Issue
Block a user