103 lines
6.2 KiB
TypeScript
103 lines
6.2 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
|
|
import { HistoryOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import api from '../../api';
|
|
import PermissionButton from '../../components/PermissionButton';
|
|
import { message } from '../../ui/app-message';
|
|
|
|
interface WalletRow {
|
|
studentId: number;
|
|
studentName: string;
|
|
studentNo?: string;
|
|
balance: number;
|
|
outstandingAmount: number;
|
|
}
|
|
|
|
const transactionNames: Record<string, string> = {
|
|
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
|
|
};
|
|
|
|
const WalletsPage: React.FC = () => {
|
|
const [rows, setRows] = useState<WalletRow[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [debtOnly, setDebtOnly] = useState(false);
|
|
const [selected, setSelected] = useState<WalletRow | null>(null);
|
|
const [transactions, setTransactions] = useState<any[]>([]);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [form] = Form.useForm();
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const fetchRows = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
|
|
setRows(data as WalletRow[]);
|
|
} catch (error: any) {
|
|
message.error(error?.message || '加载学生余额失败');
|
|
} finally { setLoading(false); }
|
|
}, [keyword, debtOnly]);
|
|
|
|
useEffect(() => { void fetchRows(); }, [fetchRows]);
|
|
|
|
const openChange = (row: WalletRow) => {
|
|
setSelected(row);
|
|
form.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
|
};
|
|
|
|
const submitChange = async () => {
|
|
if (!selected) return;
|
|
const values = await form.validateFields();
|
|
setSaving(true);
|
|
try {
|
|
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
|
|
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
|
|
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
|
|
setSelected(null);
|
|
await fetchRows();
|
|
} catch (error: any) { message.error(error?.message || '余额操作失败'); }
|
|
finally { setSaving(false); }
|
|
};
|
|
|
|
const showTransactions = async (row: WalletRow) => {
|
|
setSelected(row); setDrawerOpen(true);
|
|
try { setTransactions(await api.get('/wallets/transactions', { params: { studentId: row.studentId } }) as any[]); }
|
|
catch (error: any) { message.error(error?.message || '加载流水失败'); }
|
|
};
|
|
|
|
const columns = useMemo(() => [
|
|
{ title: '学生', render: (_: unknown, row: WalletRow) => <><strong>{row.studentName}</strong><div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div></> },
|
|
{ title: '可用余额', dataIndex: 'balance', render: (value: number) => <strong style={{ color: Number(value) > 0 ? '#1677ff' : undefined }}>¥{Number(value).toFixed(2)}</strong> },
|
|
{ title: '未付账单', dataIndex: 'outstandingAmount', render: (value: number) => Number(value) > 0 ? <Tag color="red">¥{Number(value).toFixed(2)}</Tag> : <Tag color="green">无欠费</Tag> },
|
|
{ title: '操作', render: (_: unknown, row: WalletRow) => <Space><PermissionButton permission="wallet:edit" type="primary" size="small" icon={<PlusOutlined />} onClick={() => openChange(row)}>充值/调账</PermissionButton><Button size="small" icon={<HistoryOutlined />} onClick={() => showTransactions(row)}>流水</Button></Space> },
|
|
], []);
|
|
|
|
return <div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
|
<Space wrap><Input.Search allowClear placeholder="搜索姓名或学号" style={{ width: 240 }} onSearch={setKeyword} onChange={(event) => !event.target.value && setKeyword('')} /><span>仅看欠费</span><Switch checked={debtOnly} onChange={setDebtOnly} /></Space>
|
|
<Button icon={<ReloadOutlined />} onClick={fetchRows}>刷新</Button>
|
|
</div>
|
|
<Table rowKey="studentId" loading={loading} dataSource={rows} columns={columns} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }} />
|
|
<Modal title={`${selected?.studentName || ''} - 余额操作`} open={!!selected && !drawerOpen} onCancel={() => setSelected(null)} onOk={submitChange} confirmLoading={saving} okText="确认">
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="type" label="操作类型" rules={[{ required: true }]}><Radio.Group options={[{ label: '充值', value: 'recharge' }, { label: '调账', value: 'adjustment' }]} /></Form.Item>
|
|
<Form.Item name="amount" label="变动金额" extra="充值填正数;调减余额时填写负数。充值后会按最早账单优先自动补扣。" rules={[{ required: true, message: '请输入金额' }]}><InputNumber precision={2} style={{ width: '100%' }} addonBefore="¥" /></Form.Item>
|
|
<Form.Item name="description" label="备注"><Input.TextArea maxLength={300} /></Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
<Drawer title={`${selected?.studentName || ''} - 余额流水`} width={680} open={drawerOpen} onClose={() => { setDrawerOpen(false); setSelected(null); }}>
|
|
<Table rowKey="id" dataSource={transactions} pagination={{ pageSize: 10 }} columns={[
|
|
{ title: '时间', dataIndex: 'createdAt', render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm') },
|
|
{ title: '类型', dataIndex: 'type', render: (value: string) => transactionNames[value] || value },
|
|
{ title: '金额', dataIndex: 'amount', render: (value: number) => <span style={{ color: Number(value) >= 0 ? '#389e0d' : '#cf1322' }}>{Number(value) >= 0 ? '+' : ''}¥{Number(value).toFixed(2)}</span> },
|
|
{ title: '变动后余额', dataIndex: 'balanceAfter', render: (value: number) => `¥${Number(value).toFixed(2)}` },
|
|
{ title: '关联账单', dataIndex: 'billId', render: (value: number) => value ? `#${value}` : '-' },
|
|
{ title: '说明', dataIndex: 'description' },
|
|
]} />
|
|
</Drawer>
|
|
</div>;
|
|
};
|
|
|
|
export default WalletsPage;
|