Files
yunhaifinance/frontend/src/pages/finance/FinancePage.tsx
T
a273825743 706dcc24eb 备份:PWA配置前的完整版本
包含所有最新开发的功能和修复,作为PWA正确配置前的备份点。
2026-06-13 12:44:48 +08:00

655 lines
30 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { Card, Typography, Table, Statistic, Row, Col, Tag, Select, DatePicker, Space, Spin, Button, Modal, Form, Input, InputNumber, message, Divider, Tabs, Popconfirm, Upload } from 'antd';
import { DollarOutlined, RiseOutlined, FallOutlined, PlusOutlined, DownloadOutlined, DeleteOutlined, UploadOutlined, FundOutlined } from '@ant-design/icons';
import apiClient from '../../utils/request';
import dayjs from 'dayjs';
import * as XLSX from 'xlsx';
import { useLanguageStore } from '../../store/languageStore';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const FinancePage: React.FC = () => {
const { t, currentLanguage } = useLanguageStore();
const LEVEL1_LABELS = useMemo<Record<string, string>>(() => ({
income: t('finance.incomeCategory'),
project: t('finance.projectExpense'),
company: t('finance.companyExpense'),
finance: t('cash.financeExpense'),
}), [t]);
const LEVEL2_LABELS = useMemo<Record<string, string>>(() => ({
contract_payment: t('finance.projectRevenue'),
deposit_refund: t('finance.warrantyReturn'),
shareholder_investment: t('finance.shareholderInvestment'),
other_income: t('finance.otherIncome'),
customer_advance: t('cash.customerAdvance'),
bank_loan: t('cash.bankLoan'),
other_loan: t('cash.otherLoan'),
dividend_income: t('cash.dividendIncome'),
interest_income: t('cash.interestIncome'),
asset_disposal: t('cash.assetDisposal'),
tax_refund: t('cash.taxRefund'),
government_subsidy: t('cash.governmentSubsidy'),
material: t('finance.materialPurchase'),
equipment: t('finance.equipmentPurchase'),
subcontract: t('finance.constructionSubcontract'),
construction_subcontract: t('finance.constructionSubcontract'),
labor: t('finance.laborWage'),
travel: t('finance.travelTransport'),
accommodation: t('finance.accommodationFood'),
freight: t('finance.transportLogistics'),
transport_logistics: t('finance.transportLogistics'),
design: t('finance.surveyDesign'),
survey_design: t('finance.surveyDesign'),
tools: t('finance.smallTools'),
client_relations: t('finance.customerEDLRelation'),
customer_edl: t('finance.customerEDLRelation'),
other_project: t('finance.otherProjectExpense'),
salary: t('finance.salaryWelfare'),
rent: t('finance.rentProperty'),
office: t('finance.officeExpense'),
commute: t('finance.commute'),
vehicle_maintenance: t('finance.vehicleMaintenance'),
assets: t('finance.fixedAsset'),
marketing: t('finance.marketing'),
entertainment: t('finance.entertainment'),
welfare: t('finance.employeeBenefit'),
logistics: t('finance.expressLogistics'),
other_company: t('finance.otherCompanyExpense'),
loan_repayment: t('cash.loanRepayment'),
interest_expense: t('cash.interestExpense'),
dividend_payment: t('cash.dividendPayment'),
tax_payment: t('cash.taxPayment'),
deposit_payment: t('cash.depositPayment'),
owner_expense: t('cash.ownerExpense'),
other_finance: t('cash.otherFinance'),
}), [t]);
const COUNTERPARTY_TYPES = useMemo(() => [
{ value: 'supplier', label: t('finance.counterpartySupplier') },
{ value: 'subcontractor', label: t('finance.counterpartySubcontractor') },
{ value: 'customer', label: t('finance.counterpartyCustomer') },
{ value: 'employee', label: t('finance.counterpartyEmployee') },
{ value: 'logistics', label: t('finance.counterpartyLogistics') },
{ value: 'shareholder', label: t('finance.counterpartyShareholder') },
{ value: 'bank', label: t('cash.counterpartyBank') },
{ value: 'other', label: t('finance.counterpartyOther') },
], [t, currentLanguage]);
const CURRENCIES = useMemo(() => [
{ value: 'CNY', label: t('finance.currencyCNY') },
{ value: 'LAK', label: t('finance.currencyLAK') },
{ value: 'USD', label: t('finance.currencyUSD') },
{ value: 'THB', label: t('finance.currencyTHB') },
], [t, currentLanguage]);
const SOURCE_MAP = useMemo<Record<string, string>>(() => ({
manual: t('finance.manual'),
cash_management: t('cash.sourceLabel'),
receipt: t('cash.receiptSource'),
advance: t('finance.advance'),
reimbursement: t('finance.reimbursement'),
payment_request: t('finance.payment'),
material: t('finance.material'),
primary_freight: t('finance.freight'),
secondary_freight: t('finance.freight'),
}), [t]);
const [activeTab, setActiveTab] = useState('overview');
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);
const [addModalVisible, setAddModalVisible] = useState(false);
const [addLoading, setAddLoading] = useState(false);
const [addModalType, setAddModalType] = useState<'income' | 'expense'>('income');
const [form] = Form.useForm();
const [categories, setCategories] = useState<any>({ income: [], project: [], company: [], finance: [] });
const [projects, setProjects] = useState<any[]>([]);
const [customers, setCustomers] = useState<any[]>([]);
const [suppliers, setSuppliers] = useState<any[]>([]);
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 });
const [cashRecords, setCashRecords] = useState<any[]>([]);
const [cashPagination, setCashPagination] = useState({ page: 1, pageSize: 20, total: 0 });
const [cashFilterType, setCashFilterType] = useState<string | undefined>(undefined);
const [cashDateRange, setCashDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [cashLoading, setCashLoading] = useState(false);
const [amountVal, setAmountVal] = useState<number>(0);
const [rateVal, setRateVal] = useState<number>(1);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
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('/customers', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setCustomers(res.data.data || []);
}).catch(() => {});
apiClient.get('/suppliers', { params: { pageSize: 200 } }).then(res => {
if (res.data.success) setSuppliers(res.data.data || []);
}).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(() => {});
}, []);
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);
};
const fetchCashRecords = async (page = 1) => {
setCashLoading(true);
try {
const params: any = { page, pageSize: cashPagination.pageSize };
if (cashDateRange && cashDateRange[0]) {
params.date_from = cashDateRange[0].format('YYYY-MM-DD');
params.date_to = cashDateRange[1]?.format('YYYY-MM-DD');
}
if (cashFilterType) params.txn_type = cashFilterType;
const res = await apiClient.get('/cash-management/records', { params });
if (res.data.success) {
setCashRecords(res.data.data);
setCashPagination(res.data.pagination);
}
} catch (e) { console.error(e); }
setCashLoading(false);
};
useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]);
useEffect(() => { if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1); }, [activeTab, cashDateRange, cashFilterType]);
const handleAddIncome = () => {
setAddModalType('income');
form.resetFields();
setAmountVal(0);
setRateVal(1);
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'income', category_level1: 'income' });
setAddModalVisible(true);
};
const handleAddExpense = () => {
setAddModalType('expense');
form.resetFields();
setAmountVal(0);
setRateVal(1);
form.setFieldsValue({ record_date: dayjs(), currency: 'CNY', exchange_rate: 1, txn_type: 'expense', category_level1: 'project' });
setAddModalVisible(true);
};
const handleLevel1Change = () => {
form.setFieldsValue({ category_level2: undefined, project_id: undefined });
};
const handleCurrencyChange = (currency: string) => {
const rate = exchangeRates[currency] || 1;
setRateVal(rate);
form.setFieldsValue({ exchange_rate: rate });
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setAddLoading(true);
const payload = {
txn_type: values.txn_type,
category_level1: values.category_level1,
category_level2: values.category_level2,
project_id: values.project_id || undefined,
amount: values.amount_original,
currency: values.currency,
exchange_rate: values.exchange_rate,
record_date: values.record_date?.format('YYYY-MM-DD'),
counterparty_name: values.counterparty_name,
counterparty_type: values.counterparty_type,
counterparty_id: values.counterparty_id,
description: values.description,
voucher_url: values.voucher_url,
};
if (!['project', 'income'].includes(payload.category_level1)) {
delete payload.project_id;
}
await apiClient.post('/cash-management', payload);
message.success(t('finance.recordSuccess'));
setAddModalVisible(false);
fetchSummary();
fetchRecords(1);
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
}
setAddLoading(false);
};
const handleDelete = async (id: number) => {
try {
await apiClient.delete(`/cash-management/${id}`);
message.success(t('common.deleteSuccess'));
fetchSummary();
fetchRecords(1);
if (activeTab === 'income' || activeTab === 'expense') fetchCashRecords(1);
} catch (e: any) {
if (e.response?.data?.message) message.error(e.response.data.message);
}
};
const handleExport = async () => {
try {
message.loading({ content: t('finance.exporting'), 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 = [
t('finance.date'), t('finance.incomeType'), t('finance.level1Category'), t('finance.level2Category'),
t('finance.projectName'), t('finance.amount'), t('finance.currency'), t('finance.exchangeRate'),
t('finance.equivalentCNY'), t('finance.counterpartyName'), t('finance.counterpartyType'),
t('finance.personName'), t('finance.desc'), t('finance.source'),
];
const rows = data.map((r: any) => [
r.record_date,
r.txn_type === 'income' ? t('finance.income') : t('finance.expense'),
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 || '',
SOURCE_MAP[r.source] || r.source || '',
]);
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
ws['!cols'] = headers.map(() => ({ wch: 14 }));
XLSX.utils.book_append_sheet(wb, ws, t('finance.sheetName'));
XLSX.writeFile(wb, `${t('finance.title')}_${dayjs().format('YYYYMMDD')}.xlsx`);
message.success({ content: t('finance.exportSuccess'), key: 'export' });
} catch (e) {
message.error({ content: t('finance.exportFailed'), key: 'export' });
}
};
const getLevel1Options = (txnType: string) => {
if (txnType === 'income') return [{ value: 'income', label: t('finance.incomeCategory') }];
return [
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
];
};
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 }));
const getCounterpartyOptions = () => {
const type = form.getFieldValue('counterparty_type');
if (type === 'customer') return customers.map((c: any) => ({ value: c.id, label: c.name }));
if (type === 'supplier') return suppliers.map((s: any) => ({ value: s.id, label: s.name }));
return [];
};
const equivalentCny = amountVal * rateVal;
const desktopColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, sorter: (a: any, b: any) => a.record_date?.localeCompare(b.record_date), render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 60, render: (v: string) => <Tag color={v === 'income' ? 'green' : 'red'}>{v === 'income' ? t('finance.income') : t('finance.expense')}</Tag> },
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 100, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
{ title: t('finance.equivalentCNY'), 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: t('finance.personName'), dataIndex: 'user_name', width: 70 },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{ title: t('finance.source'), dataIndex: 'source', width: 80, render: (v: string) => <Tag>{SOURCE_MAP[v] || v}</Tag> },
];
const mobileColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.incomeType'), dataIndex: 'txn_type', width: 40, render: (v: string) => <span style={{ color: v === 'income' ? 'green' : 'red' }}>{v === 'income' ? '↑' : '↓'}</span> },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number) => <b>¥{v?.toLocaleString()}</b>, align: 'right' as const },
];
const cashColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 100, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.level1Category'), dataIndex: 'category_level1', width: 90, render: (v: string) => LEVEL1_LABELS[v] || v },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 110, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.projectName'), dataIndex: 'project_name', width: 140, ellipsis: true },
{ title: t('finance.amount'), dataIndex: 'amount_original', width: 100, render: (v: number) => v?.toLocaleString(), align: 'right' as const },
{ title: t('finance.currency'), dataIndex: 'currency', width: 50 },
{ title: t('finance.equivalentCNY'), dataIndex: 'amount_cny', width: 110, render: (v: number) => ${v?.toLocaleString()}`, align: 'right' as const },
{ title: t('finance.counterpartyName'), dataIndex: 'counterparty_name', width: 100, ellipsis: true },
{ title: t('finance.desc'), dataIndex: 'description', ellipsis: true },
{
title: '', width: 50, render: (_: any, record: any) => (
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)
},
];
const cashMobileColumns = [
{ title: t('finance.date'), dataIndex: 'record_date', width: 80, render: (v: string) => v?.slice(0, 10) },
{ title: t('finance.level2Category'), dataIndex: 'category_level2', width: 80, render: (v: string) => LEVEL2_LABELS[v] || v },
{ title: t('finance.amount'), dataIndex: 'amount_cny', render: (v: number, r: any) => <span style={{ color: r.txn_type === 'income' ? 'green' : 'red' }}>¥{v?.toLocaleString()}</span>, align: 'right' as const },
{
title: '', width: 40, render: (_: any, record: any) => (
<Popconfirm title={t('common.confirmDelete')} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
)
},
];
const renderOverview = () => (
<>
<Row gutter={[8, 8]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={8}>
<Card size="small" style={{ textAlign: 'center' }}>
<Statistic title={<span style={{ fontSize: 12 }}>{t('finance.totalIncome')}</span>} 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={<span style={{ fontSize: 12 }}>{t('finance.totalExpense')}</span>} 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={<span style={{ fontSize: 12 }}>{t('finance.netProfit')}</span>} value={summary.net_profit} prefix="¥"
valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} />
</Card>
</Col>
</Row>
{byCategory.length > 0 && (
<Card title={t('finance.expenseSummary')} 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={t('finance.detail')} 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={t('finance.filterType')} style={{ width: 100 }}
onChange={setFilterType} value={filterType}
options={[{ value: 'income', label: t('finance.income') }, { value: 'expense', label: t('finance.expense') }]} />
</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) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
);
const renderCashTab = (type: 'income' | 'expense') => {
const isIncome = type === 'income';
return (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space size="small" wrap>
<RangePicker size="small" onChange={(dates) => setCashDateRange(dates as any)} />
<Select size="small" allowClear placeholder={t('finance.level1Category')} style={{ width: 120 }}
onChange={(v) => { setCashFilterType(v); }}
options={isIncome
? [{ value: 'income', label: t('finance.incomeCategory') }]
: [
{ value: 'project', label: t('finance.projectExpense') },
{ value: 'company', label: t('finance.companyExpense') },
{ value: 'finance', label: t('cash.financeExpense') },
]
}
/>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={isIncome ? handleAddIncome : handleAddExpense}>
{isMobile ? t('common.add') : (isIncome ? t('cash.addIncome') : t('cash.addExpense'))}
</Button>
</div>
<Card size="small" styles={{ body: { padding: isMobile ? 8 : 16 } }}>
<Spin spinning={cashLoading}>
<Table
dataSource={cashRecords.filter(r => isIncome ? r.txn_type === 'income' : r.txn_type === 'expense')}
columns={isMobile ? cashMobileColumns : cashColumns}
rowKey="id"
size={isMobile ? 'small' : 'middle'}
scroll={isMobile ? { x: 400 } : { x: 1000 }}
pagination={{
current: cashPagination.page,
pageSize: cashPagination.pageSize,
total: cashPagination.total,
onChange: (page) => fetchCashRecords(page),
size: isMobile ? 'small' : 'default',
showTotal: (total) => t('finance.totalRecords', { total })
}}
/>
</Spin>
</Card>
</>
);
};
const tabItems = [
{
key: 'overview',
label: t('cash.tabOverview'),
icon: <FundOutlined />,
children: renderOverview(),
},
{
key: 'income',
label: t('cash.tabIncome'),
icon: <RiseOutlined />,
children: renderCashTab('income'),
},
{
key: 'expense',
label: t('cash.tabExpense'),
icon: <FallOutlined />,
children: renderCashTab('expense'),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Title level={3} style={{ marginBottom: 0 }}>{t('finance.title')}</Title>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleExport}>
{isMobile ? t('common.export') : t('finance.exportExcel')}
</Button>
</Space>
</div>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} type="card" />
<Modal
title={addModalType === 'income' ? t('cash.addIncome') : t('cash.addExpense')}
open={addModalVisible}
onOk={handleSave}
onCancel={() => setAddModalVisible(false)}
confirmLoading={addLoading}
okText={t('common.save')}
cancelText={t('common.cancel')}
width={isMobile ? '95%' : 640}
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={t('finance.date')} rules={[{ required: true, message: t('finance.selectDate') }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="category_level1" label={t('finance.level1Category')} rules={[{ required: true, message: t('finance.selectLevel1') }]}>
<Select onChange={handleLevel1Change} options={getLevel1Options(addModalType)} placeholder={t('finance.selectLevel1')} />
</Form.Item>
<Form.Item name="category_level2" label={t('finance.level2Category')} rules={[{ required: true, message: t('finance.selectLevel2') }]}>
<Select options={getLevel2Options()} placeholder={t('finance.selectLevel2')} notFoundContent={t('finance.selectLevel2')} />
</Form.Item>
<Form.Item name="project_id" label={t('finance.projectName')}>
<Select showSearch optionFilterProp="label" options={projectOptions} placeholder={t('finance.selectProject')} allowClear />
</Form.Item>
<Row gutter={8}>
<Col span={10}>
<Form.Item name="amount_original" label={t('finance.amount')} labelCol={{ span: isMobile ? 24 : 8 }} wrapperCol={{ span: isMobile ? 24 : 16 }} rules={[{ required: true, message: t('common.inputPlaceholder') }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} onChange={(v) => setAmountVal(v || 0)} placeholder={t('finance.amountPlaceholder')} />
</Form.Item>
</Col>
<Col span={7}>
<Form.Item name="currency" label={isMobile ? t('finance.currency') : ''} 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 ? t('finance.exchangeRate') : ''} 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={(v) => setRateVal(v || 1)} />
</Form.Item>
</Col>
</Row>
<Form.Item label={t('finance.equivalentCNY')}>
<span style={{ fontSize: 18, fontWeight: 'bold', color: '#1890ff' }}>
¥{equivalentCny.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</Form.Item>
<Divider style={{ margin: '8px 0' }} />
<Form.Item name="counterparty_type" label={t('finance.counterpartyType')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Select options={COUNTERPARTY_TYPES} placeholder={t('finance.selectType')} allowClear onChange={() => form.setFieldsValue({ counterparty_id: undefined })} />
</Form.Item>
{getCounterpartyOptions().length > 0 && (
<Form.Item name="counterparty_id" label={t('cash.counterpartySelect')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Select showSearch optionFilterProp="label" options={getCounterpartyOptions()} placeholder={t('cash.counterpartySelectPlaceholder')} allowClear />
</Form.Item>
)}
<Form.Item name="counterparty_name" label={t('finance.counterpartyName')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input placeholder={t('finance.counterpartyPlaceholder')} />
</Form.Item>
<Form.Item name="description" label={t('finance.desc')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Input.TextArea rows={2} placeholder={t('finance.descPlaceholder')} />
</Form.Item>
<Form.Item name="voucher_url" label={t('cash.voucherUpload')} labelCol={{ span: isMobile ? 24 : 6 }} wrapperCol={{ span: isMobile ? 24 : 18 }}>
<Upload
maxCount={1}
action="/api/upload/single"
headers={{ Authorization: `Bearer ${JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state?.token || ''}` }}
onChange={(info) => {
if (info.file.status === 'done' && info.file.response?.url) {
form.setFieldsValue({ voucher_url: info.file.response.url });
message.success(t('cash.uploadSuccess'));
} else if (info.file.status === 'error') {
message.error(t('cash.uploadFailed'));
}
}}
onRemove={() => form.setFieldsValue({ voucher_url: undefined })}
>
<Button icon={<UploadOutlined />}>{t('cash.uploadVoucher')}</Button>
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default FinancePage;