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>(() => ({ income: t('finance.incomeCategory'), project: t('finance.projectExpense'), company: t('finance.companyExpense'), finance: t('cash.financeExpense'), }), [t]); const LEVEL2_LABELS = useMemo>(() => ({ 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>(() => ({ 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({ total_income: 0, total_expense: 0, net_profit: 0 }); const [byCategory, setByCategory] = useState([]); const [records, setRecords] = useState([]); 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(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({ income: [], project: [], company: [], finance: [] }); const [projects, setProjects] = useState([]); const [customers, setCustomers] = useState([]); const [suppliers, setSuppliers] = useState([]); const [exchangeRates, setExchangeRates] = useState>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 }); const [cashRecords, setCashRecords] = useState([]); const [cashPagination, setCashPagination] = useState({ page: 1, pageSize: 20, total: 0 }); const [cashFilterType, setCashFilterType] = useState(undefined); const [cashDateRange, setCashDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); const [cashLoading, setCashLoading] = useState(false); const [amountVal, setAmountVal] = useState(0); const [rateVal, setRateVal] = useState(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 = { 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) => {v === 'income' ? t('finance.income') : t('finance.expense')} }, { 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) => {SOURCE_MAP[v] || v} }, ]; 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) => {v === 'income' ? '↑' : '↓'} }, { 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) => ¥{v?.toLocaleString()}, 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) => ( handleDelete(record.id)}> 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 }) }} /> ); }; const tabItems = [ { key: 'overview', label: t('cash.tabOverview'), icon: , children: renderOverview(), }, { key: 'income', label: t('cash.tabIncome'), icon: , children: renderCashTab('income'), }, { key: 'expense', label: t('cash.tabExpense'), icon: , children: renderCashTab('expense'), }, ]; return (
{t('finance.title')}
setAddModalVisible(false)} confirmLoading={addLoading} okText={t('common.save')} cancelText={t('common.cancel')} width={isMobile ? '95%' : 640} style={{ top: isMobile ? 10 : 40 }} destroyOnClose >
setRateVal(v || 1)} /> ¥{equivalentCny.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} )} { 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 })} > ); }; export default FinancePage;