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

@@ -3,9 +3,7 @@ import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit
describe('deposit student option', () => {
it('uses the student number as the non-sensitive identifier', () => {
expect(
buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' }),
).toEqual({
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' })).toEqual({
value: 23,
label: '张三 (S2026001)',
});
@@ -28,7 +26,14 @@ describe('deposit student option', () => {
});
it('includes room type when available', () => {
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001', roomType: '四人间' })).toEqual({
expect(
buildDepositStudentOption({
id: 23,
name: '张三',
studentNo: 'S2026001',
roomType: '四人间',
}),
).toEqual({
value: 23,
label: '张三 (S2026001) - 四人间',
});

View File

@@ -50,7 +50,13 @@ interface DepositRecord {
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{ id: number; amount: number; dueDate: string; paidDate?: string | null; status: string }>;
installments?: Array<{
id: number;
amount: number;
dueDate: string;
paidDate?: string | null;
status: string;
}>;
student?: DepositStudentLookup;
}
@@ -67,9 +73,9 @@ interface EligibleStudent {
}
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
typeof error === 'object' &&
error !== null &&
Array.isArray((error as { errorFields?: unknown }).errorFields);
const DepositsPage: React.FC = () => {
const [data, setData] = useState<DepositRecord[]>([]);
@@ -141,7 +147,12 @@ const DepositsPage: React.FC = () => {
if (filterRoomType) {
const s = searchText.trim().toLowerCase();
return eligibleStudents
.filter((item) => !s || item.studentName.toLowerCase().includes(s) || item.studentNo?.toLowerCase().includes(s))
.filter(
(item) =>
!s ||
item.studentName.toLowerCase().includes(s) ||
item.studentNo?.toLowerCase().includes(s),
)
.map((item) => {
const deposit = depositByStudentId.get(item.studentId);
return {
@@ -176,12 +187,7 @@ const DepositsPage: React.FC = () => {
});
}, [data, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]);
const studentOptions = useMemo(
() => buildDepositStudentOptions(students),
[students],
);
const studentOptions = useMemo(() => buildDepositStudentOptions(students), [students]);
const openBatchModal = (roomType = filterRoomType || '四人间') => {
const amount = suggestedDepositByRoomType[roomType] ?? 100;
@@ -194,7 +200,9 @@ const DepositsPage: React.FC = () => {
const handleBatchRoomTypeChange = (roomType: string) => {
setBatchRoomType(roomType);
batchForm.setFieldsValue({ amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100 });
batchForm.setFieldsValue({
amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100,
});
fetchEligibleStudents(roomType);
};
@@ -316,87 +324,115 @@ const DepositsPage: React.FC = () => {
}
};
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
{ title: '房间', width: 120, render: (_: unknown, r: any) => r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-' },
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (s: string) => s === 'unpaid'
? <Tag color="default"></Tag>
: <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number';
return (
<Space>
{hasDeposit && (
<PermissionButton
permission="deposit:view"
size="small"
onClick={() => {
setDetailModal(record);
}}
>
</PermissionButton>
)}
{record.status === 'paid' && hasDeposit && (
<PermissionButton
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
</PermissionButton>
)}
{hasDeposit && (
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('归档成功');
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
);
const columns = useMemo(
() => [
{ title: '学生', width: 120, render: (_: unknown, r: any) => r.student?.name || '-' },
{
title: '当前可用押金',
dataIndex: 'amount',
width: 130,
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
},
], [fetchData, fetchEligibleStudents, filterRoomType, refundForm]);
{
title: '房间',
width: 120,
render: (_: unknown, r: any) =>
r.roomNumber ? `${r.building ? `${r.building}-` : ''}${r.roomNumber}` : '-',
},
{ title: '房型', dataIndex: 'roomType', width: 100, render: (v: string) => v || '-' },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120, render: (v: string) => v || '-' },
{
title: '状态',
dataIndex: 'status',
render: (s: string) =>
s === 'unpaid' ? (
<Tag color="default"></Tag>
) : (
<Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>
),
},
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: unknown, record: any) => {
const hasDeposit = typeof record.id === 'number';
return (
<Space>
{hasDeposit && (
<PermissionButton
permission="deposit:view"
size="small"
onClick={() => {
setDetailModal(record);
}}
>
</PermissionButton>
)}
{record.status === 'paid' && hasDeposit && (
<PermissionButton
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs() });
}}
>
退
</PermissionButton>
)}
{hasDeposit && (
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('归档成功');
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
)}
</Space>
);
},
},
],
[fetchData, fetchEligibleStudents, filterRoomType, refundForm],
);
const eligibleColumns = [
{ title: '学生', render: (_: unknown, r: EligibleStudent) => `${r.studentName} (${r.studentNo || `#${r.studentId}`})` },
{ title: '房间', render: (_: unknown, r: EligibleStudent) => `${r.building ? `${r.building}-` : ''}${r.roomNumber}` },
{
title: '学生',
render: (_: unknown, r: EligibleStudent) =>
`${r.studentName} (${r.studentNo || `#${r.studentId}`})`,
},
{
title: '房间',
render: (_: unknown, r: EligibleStudent) =>
`${r.building ? `${r.building}-` : ''}${r.roomNumber}`,
},
{ title: '房型', dataIndex: 'roomType' },
{ title: '当前押金', dataIndex: 'depositAmount', render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
{
title: '当前押金',
dataIndex: 'depositAmount',
render: (v: number) => `¥${Number(v || 0).toFixed(2)}`,
},
];
return (
@@ -492,13 +528,29 @@ const DepositsPage: React.FC = () => {
>
<Form form={batchForm} layout="vertical">
<Space style={{ width: '100%' }} align="start" wrap>
<Form.Item name="roomType" label="房型" rules={[{ required: true, message: '请选择房型' }]}>
<Select style={{ width: 140 }} options={roomTypeOptions} onChange={handleBatchRoomTypeChange} />
<Form.Item
name="roomType"
label="房型"
rules={[{ required: true, message: '请选择房型' }]}
>
<Select
style={{ width: 140 }}
options={roomTypeOptions}
onChange={handleBatchRoomTypeChange}
/>
</Form.Item>
<Form.Item name="amount" label="每人收取金额(元)" rules={[{ required: true, message: '请输入金额' }]}>
<Form.Item
name="amount"
label="每人收取金额(元)"
rules={[{ required: true, message: '请输入金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: 180 }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true, message: '请选择日期' }]}>
<Form.Item
name="paidDate"
label="收取日期"
rules={[{ required: true, message: '请选择日期' }]}
>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
@@ -509,7 +561,9 @@ const DepositsPage: React.FC = () => {
<div style={{ marginBottom: 8 }}>
<strong>{selectedEligibleStudentIds.length}</strong> / {eligibleStudents.length}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>¥{suggestedDepositByRoomType[batchRoomType]}</span>
<span style={{ color: '#999', marginLeft: 8 }}>
¥{suggestedDepositByRoomType[batchRoomType]}
</span>
)}
</div>
<Table
@@ -549,10 +603,10 @@ const DepositsPage: React.FC = () => {
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<Form.Item name="paidDate" label="收取日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
@@ -574,7 +628,7 @@ const DepositsPage: React.FC = () => {
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
@@ -594,19 +648,34 @@ const DepositsPage: React.FC = () => {
{detailModal && (
<div>
<Card size="small" style={{ marginBottom: 16 }}>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p>
<p>
<strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}
</p>
<p>
<strong>:</strong> {detailModal.paidDate}
</p>
<p>
<strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text || detailModal.status}
</Tag>
</p>
{detailModal.notes && <p><strong>:</strong> {detailModal.notes}</p>}
{detailModal.notes && (
<p>
<strong>:</strong> {detailModal.notes}
</p>
)}
</Card>
{/* Installments Section */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<h4 style={{ margin: 0 }}></h4>
<PermissionButton
permission="deposit:edit"
@@ -644,7 +713,13 @@ const DepositsPage: React.FC = () => {
title="确定归档?"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<InboxOutlined />}>
<PermissionButton
key="del"
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>,
@@ -676,10 +751,10 @@ const DepositsPage: React.FC = () => {
okText="确认"
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<Form.Item name="dueDate" label="到期日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
</Form.Item>
</Form>