232 lines
9.1 KiB
TypeScript
232 lines
9.1 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { Table, Button, Card, Space, Tag, Modal, Form, Input, InputNumber, Select, DatePicker, message } from 'antd';
|
||
|
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||
|
|
import axios from 'axios';
|
||
|
|
import dayjs from 'dayjs';
|
||
|
|
|
||
|
|
const { Option } = Select;
|
||
|
|
const { TextArea } = Input;
|
||
|
|
|
||
|
|
const AdvanceList: React.FC = () => {
|
||
|
|
const [advances, setAdvances] = useState([]);
|
||
|
|
const [projects, setProjects] = useState([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [modalVisible, setModalVisible] = useState(false);
|
||
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||
|
|
const [form] = Form.useForm();
|
||
|
|
const [isMobile, setIsMobile] = useState(false);
|
||
|
|
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({});
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const checkMobile = () => setIsMobile(window.innerWidth <= 768);
|
||
|
|
checkMobile();
|
||
|
|
window.addEventListener('resize', checkMobile);
|
||
|
|
return () => window.removeEventListener('resize', checkMobile);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchAdvances();
|
||
|
|
fetchProjects();
|
||
|
|
fetchExchangeRates();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const fetchAdvances = async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await axios.get('/api/advances');
|
||
|
|
if (res.data.success) setAdvances(res.data.data);
|
||
|
|
} catch (error) {
|
||
|
|
message.error('获取预支列表失败');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchProjects = async () => {
|
||
|
|
try {
|
||
|
|
const res = await axios.get('/api/projects');
|
||
|
|
if (res.data.success) setProjects(res.data.data);
|
||
|
|
} catch (error) {}
|
||
|
|
};
|
||
|
|
|
||
|
|
const fetchExchangeRates = async () => {
|
||
|
|
try {
|
||
|
|
const res = await axios.get('/api/exchange-rates/latest');
|
||
|
|
if (res.data.success) {
|
||
|
|
const rates: Record<string, number> = {};
|
||
|
|
Object.keys(res.data.data).forEach(key => {
|
||
|
|
rates[key] = parseFloat(res.data.data[key]) || 1;
|
||
|
|
});
|
||
|
|
setExchangeRates(rates);
|
||
|
|
}
|
||
|
|
} catch (error) {}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCreate = () => {
|
||
|
|
setEditingId(null);
|
||
|
|
form.resetFields();
|
||
|
|
setModalVisible(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEdit = (record: any) => {
|
||
|
|
setEditingId(record.id);
|
||
|
|
form.setFieldsValue({
|
||
|
|
...record,
|
||
|
|
advance_date: record.advance_date ? dayjs(record.advance_date) : null,
|
||
|
|
});
|
||
|
|
setModalVisible(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDelete = async (id: number) => {
|
||
|
|
try {
|
||
|
|
await axios.delete('/api/advances/' + id);
|
||
|
|
message.success('删除成功');
|
||
|
|
fetchAdvances();
|
||
|
|
} catch (error) {
|
||
|
|
message.error('删除失败');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSubmit = async () => {
|
||
|
|
try {
|
||
|
|
const values = await form.validateFields();
|
||
|
|
const data = {
|
||
|
|
...values,
|
||
|
|
advance_date: values.advance_date?.format('YYYY-MM-DD'),
|
||
|
|
amount_cny: values.currency === 'CNY' ? values.amount : convertToCNY(values.amount, values.currency),
|
||
|
|
};
|
||
|
|
if (editingId) {
|
||
|
|
await axios.put('/api/advances/' + editingId, data);
|
||
|
|
message.success('更新成功');
|
||
|
|
} else {
|
||
|
|
await axios.post('/api/advances', data);
|
||
|
|
message.success('创建成功');
|
||
|
|
}
|
||
|
|
setModalVisible(false);
|
||
|
|
fetchAdvances();
|
||
|
|
} catch (error) {
|
||
|
|
message.error('操作失败');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 汇率换算 - 将外币转换为人民币
|
||
|
|
const convertToCNY = (amount: number, currency: string): number => {
|
||
|
|
if (currency === 'CNY') return amount;
|
||
|
|
// 外币转人民币:需要知道 1外币 = ?人民币
|
||
|
|
// 数据库存的是 CNY_XXX,即 1人民币 = ?外币
|
||
|
|
// 所以 1外币 = 1/rate 人民币
|
||
|
|
const rateKey = 'CNY_' + currency;
|
||
|
|
const rate = exchangeRates[rateKey] || 1;
|
||
|
|
return amount / rate;
|
||
|
|
};
|
||
|
|
|
||
|
|
// 监听金额和币种变化
|
||
|
|
const amount = Form.useWatch('amount', form);
|
||
|
|
const currency = Form.useWatch('currency', form);
|
||
|
|
const expenseType = Form.useWatch('expense_type', form);
|
||
|
|
const amountCNY = amount && currency ? convertToCNY(amount, currency) : 0;
|
||
|
|
|
||
|
|
const getStatusTag = (status: string) => {
|
||
|
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||
|
|
pending: { color: 'processing', text: '待审批' },
|
||
|
|
approved: { color: 'success', text: '已批准' },
|
||
|
|
rejected: { color: 'error', text: '已拒绝' },
|
||
|
|
settled: { color: 'blue', text: '已核销' },
|
||
|
|
};
|
||
|
|
const config = statusMap[status] || { color: 'default', text: status };
|
||
|
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatAmount = (amount: number, currency: string = 'CNY') => {
|
||
|
|
const symbols: Record<string, string> = { CNY: '¥', USD: '$', LAK: '₭', THB: '฿' };
|
||
|
|
return (symbols[currency] || '¥') + (amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
|
|
};
|
||
|
|
|
||
|
|
const columns = [
|
||
|
|
{ title: '预支编号', dataIndex: 'advance_code', key: 'advance_code', width: 120 },
|
||
|
|
{ title: '预支日期', dataIndex: 'advance_date', key: 'advance_date', width: 100 },
|
||
|
|
{ title: '支出类型', dataIndex: 'expense_type', key: 'expense_type', render: (v: string) => v === 'project' ? '项目支出' : '公用支出' },
|
||
|
|
{ title: '项目', dataIndex: 'project_name', key: 'project_name', render: (v: string) => v || '-' },
|
||
|
|
{ title: '金额', dataIndex: 'amount', key: 'amount', render: (v: number, r: any) => formatAmount(v, r.currency) },
|
||
|
|
{ title: '等价人民币', dataIndex: 'amount_cny', key: 'amount_cny', render: (v: number) => <span style={{ color: '#888' }}>{formatAmount(v)}</span> },
|
||
|
|
{ title: '事由', dataIndex: 'reason', key: 'reason', ellipsis: true },
|
||
|
|
{ title: '状态', dataIndex: 'status', key: 'status', render: (status: string) => getStatusTag(status) },
|
||
|
|
{ title: '操作', key: 'action', width: 180, render: (_: any, record: any) => (
|
||
|
|
<Space>
|
||
|
|
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||
|
|
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record.id)}>删除</Button>
|
||
|
|
</Space>
|
||
|
|
)}
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||
|
|
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||
|
|
<h2 style={{ marginBottom: 8 }}>预支管理</h2>
|
||
|
|
<p style={{ color: '#888', marginBottom: 0 }}>管理员工预支申请</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Card extra={<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新建预支</Button>}>
|
||
|
|
<Table dataSource={advances} columns={columns} rowKey="id" loading={loading} pagination={{ pageSize: 20 }} size="middle" scroll={{ x: 1200 }} />
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Modal title={editingId ? '编辑预支' : '新建预支'} open={modalVisible} onOk={handleSubmit} onCancel={() => setModalVisible(false)} width={600}>
|
||
|
|
<Form form={form} layout="vertical">
|
||
|
|
<Form.Item name="advance_date" label="预支日期" rules={[{ required: true }]}>
|
||
|
|
<DatePicker style={{ width: '100%' }} />
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
<Form.Item name="expense_type" label="支出类型" rules={[{ required: true }]}>
|
||
|
|
<Select placeholder="选择支出类型" onChange={() => form.setFieldsValue({ project_id: undefined })}>
|
||
|
|
<Option value="public">公用支出</Option>
|
||
|
|
<Option value="project">项目支出</Option>
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
{expenseType === 'project' && (
|
||
|
|
<Form.Item name="project_id" label="选择项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||
|
|
<Select placeholder="选择项目" showSearch optionFilterProp="children">
|
||
|
|
{projects.map((p: any) => <Option key={p.id} value={p.id}>{p.name}</Option>)}
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Form.Item label="金额" required>
|
||
|
|
<Space>
|
||
|
|
<Form.Item name="currency" noStyle initialValue="CNY">
|
||
|
|
<Select style={{ width: 120 }}>
|
||
|
|
<Option value="CNY">人民币 (CNY)</Option>
|
||
|
|
<Option value="USD">美元 (USD)</Option>
|
||
|
|
<Option value="LAK">老挝基普 (LAK)</Option>
|
||
|
|
<Option value="THB">泰铢 (THB)</Option>
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
<Form.Item name="amount" noStyle rules={[{ required: true, message: '请输入金额' }]}>
|
||
|
|
<InputNumber
|
||
|
|
style={{ width: 200 }}
|
||
|
|
min={0}
|
||
|
|
precision={2}
|
||
|
|
formatter={v => v ? v.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''}
|
||
|
|
parser={v => v ? v.replace(/,/g, '') : ''}
|
||
|
|
placeholder="输入金额"
|
||
|
|
/>
|
||
|
|
</Form.Item>
|
||
|
|
</Space>
|
||
|
|
{amountCNY > 0 && (
|
||
|
|
<div style={{ marginTop: 8, color: '#888', fontSize: 13 }}>
|
||
|
|
等价人民币:¥ {amountCNY.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||
|
|
<TextArea rows={3} placeholder="请输入预支事由" />
|
||
|
|
</Form.Item>
|
||
|
|
</Form>
|
||
|
|
</Modal>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default AdvanceList;
|