2026-05-16 03:04:48 +08:00
|
|
|
import React, { useState, useEffect } from 'react';
|
2026-05-16 23:19:55 +08:00
|
|
|
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider } from 'antd';
|
|
|
|
|
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
2026-05-16 03:04:48 +08:00
|
|
|
import apiClient from '../../utils/request';
|
|
|
|
|
import dayjs from 'dayjs';
|
2026-05-16 23:19:55 +08:00
|
|
|
import * as XLSX from 'xlsx';
|
2026-05-16 03:04:48 +08:00
|
|
|
|
|
|
|
|
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: '其他公司支出'
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-16 23:19:55 +08:00
|
|
|
const COUNTERPARTY_TYPES = [
|
|
|
|
|
{ value: 'supplier', label: '供应商' }, { value: 'subcontractor', label: '分包商' },
|
|
|
|
|
{ value: 'customer', label: '客户' }, { value: 'employee', label: '员工' },
|
|
|
|
|
{ value: 'logistics', label: '物流公司' }, { value: 'shareholder', label: '股东' },
|
|
|
|
|
{ value: 'other', label: '其他' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const CURRENCIES = [
|
|
|
|
|
{ value: 'CNY', label: 'CNY (人民币)' },
|
|
|
|
|
{ value: 'LAK', label: 'LAK (老挝基普)' },
|
|
|
|
|
{ value: 'USD', label: 'USD (美元)' },
|
|
|
|
|
{ value: 'THB', label: 'THB (泰铢)' },
|
|
|
|
|
];
|
|
|
|
|
|
2026-05-16 03:04:48 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-05-16 23:19:55 +08:00
|
|
|
const [addModalVisible, setAddModalVisible] = useState(false);
|
|
|
|
|
const [addLoading, setAddLoading] = useState(false);
|
|
|
|
|
const [form] = Form.useForm();
|
|
|
|
|
|
|
|
|
|
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [] });
|
|
|
|
|
const [projects, setProjects] = useState<any[]>([]);
|
|
|
|
|
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 });
|
|
|
|
|
|
2026-05-16 03:04:48 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
|
|
|
|
checkMobile();
|
|
|
|
|
window.addEventListener('resize', checkMobile);
|
|
|
|
|
return () => window.removeEventListener('resize', checkMobile);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-16 23:19:55 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
apiClient.get('/expense-categories/grouped').then(res => {
|
|
|
|
|
if (res.data.success) setCategories(res.data.data);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
apiClient.get('/projects', { params: { pageSize: 200 } }).then(res => {
|
|
|
|
|
if (res.data.success) setProjects(res.data.data || res.data.projects || []);
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
apiClient.get('/exchange-rates/latest').then(res => {
|
|
|
|
|
if (res.data.success && res.data.data) {
|
|
|
|
|
const rates: Record<string, number> = { CNY: 1 };
|
|
|
|
|
const data = res.data.data;
|
|
|
|
|
if (data.CNY_LAK) rates.LAK = 1 / parseFloat(data.CNY_LAK);
|
|
|
|
|
if (data.CNY_USD) rates.USD = 1 / parseFloat(data.CNY_USD);
|
|
|
|
|
if (data.CNY_THB) rates.THB = 1 / parseFloat(data.CNY_THB);
|
|
|
|
|
setExchangeRates(rates);
|
|
|
|
|
}
|
|
|
|
|
}).catch(() => {});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-16 03:04:48 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-16 23:19:55 +08:00
|
|
|
const handleAdd = () => {
|
|
|
|
|
form.resetFields();
|
|
|
|
|
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1 });
|
|
|
|
|
setAddModalVisible(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleTxnTypeChange = (v: string) => {
|
|
|
|
|
form.setFieldsValue({ category_level1: undefined, category_level2: undefined });
|
|
|
|
|
if (v === 'income') {
|
|
|
|
|
form.setFieldsValue({ category_level1: 'income' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleLevel1Change = () => {
|
|
|
|
|
form.setFieldsValue({ category_level2: undefined });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCurrencyChange = (currency: string) => {
|
|
|
|
|
const rate = exchangeRates[currency] || 1;
|
|
|
|
|
form.setFieldsValue({ exchange_rate: rate });
|
|
|
|
|
const amount = form.getFieldValue('amount_original') || 0;
|
|
|
|
|
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleAmountChange = (val: number | null) => {
|
|
|
|
|
const rate = form.getFieldValue('exchange_rate') || 1;
|
|
|
|
|
const amount = val || 0;
|
|
|
|
|
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleRateChange = (val: number | null) => {
|
|
|
|
|
const amount = form.getFieldValue('amount_original') || 0;
|
|
|
|
|
const rate = val || 1;
|
|
|
|
|
form.setFieldsValue({ amount_cny: Math.round(amount * rate * 100) / 100 });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const values = await form.validateFields();
|
|
|
|
|
setAddLoading(true);
|
|
|
|
|
const payload = {
|
|
|
|
|
...values,
|
|
|
|
|
record_date: values.record_date?.format('YYYY-MM-DD'),
|
|
|
|
|
amount_cny: Math.round((values.amount_original || 0) * (values.exchange_rate || 1) * 100) / 100,
|
|
|
|
|
source: 'manual',
|
|
|
|
|
};
|
|
|
|
|
if (payload.category_level1 !== 'project' && payload.category_level1 !== 'income') {
|
|
|
|
|
payload.project_id = undefined;
|
|
|
|
|
}
|
|
|
|
|
await apiClient.post('/financial-records', payload);
|
|
|
|
|
message.success('录入成功');
|
|
|
|
|
setAddModalVisible(false);
|
|
|
|
|
fetchSummary();
|
|
|
|
|
fetchRecords(1);
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
if (e.response?.data?.message) message.error(e.response.data.message);
|
|
|
|
|
}
|
|
|
|
|
setAddLoading(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleExport = async () => {
|
|
|
|
|
try {
|
|
|
|
|
message.loading({ content: '正在导出...', key: 'export' });
|
|
|
|
|
const res = await apiClient.get('/financial-records', { params: { page: 1, pageSize: 5000 } });
|
|
|
|
|
if (!res.data.success) return;
|
|
|
|
|
const data = res.data.data;
|
|
|
|
|
|
|
|
|
|
const wb = XLSX.utils.book_new();
|
|
|
|
|
const headers = ['日期', '收支类型', '一级分类', '二级分类', '项目名称', '金额', '币种', '汇率', '等效人民币', '对方名称', '对方类型', '人员姓名', '描述', '凭证编号'];
|
|
|
|
|
const rows = data.map((r: any) => [
|
|
|
|
|
r.record_date,
|
|
|
|
|
r.txn_type === 'income' ? '收入' : '支出',
|
|
|
|
|
LEVEL1_LABELS[r.category_level1] || r.category_level1,
|
|
|
|
|
LEVEL2_LABELS[r.category_level2] || r.category_level2,
|
|
|
|
|
r.project_name || '',
|
|
|
|
|
r.amount_original,
|
|
|
|
|
r.currency,
|
|
|
|
|
r.exchange_rate,
|
|
|
|
|
r.amount_cny,
|
|
|
|
|
r.counterparty_name || '',
|
|
|
|
|
COUNTERPARTY_TYPES.find(c => c.value === r.counterparty_type)?.label || r.counterparty_type || '',
|
|
|
|
|
r.user_name || '',
|
|
|
|
|
r.description || '',
|
|
|
|
|
r.voucher_no || '',
|
|
|
|
|
]);
|
|
|
|
|
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
|
|
|
|
ws['!cols'] = headers.map(() => ({ wch: 14 }));
|
|
|
|
|
XLSX.utils.book_append_sheet(wb, ws, '财务记账');
|
|
|
|
|
XLSX.writeFile(wb, `财务记录_${dayjs().format('YYYYMMDD')}.xlsx`);
|
|
|
|
|
message.success({ content: '导出成功', key: 'export' });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
message.error({ content: '导出失败', key: 'export' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getLevel1Options = () => {
|
|
|
|
|
const txnType = form.getFieldValue('txn_type');
|
|
|
|
|
if (txnType === 'income') return [{ value: 'income', label: '收入' }];
|
|
|
|
|
return [{ value: 'project', label: '项目支出' }, { value: 'company', label: '公司支出' }];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getLevel2Options = () => {
|
|
|
|
|
const level1 = form.getFieldValue('category_level1');
|
|
|
|
|
if (!level1 || !categories[level1]) return [];
|
|
|
|
|
return categories[level1].map((c: any) => ({ value: c.value, label: c.label }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const projectOptions = projects.map((p: any) => ({ value: p.id, label: p.name }));
|
|
|
|
|
|
2026-05-16 03:04:48 +08:00
|
|
|
const desktopColumns = [
|
|
|
|
|
{ title: '日期', dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date) },
|
2026-05-16 23:19:55 +08:00
|
|
|
{ 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 },
|
2026-05-16 03:04:48 +08:00
|
|
|
{ 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 },
|
2026-05-16 23:19:55 +08:00
|
|
|
{ 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>;
|
|
|
|
|
}},
|
2026-05-16 03:04:48 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
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>
|
2026-05-16 23:19:55 +08:00
|
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
|
|
|
|
|
<div>
|
|
|
|
|
<Title level={3} style={{ marginBottom: 0 }}>财务管理</Title>
|
|
|
|
|
</div>
|
|
|
|
|
<Space>
|
|
|
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
|
|
|
|
|
{isMobile ? '新增' : '新增记录'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
|
|
|
|
{isMobile ? '导出' : '导出Excel'}
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
2026-05-16 03:04:48 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
|
2026-05-16 23:19:55 +08:00
|
|
|
<Col xs={8} sm={8}>
|
2026-05-16 03:04:48 +08:00
|
|
|
<Card size="small" style={{ textAlign: 'center' }}>
|
|
|
|
|
<Statistic title={<span style={{ fontSize: 12 }}>总收入</span>} value={summary.total_income} prefix="¥"
|
2026-05-16 23:19:55 +08:00
|
|
|
valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} />
|
2026-05-16 03:04:48 +08:00
|
|
|
</Card>
|
|
|
|
|
</Col>
|
2026-05-16 23:19:55 +08:00
|
|
|
<Col xs={8} sm={8}>
|
2026-05-16 03:04:48 +08:00
|
|
|
<Card size="small" style={{ textAlign: 'center' }}>
|
|
|
|
|
<Statistic title={<span style={{ fontSize: 12 }}>总支出</span>} value={summary.total_expense} prefix="¥"
|
2026-05-16 23:19:55 +08:00
|
|
|
valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
2026-05-16 03:04:48 +08:00
|
|
|
</Card>
|
|
|
|
|
</Col>
|
2026-05-16 23:19:55 +08:00
|
|
|
<Col xs={8} sm={8}>
|
2026-05-16 03:04:48 +08:00
|
|
|
<Card size="small" style={{ textAlign: 'center' }}>
|
|
|
|
|
<Statistic title={<span style={{ fontSize: 12 }}>净利润</span>} value={summary.net_profit} prefix="¥"
|
2026-05-16 23:19:55 +08:00
|
|
|
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
|
2026-05-16 03:04:48 +08:00
|
|
|
</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>
|
2026-05-16 23:19:55 +08:00
|
|
|
|
|
|
|
|
<Modal
|
|
|
|
|
title="新增财务记录"
|
|
|
|
|
open={addModalVisible}
|
|
|
|
|
onOk={handleSave}
|
|
|
|
|
onCancel={() => setAddModalVisible(false)}
|
|
|
|
|
confirmLoading={addLoading}
|
|
|
|
|
okText="保存"
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
width={isMobile ? '95%' : 600}
|
|
|
|
|
style={{ top: isMobile ? 10 : 40 }}
|
|
|
|
|
destroyOnClose
|
|
|
|
|
>
|
|
|
|
|
<Form form={form} layout={isMobile ? 'vertical' : 'horizontal'} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }} size="middle">
|
|
|
|
|
|
|
|
|
|
<Form.Item name="record_date" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
|
|
|
|
|
<DatePicker style={{ width: '100%' }} />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="txn_type" label="收支类型" rules={[{ required: true, message: '请选择' }]}>
|
|
|
|
|
<Select onChange={handleTxnTypeChange} options={[{ value: 'income', label: '收入' }, { value: 'expense', label: '支出' }]} placeholder="请选择" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="category_level1" label="一级分类" rules={[{ required: true, message: '请选择' }]}>
|
|
|
|
|
<Select onChange={handleLevel1Change} options={getLevel1Options()} placeholder="请选择" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="category_level2" label="二级分类" rules={[{ required: true, message: '请选择' }]}>
|
|
|
|
|
<Select options={getLevel2Options()} placeholder="请先选择一级分类" notFoundContent="请先选择一级分类" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="project_id" label="项目名称"
|
|
|
|
|
rules={[{ required: form.getFieldValue('category_level1') === 'project' || (form.getFieldValue('category_level1') === 'income' && form.getFieldValue('category_level2') !== 'shareholder_investment' && form.getFieldValue('category_level2') !== 'other_income'), message: '请选择项目' }]}
|
|
|
|
|
>
|
|
|
|
|
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder="选择项目" allowClear />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Row gutter={8}>
|
|
|
|
|
<Col span={10}>
|
|
|
|
|
<Form.Item name="amount_original" label={isMobile ? '金额' : '金额'} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: '请输入' }]}>
|
|
|
|
|
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={handleAmountChange} placeholder="0" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={7}>
|
|
|
|
|
<Form.Item name="currency" label={isMobile ? '币种' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
|
|
|
|
<Select options={CURRENCIES} onChange={handleCurrencyChange} />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Col>
|
|
|
|
|
<Col span={7}>
|
|
|
|
|
<Form.Item name="exchange_rate" label={isMobile ? '汇率' : ''} labelCol={{ span: isMobile ? 24 : 0 }} wrapperCol={{ span: isMobile ? 24 : 24 }} rules={[{ required: true }]}>
|
|
|
|
|
<InputNumber style={{ width: '100%' }} min={0} step={0.0001} precision={6} onChange={handleRateChange} />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
<Form.Item label="等效人民币">
|
|
|
|
|
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
|
|
|
|
|
¥{((form.getFieldValue('amount_original') || 0) * (form.getFieldValue('exchange_rate') || 1)).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
|
|
|
</span>
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Divider style={{ margin: '8px 0' }} />
|
|
|
|
|
|
|
|
|
|
<Form.Item name="counterparty_name" label="对方名称" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
|
|
|
|
<Input placeholder="收款方/付款方名称" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="counterparty_type" label="对方类型" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
|
|
|
|
<Select options={COUNTERPARTY_TYPES} placeholder="选择类型" allowClear />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="user_name" label="人员姓名" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
|
|
|
|
<Input placeholder="关联员工姓名" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="description" label="描述" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
|
|
|
|
<Input.TextArea rows={2} placeholder="补充说明" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
|
|
|
|
<Form.Item name="voucher_no" label="凭证编号" labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
|
|
|
|
|
<Input placeholder="发票号/收据号" />
|
|
|
|
|
</Form.Item>
|
|
|
|
|
</Form>
|
|
|
|
|
</Modal>
|
2026-05-16 03:04:48 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default FinancePage;
|