feat: refine deposit room type workflows

This commit is contained in:
2026-07-17 17:36:04 +08:00
parent a5bda6f093
commit b3d0bafc22
11 changed files with 724 additions and 140 deletions

View File

@@ -26,4 +26,11 @@ describe('deposit student option', () => {
},
]);
});
it('includes room type when available', () => {
expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001', roomType: '四人间' })).toEqual({
value: 23,
label: '张三 (S2026001) - 四人间',
});
});
});

View File

@@ -2,11 +2,12 @@ export interface DepositStudentLookup {
id: number;
name: string;
studentNo?: string | null;
roomType?: string | null;
}
export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
value: student.id,
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
label: `${student.name} (${student.studentNo || `#${student.id}`})${student.roomType ? ` - ${student.roomType}` : ''}`,
});
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
Table,
Modal,
@@ -14,12 +14,12 @@ import {
Card,
Empty,
} from 'antd';
import { PlusOutlined, InboxOutlined, DollarOutlined } from '@ant-design/icons';
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildDepositStudentOptions } from './deposit-student-option';
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '有余额', color: 'green' },
@@ -32,49 +32,141 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
};
const roomTypeOptions = [
{ value: '单人间', label: '单人间' },
{ value: '四人间', label: '四人间' },
];
const suggestedDepositByRoomType: Record<string, number> = {
单人间: 200,
四人间: 100,
};
interface DepositRecord {
id: number;
studentId: number;
amount: number;
status: string;
paidDate: string;
refundDate?: string | null;
notes?: string | null;
installments?: Array<{ id: number; amount: number; dueDate: string; paidDate?: string | null; status: string }>;
student?: DepositStudentLookup;
}
interface EligibleStudent {
studentId: number;
studentName: string;
studentNo?: string | null;
roomId: number;
roomNumber: string;
building?: string | null;
roomType?: string | null;
capacity: number;
depositAmount: number;
}
const isFormValidationError = (error: unknown) =>
typeof error === 'object'
&& error !== null
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [data, setData] = useState<DepositRecord[]>([]);
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [eligibleLoading, setEligibleLoading] = useState(false);
const [createModal, setCreateModal] = useState(false);
const [refundModal, setRefundModal] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [batchModal, setBatchModal] = useState(false);
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
const [detailModal, setDetailModal] = useState<DepositRecord | null>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [createForm] = Form.useForm();
const [batchForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [installmentForm] = Form.useForm();
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterRoomType, setFilterRoomType] = useState<string | undefined>(undefined);
const [batchRoomType, setBatchRoomType] = useState<string>('四人间');
const [saving, setSaving] = useState(false);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [d, s]: any[] = await Promise.all([
api.get('/deposits'),
api.get('/deposits/student-lookups'),
const [d, s] = await Promise.all([
api.get<DepositRecord[]>('/deposits'),
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
]);
setData(d);
setStudents(s);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
} finally {
setLoading(false);
}
setLoading(false);
};
}, []);
const fetchEligibleStudents = useCallback(async (roomType?: string) => {
setEligibleLoading(true);
try {
const params = roomType ? `?roomType=${encodeURIComponent(roomType)}` : '';
const rows = await api.get<EligibleStudent[]>(`/deposits/eligible-students${params}`);
setEligibleStudents(rows);
setSelectedEligibleStudentIds(rows.map((item) => item.studentId));
} catch (e: any) {
message.error(e?.message || '加载在住人员失败');
} finally {
setEligibleLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, []);
}, [fetchData]);
useEffect(() => {
fetchEligibleStudents(filterRoomType);
}, [fetchEligibleStudents, filterRoomType]);
const depositByStudentId = useMemo(() => {
const map = new Map<number, DepositRecord>();
data.forEach((item) => map.set(item.studentId, item));
return map;
}, [data]);
const filteredData = useMemo(() => {
return data.filter((d: any) => {
if (filterRoomType) {
const s = searchText.trim().toLowerCase();
return eligibleStudents
.filter((item) => !s || item.studentName.toLowerCase().includes(s) || item.studentNo?.toLowerCase().includes(s))
.map((item) => {
const deposit = depositByStudentId.get(item.studentId);
return {
id: deposit?.id ?? `eligible-${item.studentId}`,
studentId: item.studentId,
amount: deposit?.amount ?? item.depositAmount ?? 0,
status: deposit?.status ?? 'unpaid',
paidDate: deposit?.paidDate ?? '',
refundDate: deposit?.refundDate,
notes: deposit?.notes,
installments: deposit?.installments ?? [],
student: {
id: item.studentId,
name: item.studentName,
studentNo: item.studentNo,
roomType: item.roomType,
},
roomNumber: item.roomNumber,
building: item.building,
roomType: item.roomType,
};
});
}
return data.filter((d) => {
if (searchText) {
const s = searchText.toLowerCase();
if (!d.student?.name?.toLowerCase().includes(s)) return false;
@@ -82,13 +174,30 @@ const DepositsPage: React.FC = () => {
if (filterStatus && d.status !== filterStatus) return false;
return true;
});
}, [data, searchText, filterStatus]);
}, [data, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]);
const studentOptions = useMemo(
() => buildDepositStudentOptions(students),
[students],
);
const openBatchModal = (roomType = filterRoomType || '四人间') => {
const amount = suggestedDepositByRoomType[roomType] ?? 100;
setBatchRoomType(roomType);
batchForm.resetFields();
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
setBatchModal(true);
fetchEligibleStudents(roomType);
};
const handleBatchRoomTypeChange = (roomType: string) => {
setBatchRoomType(roomType);
batchForm.setFieldsValue({ amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100 });
fetchEligibleStudents(roomType);
};
const handleCreate = async () => {
setSaving(true);
try {
@@ -103,6 +212,36 @@ const DepositsPage: React.FC = () => {
setCreateModal(false);
createForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
}
} finally {
setSaving(false);
}
};
const handleBatchCreate = async () => {
if (selectedEligibleStudentIds.length === 0) {
message.warning('请选择至少一名学生');
return;
}
setSaving(true);
try {
const values = await batchForm.validateFields();
await api.post('/deposits/batch', {
studentIds: selectedEligibleStudentIds,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
roomType: values.roomType,
});
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
setBatchModal(false);
batchForm.resetFields();
await fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
@@ -113,6 +252,7 @@ const DepositsPage: React.FC = () => {
};
const handleRefund = async () => {
if (!refundModal) return;
setSaving(true);
try {
const values = await refundForm.validateFields();
@@ -124,6 +264,7 @@ const DepositsPage: React.FC = () => {
setRefundModal(null);
refundForm.resetFields();
fetchData();
fetchEligibleStudents(filterRoomType);
} catch (e: any) {
if (!isFormValidationError(e)) {
message.error(e?.message || '操作失败');
@@ -176,32 +317,39 @@ const DepositsPage: React.FC = () => {
};
const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
{ 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) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
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: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: unknown) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: unknown) => v || '-' },
{
title: '操作',
width: 240,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="deposit:view"
size="small"
onClick={() => {
setDetailModal(record);
}}
>
</PermissionButton>
{record.status === 'paid' && (
<>
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"
@@ -213,97 +361,171 @@ const DepositsPage: React.FC = () => {
>
退
</PermissionButton>
</>
)}
<Popconfirm
title="确定归档?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('归档成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
}}
>
<PermissionButton
permission="deposit:delete"
size="small"
danger
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
)}
{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]);
], [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: '房型', dataIndex: 'roomType' },
{ title: '当前押金', dataIndex: 'depositAmount', render: (v: number) => `¥${Number(v || 0).toFixed(2)}` },
];
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名/学号"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
setSearchText(e.target.value);
}}
/>
<Select
placeholder="房型筛选"
allowClear
style={{ width: 130 }}
value={filterRoomType}
onChange={(v) => setFilterRoomType(v)}
options={roomTypeOptions}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
disabled={!!filterRoomType}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' },
{ value: 'depleted', label: '已扣完' },
]}
/>
</Space>
<Space wrap>
<PermissionButton
permission="deposit:create"
icon={<TeamOutlined />}
onClick={() => openBatchModal()}
>
</PermissionButton>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading || (!!filterRoomType && eligibleLoading)}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' },
{ value: 'depleted', label: '已扣完' },
]}
/>
</Space>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
{/* Batch Create Modal */}
<Modal
title="按房型批量收取押金"
open={batchModal}
onOk={handleBatchCreate}
onCancel={() => setBatchModal(false)}
okText="确认批量收取"
confirmLoading={saving}
okButtonProps={{ disabled: selectedEligibleStudentIds.length === 0 }}
width={760}
>
<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>
<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: '请选择日期' }]}>
<DatePicker style={{ width: 180 }} placeholder="选择收取日期" format="YYYY-MM-DD" />
</Form.Item>
</Space>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} placeholder={`${batchRoomType}押金`} />
</Form.Item>
</Form>
<div style={{ marginBottom: 8 }}>
<strong>{selectedEligibleStudentIds.length}</strong> / {eligibleStudents.length}
{suggestedDepositByRoomType[batchRoomType] && (
<span style={{ color: '#999', marginLeft: 8 }}>¥{suggestedDepositByRoomType[batchRoomType]}</span>
)}
</div>
<Table
size="small"
columns={eligibleColumns}
dataSource={eligibleStudents}
rowKey="studentId"
loading={eligibleLoading}
locale={{ emptyText: <Empty description="暂无符合条件的在住人员" /> }}
pagination={{ pageSize: 6, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: selectedEligibleStudentIds,
onChange: (keys) => setSelectedEligibleStudentIds(keys as number[]),
}}
/>
</Modal>
{/* Create Modal */}
<Modal
@@ -327,10 +549,10 @@ const DepositsPage: React.FC = () => {
options={studentOptions}
/>
</Form.Item>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} 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="备注">
@@ -352,7 +574,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="备注">
@@ -399,10 +621,10 @@ const DepositsPage: React.FC = () => {
</PermissionButton>
</div>
{detailModal.installments?.length > 0 ? (
{detailModal.installments && detailModal.installments.length > 0 ? (
<List
dataSource={detailModal.installments}
renderItem={(item: any) => (
renderItem={(item) => (
<List.Item
actions={[
item.status === 'pending' && (
@@ -418,13 +640,14 @@ const DepositsPage: React.FC = () => {
</PermissionButton>
),
<Popconfirm
key="archive"
title="确定归档?"
onConfirm={() => handleDeleteInstallment(item.id)}
>
<PermissionButton key="del" permission="deposit:delete" size="small" danger icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
</PermissionButton>
</Popconfirm>,
].filter(Boolean)}
>
<List.Item.Meta
@@ -453,16 +676,14 @@ const DepositsPage: React.FC = () => {
okText="确认"
>
<Form form={installmentForm} layout="vertical">
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<Form.Item name="amount" label="分期金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} 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>
</Modal>
</div>
);
};