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:
2026-07-18 12:54:10 +00:00
parent 92d303ed01
commit 375c7ec60b
64 changed files with 5169 additions and 2404 deletions

View File

@@ -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;