财务分类体系+统一账本+Excel导入+报表改造

This commit is contained in:
a273825743
2026-05-16 03:04:48 +08:00
parent a4048776ba
commit ac7b616d02
14 changed files with 1552 additions and 397 deletions
+4
View File
@@ -59,6 +59,8 @@ import BackupPage from './pages/admin/BackupPage'
import ProcessManagement from './pages/admin/ProcessManagement'
import ProcessTemplates from './pages/admin/ProcessTemplates'
import AboutPage from './pages/admin/AboutPage'
import ExpenseCategories from './pages/admin/ExpenseCategories'
import ExcelImport from './pages/admin/ExcelImport'
// 布局组件
import MainLayout from './components/layout/MainLayout'
@@ -181,6 +183,8 @@ function App() {
<Route path="logs" element={<SystemLogsPage />} />
<Route path="backup" element={<BackupPage />} />
<Route path="about" element={<AboutPage />} />
<Route path="expense-categories" element={<ExpenseCategories />} />
<Route path="excel-import" element={<ExcelImport />} />
</Route>
</Routes>
</ErrorBoundary>
+13 -1
View File
@@ -9,7 +9,9 @@ import {
InfoCircleOutlined,
ArrowLeftOutlined,
SettingOutlined,
AppstoreOutlined
AppstoreOutlined,
AccountBookOutlined,
ImportOutlined
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
@@ -40,6 +42,16 @@ const AdminLayout: React.FC = () => {
icon: <AppstoreOutlined />,
label: '工程模板管理'
},
{
key: '/admin/expense-categories',
icon: <AccountBookOutlined />,
label: '财务分类管理'
},
{
key: '/admin/excel-import',
icon: <ImportOutlined />,
label: 'Excel批量导入'
},
{
key: '/admin/logs',
icon: <FileTextOutlined />,
+286
View File
@@ -0,0 +1,286 @@
import React, { useState, useEffect } from 'react';
import { Card, Upload, Button, Table, Tag, message, Typography, Space, Alert, Descriptions, Progress, Select, DatePicker } from 'antd';
import { UploadOutlined, FileExcelOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import * as XLSX from 'xlsx';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
const { Title } = Typography;
const TXN_TYPE_MAP: Record<string, string> = { '收入': 'income', '支出': 'expense' };
const LEVEL1_MAP: Record<string, string> = { '收入': 'income', '项目支出': 'project', '公司支出': 'company' };
const COUNTERPARTY_TYPE_MAP: Record<string, string> = {
'供应商': 'supplier', '分包商': 'subcontractor', '客户': 'customer',
'员工': 'employee', '物流公司': 'logistics', '股东': 'shareholder', '其他': 'other'
};
interface ParsedRow {
row: number;
record_date: string;
txn_type: string;
category_level1: string;
category_level2: string;
project_name: string;
amount_original: number;
currency: string;
exchange_rate: number;
amount_cny: number;
counterparty_name: string;
counterparty_type: string;
user_name: string;
description: string;
voucher_no: string;
errors: string[];
}
const ExcelImport: React.FC = () => {
const [parsedData, setParsedData] = useState<ParsedRow[]>([]);
const [categories, setCategories] = useState<any>({});
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<any>(null);
useEffect(() => {
apiClient.get('/expense-categories/grouped').then(res => {
if (res.data.success) setCategories(res.data.data);
}).catch(() => {});
}, []);
const getCategoryMap = () => {
const map: Record<string, string> = {};
Object.entries(categories).forEach(([level1, items]: [string, any]) => {
items.forEach((item: any) => { map[item.label] = item.value; });
});
return map;
};
const handleFileUpload = (file: File) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const json = XLSX.utils.sheet_to_json(sheet, { header: 1 });
if (json.length < 2) {
message.error('Excel文件没有数据行');
return;
}
const categoryMap = getCategoryMap();
const rows: ParsedRow[] = [];
for (let i = 1; i < json.length; i++) {
const r: any[] = json[i];
if (!r || r.length === 0 || !r[0]) continue;
const errors: string[] = [];
const dateVal = r[0];
let recordDate = '';
if (typeof dateVal === 'number') {
const d = XLSX.SSF.parse_date_code(dateVal);
recordDate = `${d.y}-${String(d.m).padStart(2, '0')}-${String(d.d).padStart(2, '0')}`;
} else if (typeof dateVal === 'string') {
recordDate = dateVal;
}
const txnTypeLabel = String(r[1] || '').trim();
const level1Label = String(r[2] || '').trim();
const level2Label = String(r[3] || '').trim();
const projectName = String(r[4] || '').trim();
const amountOriginal = parseFloat(r[5]) || 0;
const currency = String(r[6] || 'CNY').trim();
const exchangeRate = parseFloat(r[7]) || 1;
const amountCny = parseFloat(r[8]) || (amountOriginal * exchangeRate);
const counterpartyName = String(r[9] || '').trim();
const counterpartyTypeLabel = String(r[10] || '').trim();
const userName = String(r[11] || '').trim();
const description = String(r[12] || '').trim();
const voucherNo = String(r[13] || '').trim();
const txnType = TXN_TYPE_MAP[txnTypeLabel] || '';
const level1 = LEVEL1_MAP[level1Label] || '';
const level2 = categoryMap[level2Label] || '';
if (!recordDate) errors.push('日期为空');
if (!txnType) errors.push(`收支类型无效: ${txnTypeLabel}`);
if (!level1) errors.push(`一级分类无效: ${level1Label}`);
if (!level2) errors.push(`二级分类无效: ${level2Label}`);
if (amountOriginal <= 0) errors.push('金额必须大于0');
if ((level1 === 'project' || (level1 === 'income' && level2 !== 'shareholder_investment')) && !projectName) {
errors.push('项目支出/项目收入必须填写项目名称');
}
rows.push({
row: i + 1,
record_date: recordDate,
txn_type: txnType,
category_level1: level1,
category_level2: level2,
project_name: projectName,
amount_original: amountOriginal,
currency,
exchange_rate: exchangeRate,
amount_cny: Math.round(amountCny * 100) / 100,
counterparty_name: counterpartyName,
counterparty_type: COUNTERPARTY_TYPE_MAP[counterpartyTypeLabel] || '',
user_name: userName,
description,
voucher_no: voucherNo,
errors
});
}
setParsedData(rows);
setImportResult(null);
message.success(`解析完成,共 ${rows.length} 条记录`);
} catch (err: any) {
message.error('解析Excel失败: ' + err.message);
}
};
reader.readAsArrayBuffer(file);
return false;
};
const handleImport = async () => {
const validRows = parsedData.filter(r => r.errors.length === 0);
if (validRows.length === 0) {
message.error('没有有效的数据可导入');
return;
}
setImporting(true);
try {
const records = validRows.map(r => ({
txn_type: r.txn_type,
category_level1: r.category_level1,
category_level2: r.category_level2,
project_name: r.project_name,
amount_original: r.amount_original,
currency: r.currency,
exchange_rate: r.exchange_rate,
amount_cny: r.amount_cny,
record_date: r.record_date,
counterparty_name: r.counterparty_name,
counterparty_type: r.counterparty_type,
user_name: r.user_name,
description: r.description,
voucher_no: r.voucher_no,
source: 'manual'
}));
const res = await apiClient.post('/financial-records/batch', { records });
if (res.data.success) {
setImportResult(res.data.data);
message.success(`导入完成:成功 ${res.data.data.imported} 条,失败 ${res.data.data.failed}`);
}
} catch (e: any) {
message.error('导入失败: ' + (e.response?.data?.message || e.message));
}
setImporting(false);
};
const errorCount = parsedData.filter(r => r.errors.length > 0).length;
const validCount = parsedData.filter(r => r.errors.length === 0).length;
const columns = [
{ title: '行号', dataIndex: 'row', width: 50 },
{ title: '日期', dataIndex: 'record_date', width: 100 },
{ title: '收支', dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag> },
{ title: '一级', dataIndex: 'category_level1', width: 80, render: (v: string) => {
const m: Record<string, string> = { income: '收入', project: '项目', company: '公司' };
return m[v] || v;
}},
{ title: '二级', dataIndex: 'category_level2', width: 100 },
{ title: '项目', dataIndex: 'project_name', width: 120, ellipsis: true },
{ title: '金额', dataIndex: 'amount_original', width: 90, render: (v: number) => v?.toLocaleString() },
{ title: '币种', dataIndex: 'currency', width: 50 },
{ title: '汇率', dataIndex: 'exchange_rate', width: 60 },
{ title: '等效人民币', dataIndex: 'amount_cny', width: 100, render: (v: number) => v?.toLocaleString() },
{ title: '描述', dataIndex: 'description', width: 150, ellipsis: true },
{
title: '校验', width: 80,
render: (_: unknown, r: ParsedRow) => r.errors.length === 0
? <CheckCircleOutlined style={{ color: '#52c41a' }} />
: <CloseCircleOutlined style={{ color: '#ff4d4f' }} title={r.errors.join('; ')} />
}
];
return (
<div style={{ padding: 24 }}>
<Card>
<Title level={4}>Excel </Title>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Excel 模板格式要求"
description={
<div>
<p> | | | | | | | | | | | | | </p>
<p> / / / CNY / USD / LAK / THB</p>
<p>使</p>
</div>
}
/>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
beforeUpload={handleFileUpload}
>
<Button icon={<FileExcelOutlined />} type="primary"> Excel </Button>
</Upload>
{parsedData.length > 0 && (
<>
<div style={{ margin: '16px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<span> {parsedData.length} </span>
<Tag color="green"> {validCount}</Tag>
{errorCount > 0 && <Tag color="red"> {errorCount}</Tag>}
</Space>
<Button
type="primary"
onClick={handleImport}
loading={importing}
disabled={validCount === 0}
>
{validCount}
</Button>
</div>
<Table
dataSource={parsedData}
columns={columns}
rowKey="row"
size="small"
scroll={{ x: 1200 }}
pagination={{ pageSize: 20 }}
rowClassName={(r) => r.errors.length > 0 ? 'row-error' : ''}
/>
{importResult && (
<Alert
type={importResult.failed > 0 ? 'warning' : 'success'}
showIcon
style={{ marginTop: 16 }}
message={`导入完成:成功 ${importResult.imported} 条,失败 ${importResult.failed}`}
description={
importResult.errors?.length > 0
? importResult.errors.map((e: any, i: number) => <div key={i}> {e.row} {e.message}</div>)
: undefined
}
/>
)}
</>
)}
</Card>
<style>{`.row-error { background: #fff2f0 !important; }`}</style>
</div>
);
};
export default ExcelImport;
@@ -0,0 +1,150 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Modal, Form, Input, Select, Switch, Tag, message, Space, Card, Typography } from 'antd';
import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
const { Title } = Typography;
const LEVEL1_OPTIONS = [
{ value: 'income', label: '收入', color: 'green' },
{ value: 'project', label: '项目支出', color: 'blue' },
{ value: 'company', label: '公司支出', color: 'orange' },
];
const ExpenseCategories: React.FC = () => {
const [categories, setCategories] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form] = Form.useForm();
const fetchCategories = async () => {
setLoading(true);
try {
const res = await apiClient.get('/expense-categories');
if (res.data.success) {
setCategories(res.data.data);
}
} catch (e) {
message.error('获取分类失败');
}
setLoading(false);
};
useEffect(() => { fetchCategories(); }, []);
const handleAdd = () => {
setEditingId(null);
form.resetFields();
setModalVisible(true);
};
const handleEdit = (record: any) => {
setEditingId(record.id);
form.setFieldsValue(record);
setModalVisible(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
if (editingId) {
await apiClient.put(`/expense-categories/${editingId}`, values);
message.success('更新成功');
} else {
await apiClient.post('/expense-categories', values);
message.success('创建成功');
}
setModalVisible(false);
fetchCategories();
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
}
};
const handleToggleActive = async (id: number, isActive: boolean) => {
try {
await apiClient.put(`/expense-categories/${id}`, { is_active: isActive });
message.success(isActive ? '已启用' : '已禁用');
fetchCategories();
} catch (e) {
message.error('操作失败');
}
};
const getLevel1Tag = (level1: string) => {
const opt = LEVEL1_OPTIONS.find(o => o.value === level1);
return <Tag color={opt?.color}>{opt?.label || level1}</Tag>;
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '一级分类', dataIndex: 'category_level1', width: 120, render: getLevel1Tag },
{ title: '二级编码', dataIndex: 'category_level2', width: 160 },
{ title: '显示名称', dataIndex: 'label', width: 140 },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '排序', dataIndex: 'sort_order', width: 70 },
{
title: '状态', dataIndex: 'is_active', width: 90,
render: (v: boolean, r: any) => (
<Switch size="small" checked={v} onChange={(checked) => handleToggleActive(r.id, checked)} />
)
},
{
title: '操作', width: 100,
render: (_: unknown, r: any) => (
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}></Button>
)
}
];
return (
<div style={{ padding: 24 }}>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchCategories}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}></Button>
</Space>
</div>
<Table
dataSource={categories}
columns={columns}
rowKey="id"
loading={loading}
pagination={false}
size="small"
/>
</Card>
<Modal
title={editingId ? '编辑分类' : '新增分类'}
open={modalVisible}
onOk={handleSave}
onCancel={() => setModalVisible(false)}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
<Select options={LEVEL1_OPTIONS.map(o => ({ value: o.value, label: o.label }))} />
</Form.Item>
<Form.Item name="category_level2" label="二级编码" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="如 material, salary 等" />
</Form.Item>
<Form.Item name="label" label="显示名称" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="如 材料采购" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<Input type="number" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ExpenseCategories;
+185 -196
View File
@@ -1,196 +1,185 @@
import React from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag } from 'antd';
import { DollarOutlined, FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const FinancePage: React.FC = () => {
// 统计数据 - 从API获取真实数据
const [stats, setStats] = React.useState([
{
title: '本月总收入',
value: 0,
prefix: '¥',
icon: <DollarOutlined />,
trend: '',
color: '#3f8600',
},
{
title: '本月总支出',
value: 0,
prefix: '¥',
icon: <FileTextOutlined />,
trend: '',
color: '#cf1322',
},
{
title: '待审批报销',
value: 0,
prefix: '¥',
icon: <CheckCircleOutlined />,
trend: '',
color: '#1890ff',
},
]);
// 财务记录数据 - 从API获取真实数据
const [dataSource, setDataSource] = React.useState([]);
// 从API获取财务数据
React.useEffect(() => {
// 这里可以添加从API获取真实数据的逻辑
// 例如:fetch('/api/finance-stats') 等
}, []);
// 桌面端表格列
const desktopColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
sorter: (a: any, b: any) => a.date.localeCompare(b.date),
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: (type: string) => (
<span style={{ color: type === '收入' ? 'green' : 'red' }}>
{type === '收入' ? '↑ 收入' : '↓ 支出'}
</span>
),
},
{
title: '类别',
dataIndex: 'category',
key: 'category',
},
{
title: '项目',
dataIndex: 'project',
key: 'project',
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (amount: number) => `¥${amount.toLocaleString()}`,
sorter: (a: any, b: any) => a.amount - b.amount,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const colorMap: Record<string, string> = {
'已入账': 'green',
'已付款': 'blue',
'处理中': 'orange',
};
return <Tag color={colorMap[status] || 'default'}>{status}</Tag>;
},
},
];
// 移动端简化表格列
const mobileColumns = [
{
title: '日期',
dataIndex: 'date',
key: 'date',
width: 100,
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 60,
render: (type: string) => (
<span style={{ color: type === '收入' ? 'green' : 'red', fontSize: 12 }}>
{type === '收入' ? '↑' : '↓'}
</span>
),
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: (amount: number) => (
<div style={{ fontWeight: 'bold' }}>¥{amount.toLocaleString()}</div>
),
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === '已入账' ? 'green' : 'blue'} style={{ fontSize: 10 }}>
{status}
</Tag>
),
},
];
const [isMobile, setIsMobile] = React.useState(false);
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
return (
<div>
<div style={{ marginBottom: 16 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
</div>
{/* 统计卡片 - 移动端优化 */}
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
{stats.map((stat, index) => (
<Col xs={24} sm={8} key={index}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic
title={<span style={{ fontSize: 12 }}>{stat.title}</span>}
value={stat.value}
prefix={stat.prefix}
suffix={stat.trend}
valueStyle={{
color: stat.color,
fontSize: isMobile ? 18 : undefined
}}
/>
</Card>
</Col>
))}
</Row>
{/* 财务明细表 */}
<Card
title="财务明细"
size="small"
styles={{ body: { padding: isMobile ? 8 : 24 } }}
>
<Table
dataSource={dataSource}
columns={isMobile ? mobileColumns : desktopColumns}
pagination={{
pageSize: 5,
size: isMobile ? 'small' : 'default'
}}
scroll={isMobile ? { x: 500 } : undefined}
size={isMobile ? 'small' : 'middle'}
/>
</Card>
</div>
);
};
export default FinancePage;
import React, { useState, useEffect } from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin } from 'antd';
import { DollarOutlined, FileTextOutlined, RiseOutlined, FallOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
const { Title, Paragraph } = Typography;
const { RangePicker } = DatePicker;
const LEVEL1_LABELS: Record<string, string> = { income: '收入', project: '项目支出', company: '公司支出' };
const LEVEL2_LABELS: Record<string, string> = {
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
};
const FinancePage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
const [byCategory, setByCategory] = useState<any[]>([]);
const [records, setRecords] = useState<any[]>([]);
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0 });
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [filterType, setFilterType] = useState<string | undefined>(undefined);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const fetchSummary = async () => {
try {
const params: any = {};
if (dateRange && dateRange[0]) {
params.date_from = dateRange[0].format('YYYY-MM-DD');
params.date_to = dateRange[1]?.format('YYYY-MM-DD');
}
const res = await apiClient.get('/financial-records/summary', { params });
if (res.data.success) {
setSummary(res.data.data.totals);
setByCategory(res.data.data.byCategory);
}
} catch (e) { console.error(e); }
};
const fetchRecords = async (page = 1) => {
setLoading(true);
try {
const params: any = { page, pageSize: pagination.pageSize };
if (dateRange && dateRange[0]) {
params.date_from = dateRange[0].format('YYYY-MM-DD');
params.date_to = dateRange[1]?.format('YYYY-MM-DD');
}
if (filterType) params.txn_type = filterType;
const res = await apiClient.get('/financial-records', { params });
if (res.data.success) {
setRecords(res.data.data);
setPagination(res.data.pagination);
}
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
const desktopColumns = [
{ title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) },
{
title: '收支', dataIndex: 'txn_type', width: 60,
render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? '收入' : '支出'}</Tag>
},
{
title: '一级分类', dataIndex: 'category_level1', width: 90,
render: (v: string) => LEVEL1_LABELS[v] || v
},
{
title: '二级分类', dataIndex: 'category_level2', width: 100,
render: (v: string) => LEVEL2_LABELS[v] || v
},
{ title: '项目', dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: '原始金额', dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: '币种', dataIndex: 'currency', width: 50 },
{ title: '等效人民币', dataIndex: 'amount_cny', width: 110, render: (v: number) => `¥${v?.toLocaleString()}`, align: 'right' as const, sorter: (a: any, b: any) => a.amount_cny - b.amount_cny },
{ title: '人员', dataIndex: 'user_name', width: 70 },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '来源', dataIndex: 'source', width: 80,
render: (v: string) => {
const m: Record<string, string> = { manual: '手动导入', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' };
return <Tag>{m[v] || v}</Tag>;
}
}
];
const mobileColumns = [
{ title: '日期', dataIndex: 'record_date', width: 80 },
{ title: '收支', dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
{ title: '分类', dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额', dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Title level={3} style={{ marginBottom: 8 }}></Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}></Paragraph>
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.total_income} prefix="¥"
valueStyle={{ color: '#3f8600', fontSize: isMobile ? 18 : undefined }}
icon={<RiseOutlined />} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.total_expense} prefix="¥"
valueStyle={{ color: '#cf1322', fontSize: isMobile ? 18 : undefined }}
icon={<FallOutlined />} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}></span>} value={summary.net_profit} prefix="¥"
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 18 : undefined }}
icon={<DollarOutlined />} />
</Card>
</Col>
</Row>
{byCategory.length > 0 && (
<Card title="支出分类汇总" size="small" style={{ marginBottom: 16 }}>
<Row gutter={[8, 8]}>
{byCategory.filter(c => c.category_level1 !== 'income').map((c, i) => (
<Col xs={12} sm={8} md={6} key={i}>
<div style={{ fontSize: 12, color: '#666' }}>{LEVEL2_LABELS[c.category_level2] || c.category_level2}</div>
<div style={{ fontWeight: 'bold' }}>¥{parseFloat(c.total_amount).toLocaleString()}</div>
</Col>
))}
</Row>
</Card>
)}
<Card title="财务明细" size="small" styles={{ body: { padding: isMobile ? 8 : 24 } }}
extra={
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setDateRange(dates as any)} />
<Select size="small" allowClear placeholder="筛选类型" style={{ width: 100 }}
onChange={setFilterType} value={filterType}
options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} />
</Space>
}
>
<Spin spinning={loading}>
<Table
dataSource={records}
columns={isMobile ? mobileColumns : desktopColumns}
rowKey="id"
size={isMobile ? 'small' : 'middle'}
scroll={isMobile ? { x: 400 } : { x: 1100 }}
pagination={{
current: pagination.page,
pageSize: pagination.pageSize,
total: pagination.total,
onChange: (page) => fetchRecords(page),
size: isMobile ? 'small' : 'default',
showTotal: (total) => `${total}`
}}
/>
</Spin>
</Card>
</div>
);
};
export default FinancePage;
+85 -22
View File
@@ -868,31 +868,94 @@ const ProjectDetail: React.FC = () => {
</Card>
)
// 财务信息 Tab
const FinanceTab = () => {
const contractAmount = parseFloat(project?.contract_amount || '0')
const totalIncome = finances.filter(f => f.payment_type === 'income').reduce((sum, f) => sum + (f.amount || 0), 0)
const totalExpense = finances.filter(f => f.payment_type === 'expense').reduce((sum, f) => sum + (f.amount || 0), 0)
const grossProfit = totalIncome - totalExpense
const [profitData, setProfitData] = React.useState<any>(null);
const [profitLoading, setProfitLoading] = React.useState(false);
React.useEffect(() => {
if (!project?.id) return;
setProfitLoading(true);
apiClient.get(`/financial-records/project-profit/${project.id}`)
.then(res => { if (res.data.success) setProfitData(res.data.data); })
.catch(() => {})
.finally(() => setProfitLoading(false));
}, [project?.id]);
const contractAmount = parseFloat(project?.contract_amount || '0');
const totalIncome = profitData?.total_income || 0;
const totalExpense = profitData?.total_expense || 0;
const grossProfit = profitData?.gross_profit || 0;
const grossMargin = profitData?.gross_margin || 0;
const LEVEL2_LABELS: Record<string, string> = {
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
contract_payment: '合同收款', deposit_refund: '质保金退回'
};
return (
<Card title="财务信息">
<Descriptions column={2} bordered>
<Descriptions.Item label="合同金额">
¥{contractAmount.toLocaleString()}
</Descriptions.Item>
<Descriptions.Item label="已收款">¥{totalIncome.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="预支总额">¥0</Descriptions.Item>
<Descriptions.Item label="报销总额">¥0</Descriptions.Item>
<Descriptions.Item label="分包付款">¥0</Descriptions.Item>
<Descriptions.Item label="支出合计">¥{totalExpense.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="毛利润" span={2}>
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
¥{grossProfit.toLocaleString()}
</span>
</Descriptions.Item>
</Descriptions>
</Card>
<Spin spinning={profitLoading}>
<Card title="财务信息">
<Descriptions column={2} bordered>
<Descriptions.Item label="合同金额">¥{contractAmount.toLocaleString()}</Descriptions.Item>
<Descriptions.Item label="已收款">
<span style={{ color: '#3f8600', fontWeight: 'bold' }}>¥{totalIncome.toLocaleString()}</span>
</Descriptions.Item>
<Descriptions.Item label="支出合计">
<span style={{ color: '#cf1322' }}>¥{totalExpense.toLocaleString()}</span>
</Descriptions.Item>
<Descriptions.Item label="毛利润">
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold', fontSize: 16 }}>
¥{grossProfit.toLocaleString()}
</span>
</Descriptions.Item>
<Descriptions.Item label="毛利率" span={2}>
<span style={{ color: grossProfit >= 0 ? '#52c41a' : '#ff4d4f', fontWeight: 'bold' }}>
{grossMargin}%
</span>
</Descriptions.Item>
</Descriptions>
</Card>
{profitData?.expense_by_category?.length > 0 && (
<Card title="支出分类明细" size="small" style={{ marginTop: 16 }}>
<Table
dataSource={profitData.expense_by_category}
rowKey="category_level2"
pagination={false}
size="small"
columns={[
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
{
title: '占比', render: (_: unknown, r: any) => {
const pct = totalExpense > 0 ? (parseFloat(r.total_amount) / totalExpense * 100).toFixed(1) : '0';
return `${pct}%`;
}
},
]}
/>
</Card>
)}
{profitData?.expense_by_user?.length > 0 && (
<Card title="人员支出明细" size="small" style={{ marginTop: 16 }}>
<Table
dataSource={profitData.expense_by_user}
rowKey="user_name"
pagination={false}
size="small"
columns={[
{ title: '人员', dataIndex: 'user_name' },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
]}
/>
</Card>
)}
</Spin>
)
}
+177 -177
View File
@@ -1,177 +1,177 @@
import React, { useState, useEffect } from 'react';
import { Card, Typography, Table, DatePicker, Button, Row, Col, Tag } from 'antd';
import { DownloadOutlined } from '@ant-design/icons';
const { Title, Paragraph } = Typography;
const ReportsPage: React.FC = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 报表数据 - 从API获取或为空数组
const dataSource: any[] = [];
// 桌面端表格列
const desktopColumns = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
},
{
title: '总收入',
dataIndex: 'income',
key: 'income',
render: (amount: number) => `¥${amount.toLocaleString()}`,
},
{
title: '总支出',
dataIndex: 'expense',
key: 'expense',
render: (amount: number) => `¥${amount.toLocaleString()}`,
},
{
title: '净利润',
dataIndex: 'profit',
key: 'profit',
render: (amount: number) => (
<span style={{ color: amount > 0 ? 'green' : 'red' }}>
¥{amount.toLocaleString()}
</span>
),
},
{
title: '项目数量',
dataIndex: 'projects',
key: 'projects',
},
{
title: '操作',
key: 'action',
render: () => (
<Button size="small" icon={<DownloadOutlined />}></Button>
),
},
];
// 移动端简化表格列
const mobileColumns = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
render: (month: string) => month.replace('-', '/'),
},
{
title: '收入',
dataIndex: 'income',
key: 'income',
render: (amount: number) => (
<div style={{ color: 'green' }}>¥{(amount / 10000).toFixed(0)}</div>
),
},
{
title: '支出',
dataIndex: 'expense',
key: 'expense',
render: (amount: number) => (
<div style={{ color: 'red' }}>¥{(amount / 10000).toFixed(0)}</div>
),
},
{
title: '利润',
dataIndex: 'profit',
key: 'profit',
render: (amount: number) => (
<div style={{ fontWeight: 'bold', color: amount > 0 ? 'green' : 'red' }}>
¥{(amount / 10000).toFixed(0)}
</div>
),
},
];
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="月度财务报表"
size="small"
styles={{ body: { padding: isMobile ? 8 : 24 } }}
extra={
isMobile ? (
<Button size="small" icon={<DownloadOutlined />} />
) : (
<DatePicker picker="month" style={{ marginRight: 8 }} />
)
}
>
<Table
dataSource={dataSource}
columns={isMobile ? mobileColumns : desktopColumns}
pagination={false}
scroll={isMobile ? { x: 350 } : undefined}
size={isMobile ? 'small' : 'middle'}
summary={(pageData) => {
let totalIncome = 0;
let totalExpense = 0;
let totalProfit = 0;
let totalProjects = 0;
pageData.forEach(({ income, expense, profit, projects }) => {
totalIncome += income;
totalExpense += expense;
totalProfit += profit;
totalProjects += projects;
});
return (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}>
<strong></strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={1}>
<strong>¥{(totalIncome / 10000).toFixed(0)}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={2}>
<strong>¥{(totalExpense / 10000).toFixed(0)}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={3}>
<strong style={{ color: totalProfit > 0 ? 'green' : 'red' }}>
¥{(totalProfit / 10000).toFixed(0)}
</strong>
</Table.Summary.Cell>
{!isMobile && (
<>
<Table.Summary.Cell index={4}>
<strong>{totalProjects}</strong>
</Table.Summary.Cell>
<Table.Summary.Cell index={5} />
</>
)}
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
</Card>
</div>
);
};
export default ReportsPage;
import React, { useState, useEffect } from 'react';
import { Card, Typography, Table, DatePicker, Row, Col, Statistic, Tag, Spin } from 'antd';
import { RiseOutlined, FallOutlined, DollarOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
const { Title, Paragraph } = Typography;
const LEVEL2_LABELS: Record<string, string> = {
contract_payment: '项目合同收款', deposit_refund: '质保金退回', shareholder_investment: '股东投资入股', other_income: '其他收入',
material: '材料采购', equipment: '设备采购', subcontract: '施工分包', labor: '人工工资',
travel: '差旅交通', accommodation: '食宿费用', freight: '运输物流', design: '勘测设计',
tools: '小型工具', client_relations: '客户/EDL关系', other_project: '其他项目支出',
salary: '工资薪酬', rent: '房租物业', office: '办公费用', commute: '交通通勤',
vehicle_maintenance: '车辆维保', assets: '固定资产', marketing: '营销拓展',
entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出'
};
const ReportsPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [summary, setSummary] = useState<any>({ total_income: 0, total_expense: 0, net_profit: 0 });
const [byMonth, setByMonth] = useState<any[]>([]);
const [byCategory, setByCategory] = useState<any[]>([]);
const [selectedMonth, setSelectedMonth] = useState<dayjs.Dayjs | null>(null);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (selectedMonth) {
params.date_from = selectedMonth.startOf('month').format('YYYY-MM-DD');
params.date_to = selectedMonth.endOf('month').format('YYYY-MM-DD');
}
const res = await apiClient.get('/financial-records/summary', { params });
if (res.data.success) {
setSummary(res.data.data.totals);
setByMonth(res.data.data.byMonth);
setByCategory(res.data.data.byCategory);
}
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [selectedMonth]);
const monthData = byMonth.map(m => ({
...m,
profit: m.income - m.expense
}));
const desktopColumns = [
{ title: '月份', dataIndex: 'month', key: 'month' },
{ title: '总收入', dataIndex: 'income', key: 'income', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{ title: '总支出', dataIndex: 'expense', key: 'expense', render: (v: number) => `¥${parseFloat(v).toLocaleString()}` },
{
title: '净利润', dataIndex: 'profit', key: 'profit',
render: (v: number) => <span style={{ color: v >= 0 ? 'green' : 'red', fontWeight: 'bold' }}>¥{v.toLocaleString()}</span>
},
];
const mobileColumns = [
{ title: '月份', dataIndex: 'month', key: 'month', render: (v: string) => v.replace('-', '/') },
{ title: '收入', dataIndex: 'income', key: 'income', render: (v: number) => <span style={{ color: 'green' }}>¥{(parseFloat(v) / 10000).toFixed(1)}</span> },
{ title: '支出', dataIndex: 'expense', key: 'expense', render: (v: number) => <span style={{ color: 'red' }}>¥{(parseFloat(v) / 10000).toFixed(1)}</span> },
{
title: '利润', dataIndex: 'profit', key: 'profit',
render: (v: number) => <b style={{ color: v >= 0 ? 'green' : 'red' }}>¥{(v / 10000).toFixed(1)}</b>
},
];
const categoryColumns = [
{ title: '分类', dataIndex: 'category_level2', render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: '金额(¥)', dataIndex: 'total_amount', render: (v: number) => parseFloat(v).toLocaleString(), align: 'right' as const },
{ title: '笔数', dataIndex: 'count', align: 'center' as const },
{
title: '占比', render: (_: unknown, r: any) => {
const total = byCategory.filter(c => c.category_level1 === r.category_level1).reduce((s, c) => s + parseFloat(c.total_amount), 0);
return total > 0 ? `${(parseFloat(r.total_amount) / total * 100).toFixed(1)}%` : '-';
}
},
];
const projectCategories = byCategory.filter(c => c.category_level1 === 'project');
const companyCategories = byCategory.filter(c => c.category_level1 === 'company');
const incomeCategories = byCategory.filter(c => c.category_level1 === 'income');
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>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="总收入" value={summary.total_income} prefix="¥" valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="总支出" value={summary.total_expense} prefix="¥" valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title="净利润" value={summary.net_profit} prefix="¥" valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
</Row>
<Card title="月度财务报表" size="small" style={{ marginBottom: 16 }}
extra={<DatePicker picker="month" size="small" allowClear onChange={(d) => setSelectedMonth(d)} placeholder="选择月份" />}
>
<Spin spinning={loading}>
<Table
dataSource={monthData}
columns={isMobile ? mobileColumns : desktopColumns}
rowKey="month"
pagination={false}
size={isMobile ? 'small' : 'middle'}
summary={(pageData) => {
let ti = 0, te = 0;
pageData.forEach(({ income, expense }) => { ti += parseFloat(income); te += parseFloat(expense); });
return (
<Table.Summary fixed>
<Table.Summary.Row>
<Table.Summary.Cell index={0}><strong></strong></Table.Summary.Cell>
<Table.Summary.Cell index={1}><strong>¥{ti.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={2}><strong>¥{te.toLocaleString()}</strong></Table.Summary.Cell>
<Table.Summary.Cell index={3}>
<strong style={{ color: ti - te >= 0 ? 'green' : 'red' }}>¥{(ti - te).toLocaleString()}</strong>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
</Spin>
</Card>
<Row gutter={[16, 16]}>
{incomeCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="green"></Tag></span>} size="small">
<Table dataSource={incomeCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
)}
{projectCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="blue"></Tag></span>} size="small">
<Table dataSource={projectCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
)}
{companyCategories.length > 0 && (
<Col xs={24} md={8}>
<Card title={<span><Tag color="orange"></Tag></span>} size="small">
<Table dataSource={companyCategories} columns={categoryColumns} rowKey="category_level2" pagination={false} size="small" />
</Card>
</Col>
)}
</Row>
</div>
);
};
export default ReportsPage;