diff --git a/frontend/src/pages/finance/FinancePage.tsx b/frontend/src/pages/finance/FinancePage.tsx index 049e250..e35d84a 100644 --- a/frontend/src/pages/finance/FinancePage.tsx +++ b/frontend/src/pages/finance/FinancePage.tsx @@ -1,8 +1,9 @@ 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 { 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'; import apiClient from '../../utils/request'; import dayjs from 'dayjs'; +import * as XLSX from 'xlsx'; const { Title, Paragraph } = Typography; const { RangePicker } = DatePicker; @@ -18,6 +19,20 @@ const LEVEL2_LABELS: Record = { entertainment: '招待费用', welfare: '员工福利', logistics: '快递物流', other_company: '其他公司支出' }; +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 (泰铢)' }, +]; + const FinancePage: React.FC = () => { const [loading, setLoading] = useState(false); const [summary, setSummary] = useState({ total_income: 0, total_expense: 0, net_profit: 0 }); @@ -28,6 +43,14 @@ const FinancePage: React.FC = () => { const [filterType, setFilterType] = useState(undefined); const [isMobile, setIsMobile] = useState(false); + const [addModalVisible, setAddModalVisible] = useState(false); + const [addLoading, setAddLoading] = useState(false); + const [form] = Form.useForm(); + + const [categories, setCategories] = useState({ income: [], project: [], company: [] }); + const [projects, setProjects] = useState([]); + const [exchangeRates, setExchangeRates] = useState>({ CNY: 1, LAK: 0.0003, USD: 7.2, THB: 0.2 }); + useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth <= 768); checkMobile(); @@ -35,6 +58,25 @@ const FinancePage: React.FC = () => { 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('/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 = {}; @@ -70,33 +112,130 @@ const FinancePage: React.FC = () => { useEffect(() => { fetchSummary(); fetchRecords(1); }, [dateRange, filterType]); + 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 })); + 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) => {v === 'income' ? '收入' : '支出'} - }, - { - 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: 'txn_type', width: 60, render: (v: string) => {v === 'income' ? '收入' : '支出'} }, + { 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 = { manual: '手动导入', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' }; - return {m[v] || v}; - } - } + { title: '来源', dataIndex: 'source', width: 80, render: (v: string) => { + const m: Record = { manual: '手动', advance: '预支', reimbursement: '报销', payment_request: '付款', material: '材料', primary_freight: '运费', secondary_freight: '运费' }; + return {m[v] || v}; + }}, ]; const mobileColumns = [ @@ -108,31 +247,37 @@ const FinancePage: React.FC = () => { return (
-
- 财务管理 - 查看公司财务状况、收支明细 +
+
+ 财务管理 +
+ + + +
- + 总收入} value={summary.total_income} prefix="¥" - valueStyle={{ color: '#3f8600', fontSize: isMobile ? 18 : undefined }} - icon={} /> + valueStyle={{ color: '#3f8600', fontSize: isMobile ? 16 : undefined }} /> - + 总支出} value={summary.total_expense} prefix="¥" - valueStyle={{ color: '#cf1322', fontSize: isMobile ? 18 : undefined }} - icon={} /> + valueStyle={{ color: '#cf1322', fontSize: isMobile ? 16 : undefined }} /> - + 净利润} value={summary.net_profit} prefix="¥" - valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 18 : undefined }} - icon={} /> + valueStyle={{ color: summary.net_profit >= 0 ? '#3f8600' : '#cf1322', fontSize: isMobile ? 16 : undefined }} /> @@ -178,6 +323,90 @@ const FinancePage: React.FC = () => { /> + + setAddModalVisible(false)} + confirmLoading={addLoading} + okText="保存" + cancelText="取消" + width={isMobile ? '95%' : 600} + style={{ top: isMobile ? 10 : 40 }} + destroyOnClose + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
); };