441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
import React, { useCallback, useMemo, useState } from 'react';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||
import { validateResponse } from '../../utils/validate';
|
||
import { roomTypesSchema, walletsSchema } from '../../api/schemas';
|
||
import {
|
||
Button,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Radio,
|
||
Select,
|
||
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;
|
||
roomType?: string;
|
||
roomNumber?: string;
|
||
}
|
||
|
||
interface WalletTransaction {
|
||
id: number;
|
||
createdAt: string;
|
||
type: string;
|
||
amount: number;
|
||
balanceAfter: number;
|
||
}
|
||
|
||
const transactionNames: Record<string, string> = {
|
||
recharge: '充值',
|
||
adjustment: '调账',
|
||
bill_payment: '账单扣款',
|
||
bill_refund: '账单冲正',
|
||
};
|
||
|
||
const WalletsPage: React.FC = () => {
|
||
const [keyword, setKeyword] = useState('');
|
||
const [debtOnly, setDebtOnly] = useState(false);
|
||
const [roomType, setRoomType] = useState<string | undefined>();
|
||
const [selected, setSelected] = useState<WalletRow | null>(null);
|
||
const [transactions, setTransactions] = useState<WalletTransaction[]>([]);
|
||
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 {
|
||
data: rows = [],
|
||
isLoading,
|
||
isFetching,
|
||
refetch,
|
||
} = useQuery<WalletRow[]>({
|
||
queryKey: ['wallets', keyword, debtOnly, roomType],
|
||
queryFn: async () => {
|
||
try {
|
||
return validateResponse<WalletRow[]>(
|
||
walletsSchema,
|
||
await api.get('/wallets', {
|
||
params: { keyword: keyword || undefined, debtOnly, roomType },
|
||
}),
|
||
);
|
||
} catch (error: any) {
|
||
message.error(error?.message || '加载学生余额失败');
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
const { data: roomTypes = [] } = useQuery<string[]>({
|
||
queryKey: ['wallets', 'room-types'],
|
||
queryFn: async () => {
|
||
try {
|
||
return validateResponse<string[]>(
|
||
roomTypesSchema,
|
||
await api.get('/wallets/room-types'),
|
||
);
|
||
} catch (error: any) {
|
||
message.error(error?.message || '加载房型失败');
|
||
return [];
|
||
}
|
||
},
|
||
});
|
||
const loading = isLoading || isFetching;
|
||
const fetchRows = useCallback(() => refetch(), [refetch]);
|
||
|
||
const changeMutation = useApiMutation(
|
||
async (payload: Record<string, unknown>) => api.post('/wallets/change-balance', payload),
|
||
{ invalidate: [['wallets']] },
|
||
);
|
||
const batchChangeMutation = useApiMutation(
|
||
async (payload: Record<string, unknown>) =>
|
||
api.post('/wallets/batch-change-balance', payload),
|
||
{ invalidate: [['wallets']] },
|
||
);
|
||
|
||
const updateKeyword = (value: string) => {
|
||
setKeyword(value);
|
||
setSelectedRowKeys([]);
|
||
};
|
||
const updateRoomType = (value: string | undefined) => {
|
||
setRoomType(value);
|
||
setSelectedRowKeys([]);
|
||
};
|
||
const updateDebtOnly = (value: boolean) => {
|
||
setDebtOnly(value);
|
||
setSelectedRowKeys([]);
|
||
};
|
||
|
||
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 changeMutation.mutateAsync({
|
||
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);
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const submitBatchChange = async () => {
|
||
const values = await batchForm.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
const result: any = await batchChangeMutation.mutateAsync({
|
||
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();
|
||
} catch {
|
||
// 错误提示由 useApiMutation 统一处理
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const showTransactions = async (row: WalletRow) => {
|
||
setSelected(row);
|
||
setDrawerOpen(true);
|
||
try {
|
||
setTransactions(
|
||
await api.get<WalletTransaction[]>('/wallets/transactions', {
|
||
params: { studentId: row.studentId },
|
||
}),
|
||
);
|
||
} catch (error: unknown) {
|
||
console.error('加载余额流水失败', error);
|
||
message.error('加载流水失败');
|
||
}
|
||
};
|
||
|
||
const columns = useMemo(
|
||
() => [
|
||
{
|
||
title: '学生',
|
||
render: (_: unknown, row: WalletRow) => (
|
||
<>
|
||
<strong>{row.studentName}</strong>
|
||
<div style={{ color: '#999' }}>{row.studentNo || `#${row.studentId}`}</div>
|
||
<div style={{ color: '#999' }}>
|
||
{row.roomType ? `${row.roomType}${row.roomNumber ? ` · ${row.roomNumber}` : ''}` : '未入住'}
|
||
</div>
|
||
</>
|
||
),
|
||
},
|
||
{
|
||
title: '可用余额',
|
||
dataIndex: 'balance',
|
||
render: (value: number) => (
|
||
<strong style={{ color: value > 0 ? '#1677ff' : undefined }}>
|
||
¥{value.toFixed(2)}
|
||
</strong>
|
||
),
|
||
},
|
||
{
|
||
title: '未付账单',
|
||
dataIndex: 'outstandingAmount',
|
||
render: (value: number) =>
|
||
value > 0 ? (
|
||
<Tag color="red">¥{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={updateKeyword}
|
||
onChange={(event) => !event.target.value && updateKeyword('')}
|
||
/>
|
||
<Select
|
||
allowClear
|
||
placeholder="按房型筛选"
|
||
style={{ width: 180 }}
|
||
value={roomType}
|
||
onChange={updateRoomType}
|
||
options={roomTypes.map((type) => ({ label: type, value: type }))}
|
||
/>
|
||
<span>仅看欠费</span>
|
||
<Switch checked={debtOnly} onChange={updateDebtOnly} />
|
||
</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={{
|
||
defaultPageSize: 15,
|
||
showSizeChanger: true,
|
||
pageSizeOptions: [15, 30, 50, 100],
|
||
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 || ''} - 余额流水`}
|
||
size={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: value >= 0 ? '#389e0d' : '#cf1322' }}>
|
||
{value >= 0 ? '+' : ''}¥{value.toFixed(2)}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
title: '变动后余额',
|
||
dataIndex: 'balanceAfter',
|
||
render: (value: number) => `¥${value.toFixed(2)}`,
|
||
},
|
||
{
|
||
title: '关联账单',
|
||
dataIndex: 'billId',
|
||
render: (value: number) => (value ? `#${value}` : '-'),
|
||
},
|
||
{ title: '说明', dataIndex: 'description' },
|
||
]}
|
||
/>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default WalletsPage;
|