Initial commit: ERP system with advance verification fixes

This commit is contained in:
System Administrator
2026-03-25 23:55:36 +07:00
commit 563ca12d76
5920 changed files with 828689 additions and 0 deletions
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>项目管理 - 轻远电力</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
{
"name": "company-finance-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",
"axios": "^1.4.0",
"antd": "^5.7.0",
"@ant-design/icons": "^5.2.6",
"dayjs": "^1.11.9",
"i18next": "^23.2.11",
"react-i18next": "^13.0.0",
"zustand": "^4.4.1"
},
"devDependencies": {
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"typescript": "^5.1.6",
"vite": "^4.4.0"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.704c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -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">
11 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'
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true
}
}
}
})