feat: refine admin forms, attendance and finance workflows
Squash merge PR #23. Included changes: - complete occupancy check-in required fields/default payload - improve responsive admin management pages - fix attendance edge cases and attendance period config - refine wallet/finance-related workflow handling Checks: - npm run typecheck -w apps/admin - npm run typecheck -w apps/server
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Drawer, Form, Input, InputNumber, Modal, Radio, Space, Switch, Table, Tag } from 'antd';
|
||||
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';
|
||||
@@ -13,10 +26,15 @@ interface WalletRow {
|
||||
studentNo?: string;
|
||||
balance: number;
|
||||
outstandingAmount: number;
|
||||
roomType?: string;
|
||||
roomNumber?: string;
|
||||
}
|
||||
|
||||
const transactionNames: Record<string, string> = {
|
||||
recharge: '充值', adjustment: '调账', bill_payment: '账单扣款', bill_refund: '账单冲正',
|
||||
recharge: '充值',
|
||||
adjustment: '调账',
|
||||
bill_payment: '账单扣款',
|
||||
bill_refund: '账单冲正',
|
||||
};
|
||||
|
||||
const WalletsPage: React.FC = () => {
|
||||
@@ -24,6 +42,8 @@ const WalletsPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [debtOnly, setDebtOnly] = useState(false);
|
||||
const [roomType, setRoomType] = useState<string | undefined>();
|
||||
const [roomTypes, setRoomTypes] = useState<string[]>([]);
|
||||
const [selected, setSelected] = useState<WalletRow | null>(null);
|
||||
const [transactions, setTransactions] = useState<any[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -36,14 +56,35 @@ const WalletsPage: React.FC = () => {
|
||||
const fetchRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get('/wallets', { params: { keyword: keyword || undefined, debtOnly } });
|
||||
const data = await api.get('/wallets', {
|
||||
params: { keyword: keyword || undefined, debtOnly, roomType },
|
||||
});
|
||||
setRows(data as WalletRow[]);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载学生余额失败');
|
||||
} finally { setLoading(false); }
|
||||
}, [keyword, debtOnly]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [keyword, debtOnly, roomType]);
|
||||
|
||||
useEffect(() => { void fetchRows(); }, [fetchRows]);
|
||||
useEffect(() => {
|
||||
void fetchRows();
|
||||
}, [fetchRows]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoomTypes = async () => {
|
||||
try {
|
||||
setRoomTypes((await api.get('/wallets/room-types')) as string[]);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载房型失败');
|
||||
}
|
||||
};
|
||||
void fetchRoomTypes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [keyword, debtOnly, roomType]);
|
||||
|
||||
const openChange = (row: WalletRow) => {
|
||||
setSelected(row);
|
||||
@@ -60,13 +101,23 @@ const WalletsPage: React.FC = () => {
|
||||
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);
|
||||
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); }
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '余额操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitBatchChange = async () => {
|
||||
@@ -79,7 +130,13 @@ const WalletsPage: React.FC = () => {
|
||||
...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);
|
||||
return (
|
||||
sum +
|
||||
(item.payments || []).reduce(
|
||||
(paymentSum: number, bill: any) => paymentSum + Number(bill.paidAmount || 0),
|
||||
0,
|
||||
)
|
||||
);
|
||||
}, 0);
|
||||
message.success(
|
||||
paid > 0
|
||||
@@ -90,78 +147,247 @@ const WalletsPage: React.FC = () => {
|
||||
setSelectedRowKeys([]);
|
||||
batchForm.resetFields();
|
||||
await fetchRows();
|
||||
} catch (error: any) { message.error(error?.message || '批量余额操作失败'); }
|
||||
finally { setSaving(false); }
|
||||
} 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 || '加载流水失败'); }
|
||||
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> },
|
||||
], []);
|
||||
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: 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>
|
||||
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('')}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="按房型筛选"
|
||||
style={{ width: 180 }}
|
||||
value={roomType}
|
||||
onChange={setRoomType}
|
||||
options={roomTypes.map((type) => ({ label: type, value: type }))}
|
||||
/>
|
||||
<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>
|
||||
<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;
|
||||
|
||||
Reference in New Issue
Block a user