forked from wangziqi/gongxue-base
168 lines
9.1 KiB
TypeScript
168 lines
9.1 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';
|
||
import { newOperationId } from '../../utils/operation-id';
|
||
|
||
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 [batchForm] = Form.useForm();
|
||
const [saving, setSaving] = useState(false);
|
||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||
const [batchModalOpen, setBatchModalOpen] = 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 openBatchChange = () => {
|
||
batchForm.setFieldsValue({ type: 'recharge', amount: undefined, description: '' });
|
||
setBatchModalOpen(true);
|
||
};
|
||
|
||
const submitChange = async () => {
|
||
if (!selected) return;
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const result: any = await api.post('/wallets/change-balance', { operationId: newOperationId(), 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 submitBatchChange = async () => {
|
||
const values = await batchForm.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const result: any = await api.post('/wallets/batch-change-balance', {
|
||
operationId: newOperationId(),
|
||
studentIds: selectedRowKeys,
|
||
...values,
|
||
});
|
||
const paid = (result.results || []).reduce((sum: number, item: any) => {
|
||
return sum + (item.payments || []).reduce((paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0), 0);
|
||
}, 0);
|
||
message.success(
|
||
paid > 0
|
||
? `已批量更新 ${selectedRowKeys.length} 名学生余额,并自动补扣历史账单`
|
||
: `已批量更新 ${selectedRowKeys.length} 名学生余额`,
|
||
);
|
||
setBatchModalOpen(false);
|
||
setSelectedRowKeys([]);
|
||
batchForm.resetFields();
|
||
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>
|
||
<Space wrap>
|
||
<PermissionButton permission="wallet:edit" type="primary" icon={<PlusOutlined />} disabled={selectedRowKeys.length === 0} onClick={openBatchChange}>批量充值/调账</PermissionButton>
|
||
<Button icon={<ReloadOutlined />} onClick={fetchRows}>刷新</Button>
|
||
</Space>
|
||
</div>
|
||
<Table
|
||
rowKey="studentId"
|
||
loading={loading}
|
||
dataSource={rows}
|
||
columns={columns}
|
||
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
|
||
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>
|
||
<Modal
|
||
title={`批量余额操作(${selectedRowKeys.length} 人)`}
|
||
open={batchModalOpen}
|
||
onCancel={() => setBatchModalOpen(false)}
|
||
onOk={submitBatchChange}
|
||
confirmLoading={saving}
|
||
okText="确认批量修改"
|
||
>
|
||
<Form form={batchForm} layout="vertical">
|
||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||
已选择 <strong>{selectedRowKeys.length}</strong> 名学生,将按相同金额批量修改水电费余额。
|
||
</div>
|
||
<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;
|