feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -1,89 +1,41 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Modal,
|
||||
Form,
|
||||
Select,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Card,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, InboxOutlined, DollarOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '有余额', color: 'green' },
|
||||
refunded: { text: '已全退', color: 'blue' },
|
||||
depleted: { text: '已扣完', color: 'red' },
|
||||
};
|
||||
|
||||
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待缴', color: 'orange' },
|
||||
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);
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useQuery, useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import {
|
||||
depositStudentLookupsSchema,
|
||||
depositsSchema,
|
||||
eligibleStudentsSchema,
|
||||
} from '../../api/schemas';
|
||||
import {
|
||||
DepositModals,
|
||||
roomTypeOptions,
|
||||
suggestedDepositByRoomType,
|
||||
} from './DepositModals';
|
||||
import type { DepositRecord, EligibleStudent } from './DepositModals';
|
||||
import { DepositTable } from './DepositTable';
|
||||
|
||||
const DepositsPage: React.FC = () => {
|
||||
const [data, setData] = useState<DepositRecord[]>([]);
|
||||
const [students, setStudents] = useState<DepositStudentLookup[]>([]);
|
||||
const [eligibleStudents, setEligibleStudents] = useState<EligibleStudent[]>([]);
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeDeposit = hasPermission('deposit:purge');
|
||||
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eligibleLoading, setEligibleLoading] = useState(false);
|
||||
const [selectionTouched, setSelectionTouched] = useState(false);
|
||||
const [eligibleRoomType, setEligibleRoomType] = useState<string | undefined>(undefined);
|
||||
const queryClient = useQueryClient();
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [batchModal, setBatchModal] = useState(false);
|
||||
const [refundModal, setRefundModal] = useState<DepositRecord | null>(null);
|
||||
@@ -99,47 +51,115 @@ const DepositsPage: React.FC = () => {
|
||||
const [batchRoomType, setBatchRoomType] = useState<string>('四人间');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
const {
|
||||
data: fetchResult = { data: [], students: [] },
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({
|
||||
queryKey: ['deposits'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const [d, s] = await Promise.all([
|
||||
api.get<DepositRecord[]>('/deposits'),
|
||||
api.get<DepositStudentLookup[]>('/deposits/student-lookups'),
|
||||
]);
|
||||
return {
|
||||
data: validateResponse<DepositRecord[]>(depositsSchema, d),
|
||||
students: validateResponse<DepositStudentLookup[]>(depositStudentLookupsSchema, s),
|
||||
};
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败');
|
||||
return { data: [], students: [] };
|
||||
}
|
||||
},
|
||||
});
|
||||
const data = fetchResult.data;
|
||||
const students = fetchResult.students;
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
const invalidateDeposits: QueryKey[] = [['deposits'], ['deposits', 'eligible']];
|
||||
const createMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) => api.post('/deposits', payload),
|
||||
{ invalidate: invalidateDeposits },
|
||||
);
|
||||
const batchCreateMutation = useApiMutation(
|
||||
async (payload: Record<string, unknown>) => api.post('/deposits/batch', payload),
|
||||
{ invalidate: invalidateDeposits },
|
||||
);
|
||||
const refundMutation = useApiMutation(
|
||||
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
|
||||
api.put(`/deposits/${id}/refund`, payload),
|
||||
{ invalidate: invalidateDeposits },
|
||||
);
|
||||
const addInstallmentMutation = useApiMutation(
|
||||
async ({ id, payload }: { id: number; payload: Record<string, unknown> }) =>
|
||||
api.post(`/deposits/${id}/installments`, payload),
|
||||
{ invalidate: [['deposits']] },
|
||||
);
|
||||
const payInstallmentMutation = useApiMutation(
|
||||
async (installmentId: number) => api.post(`/deposits/installments/${installmentId}/pay`),
|
||||
{ invalidate: [['deposits']] },
|
||||
);
|
||||
const saveInstallmentCellMutation = useApiMutation(
|
||||
async ({
|
||||
installmentId,
|
||||
field,
|
||||
value,
|
||||
}: {
|
||||
installmentId: number;
|
||||
field: 'status' | 'paidDate';
|
||||
value: unknown;
|
||||
}) => api.put(`/deposits/installments/${installmentId}`, { [field]: value }),
|
||||
{ invalidate: [['deposits']] },
|
||||
);
|
||||
const deleteInstallmentMutation = useApiMutation(
|
||||
async (installmentId: number) => api.delete(`/deposits/installments/${installmentId}`),
|
||||
{ invalidate: [['deposits']] },
|
||||
);
|
||||
const archiveMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/deposits/${id}`),
|
||||
{ invalidate: invalidateDeposits },
|
||||
);
|
||||
const purgeMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/deposits/${id}/permanent`),
|
||||
{ invalidate: invalidateDeposits },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
const {
|
||||
data: eligibleStudents = [],
|
||||
isFetching: eligibleFetching,
|
||||
} = useQuery<EligibleStudent[]>({
|
||||
queryKey: ['deposits', 'eligible', eligibleRoomType],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, string> = {};
|
||||
if (eligibleRoomType) params.roomType = eligibleRoomType;
|
||||
return validateResponse<EligibleStudent[]>(
|
||||
eligibleStudentsSchema,
|
||||
await api.get('/deposits/eligible', { params }),
|
||||
);
|
||||
},
|
||||
});
|
||||
const eligibleLoading = eligibleFetching;
|
||||
const effectiveSelectedEligibleIds = selectionTouched
|
||||
? selectedEligibleStudentIds
|
||||
: eligibleStudents.map((item) => item.studentId);
|
||||
const fetchEligibleStudents = useCallback(
|
||||
(roomType?: string) => {
|
||||
setEligibleRoomType(roomType);
|
||||
queryClient.invalidateQueries({ queryKey: ['deposits', 'eligible'] });
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEligibleStudents(filterRoomType);
|
||||
}, [fetchEligibleStudents, filterRoomType]);
|
||||
const changeFilterRoomType = (value: string | undefined) => {
|
||||
setFilterRoomType(value);
|
||||
setSelectionTouched(false);
|
||||
fetchEligibleStudents(value);
|
||||
};
|
||||
|
||||
const depositByStudentId = useMemo(() => {
|
||||
const map = new Map<number, DepositRecord>();
|
||||
data.forEach((item) => map.set(item.studentId, item));
|
||||
for (const item of data) map.set(item.studentId, item);
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
@@ -176,7 +196,6 @@ const DepositsPage: React.FC = () => {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return data.filter((d) => {
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
@@ -192,10 +211,11 @@ const DepositsPage: React.FC = () => {
|
||||
const openBatchModal = (roomType = filterRoomType || '四人间') => {
|
||||
const amount = suggestedDepositByRoomType[roomType] ?? 100;
|
||||
setBatchRoomType(roomType);
|
||||
setSelectionTouched(false);
|
||||
fetchEligibleStudents(roomType);
|
||||
batchForm.resetFields();
|
||||
batchForm.setFieldsValue({ roomType, amount, paidDate: dayjs() });
|
||||
setBatchModal(true);
|
||||
fetchEligibleStudents(roomType);
|
||||
};
|
||||
|
||||
const handleBatchRoomTypeChange = (roomType: string) => {
|
||||
@@ -207,55 +227,38 @@ const DepositsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
await api.post('/deposits', {
|
||||
await createMutation.mutateAsync({
|
||||
studentId: values.studentId,
|
||||
amount: values.amount,
|
||||
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('押金金额已增加');
|
||||
message.success('押金收取成功');
|
||||
setCreateModal(false);
|
||||
createForm.resetFields();
|
||||
fetchData();
|
||||
fetchEligibleStudents(filterRoomType);
|
||||
} catch (e: any) {
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
await batchCreateMutation.mutateAsync({
|
||||
studentIds: effectiveSelectedEligibleIds,
|
||||
amount: values.amount,
|
||||
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
roomType: values.roomType,
|
||||
});
|
||||
message.success(`已为 ${selectedEligibleStudentIds.length} 人批量收取押金`);
|
||||
message.success('批量收取成功');
|
||||
setBatchModal(false);
|
||||
batchForm.resetFields();
|
||||
await fetchData();
|
||||
fetchEligibleStudents(filterRoomType);
|
||||
} catch (e: any) {
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setSelectionTouched(false);
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
@@ -264,19 +267,18 @@ const DepositsPage: React.FC = () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const values = await refundForm.validateFields();
|
||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
await refundMutation.mutateAsync({
|
||||
id: refundModal.id,
|
||||
payload: {
|
||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
},
|
||||
});
|
||||
message.success('退还操作完成');
|
||||
setRefundModal(null);
|
||||
refundForm.resetFields();
|
||||
fetchData();
|
||||
fetchEligibleStudents(filterRoomType);
|
||||
} catch (e: any) {
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -286,31 +288,27 @@ const DepositsPage: React.FC = () => {
|
||||
if (installmentModal == null) return;
|
||||
try {
|
||||
const values = await installmentForm.validateFields();
|
||||
await api.post(`/deposits/${installmentModal}/installments`, {
|
||||
amount: values.amount,
|
||||
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
||||
await addInstallmentMutation.mutateAsync({
|
||||
id: installmentModal,
|
||||
payload: {
|
||||
amount: values.amount,
|
||||
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
||||
},
|
||||
});
|
||||
message.success('分期已添加');
|
||||
setInstallmentModal(null);
|
||||
installmentForm.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
if (!isFormValidationError(e)) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayInstallment = async (installmentId: number) => {
|
||||
try {
|
||||
await api.put(`/deposits/installments/${installmentId}`, {
|
||||
paidDate: dayjs().format('YYYY-MM-DD'),
|
||||
status: 'paid',
|
||||
});
|
||||
await payInstallmentMutation.mutateAsync(installmentId);
|
||||
message.success('分期已标记为已缴');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,117 +317,27 @@ const DepositsPage: React.FC = () => {
|
||||
field: 'status' | 'paidDate',
|
||||
value: unknown,
|
||||
) => {
|
||||
await api.put(`/deposits/installments/${installmentId}`, { [field]: value });
|
||||
message.success('分期记录已保存');
|
||||
if (detailModal) {
|
||||
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
|
||||
setDetailModal(refreshed);
|
||||
try {
|
||||
await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value });
|
||||
message.success('分期记录已保存');
|
||||
if (detailModal) {
|
||||
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
|
||||
setDetailModal(refreshed);
|
||||
}
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
const handleDeleteInstallment = async (installmentId: number) => {
|
||||
try {
|
||||
await api.delete(`/deposits/installments/${installmentId}`);
|
||||
await deleteInstallmentMutation.mutateAsync(installmentId);
|
||||
message.success('分期已归档');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[fetchData, fetchEligibleStudents, filterRoomType, refundForm],
|
||||
);
|
||||
|
||||
const eligibleColumns = [
|
||||
{
|
||||
title: '学生',
|
||||
@@ -475,7 +383,7 @@ const DepositsPage: React.FC = () => {
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={filterRoomType}
|
||||
onChange={(v) => setFilterRoomType(v)}
|
||||
onChange={changeFilterRoomType}
|
||||
options={roomTypeOptions}
|
||||
/>
|
||||
<Select
|
||||
@@ -514,301 +422,55 @@ const DepositsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
<DepositTable
|
||||
data={filteredData}
|
||||
loading={loading || (!!filterRoomType && eligibleLoading)}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
canPurgeDeposit={canPurgeDeposit}
|
||||
refundForm={refundForm}
|
||||
onDetail={(record) => setDetailModal(record)}
|
||||
onRefund={(record) => setRefundModal(record)}
|
||||
onArchive={(id) => archiveMutation.mutateAsync(id)}
|
||||
onPurge={(id) => purgeMutation.mutateAsync(id)}
|
||||
/>
|
||||
<DepositModals
|
||||
batchModal={batchModal}
|
||||
createModal={createModal}
|
||||
refundModal={refundModal}
|
||||
detailModal={detailModal}
|
||||
installmentModal={installmentModal}
|
||||
batchForm={batchForm}
|
||||
createForm={createForm}
|
||||
refundForm={refundForm}
|
||||
installmentForm={installmentForm}
|
||||
saving={saving}
|
||||
batchRoomType={batchRoomType}
|
||||
effectiveSelectedEligibleIds={effectiveSelectedEligibleIds}
|
||||
eligibleStudents={eligibleStudents}
|
||||
eligibleLoading={eligibleLoading}
|
||||
eligibleColumns={eligibleColumns}
|
||||
studentOptions={studentOptions}
|
||||
onBatchRoomTypeChange={handleBatchRoomTypeChange}
|
||||
onBatchCreate={handleBatchCreate}
|
||||
onCreate={handleCreate}
|
||||
onRefund={handleRefund}
|
||||
onAddInstallment={handleAddInstallment}
|
||||
onPayInstallment={handlePayInstallment}
|
||||
onSaveInstallmentCell={saveInstallmentCell}
|
||||
onDeleteInstallment={handleDeleteInstallment}
|
||||
onCloseBatch={() => setBatchModal(false)}
|
||||
onCloseCreate={() => setCreateModal(false)}
|
||||
onCloseRefund={() => setRefundModal(null)}
|
||||
onCloseDetail={() => setDetailModal(null)}
|
||||
onCloseInstallment={() => setInstallmentModal(null)}
|
||||
onOpenInstallment={(id) => {
|
||||
setInstallmentModal(id);
|
||||
installmentForm.resetFields();
|
||||
}}
|
||||
onSelectEligible={(ids) => {
|
||||
setSelectedEligibleStudentIds(ids);
|
||||
setSelectionTouched(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
title="收取押金"
|
||||
open={createModal}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateModal(false)}
|
||||
okText="确认"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="studentId"
|
||||
label="学生"
|
||||
rules={[{ required: true, message: '请选择学生' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索并选择学生"
|
||||
options={studentOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<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 }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择收取日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Refund Modal */}
|
||||
<Modal
|
||||
title={`退还押金 - ${refundModal?.student?.name}`}
|
||||
open={!!refundModal}
|
||||
onOk={handleRefund}
|
||||
onCancel={() => setRefundModal(null)}
|
||||
okText="确认退还"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={refundForm} layout="vertical">
|
||||
<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 }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title={`押金详情 - ${detailModal?.student?.name}`}
|
||||
open={!!detailModal}
|
||||
onCancel={() => setDetailModal(null)}
|
||||
footer={null}
|
||||
width={640}
|
||||
>
|
||||
{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>{' '}
|
||||
<Tag color={statusMap[detailModal.status]?.color}>
|
||||
{statusMap[detailModal.status]?.text || detailModal.status}
|
||||
</Tag>
|
||||
</p>
|
||||
{detailModal.notes && (
|
||||
<p>
|
||||
<strong>备注:</strong> {detailModal.notes}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Installments Section */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0 }}>分期记录</h4>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setInstallmentModal(detailModal.id);
|
||||
installmentForm.resetFields();
|
||||
}}
|
||||
>
|
||||
添加分期
|
||||
</PermissionButton>
|
||||
</div>
|
||||
{detailModal.installments && detailModal.installments.length > 0 ? (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
dataSource={detailModal.installments}
|
||||
columns={[
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
render: (value: number) => `¥${Number(value).toFixed(2)}`,
|
||||
},
|
||||
{ title: '到期日', dataIndex: 'dueDate' },
|
||||
{
|
||||
title: '实付日',
|
||||
dataIndex: 'paidDate',
|
||||
render: (value: string, item: any) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="date"
|
||||
permission="deposit:edit"
|
||||
onSave={(next) => saveInstallmentCell(item.id, 'paidDate', next)}
|
||||
>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (value: string, item: any) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'pending', label: '待缴' },
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'overdue', label: '逾期' },
|
||||
]}
|
||||
permission="deposit:edit"
|
||||
onSave={(next) => saveInstallmentCell(item.id, 'status', next)}
|
||||
>
|
||||
<Tag color={installmentStatusMap[value]?.color}>
|
||||
{installmentStatusMap[value]?.text || value}
|
||||
</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, item: any) => (
|
||||
<Space>
|
||||
{item.status === 'pending' && (
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<DollarOutlined />}
|
||||
onClick={() => handlePayInstallment(item.id)}
|
||||
>
|
||||
标记已缴
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={() => handleDeleteInstallment(item.id)}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="deposit:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ color: '#999' }}>暂无分期记录</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Add Installment Modal */}
|
||||
<Modal
|
||||
title="添加分期"
|
||||
open={installmentModal != null}
|
||||
onOk={handleAddInstallment}
|
||||
onCancel={() => setInstallmentModal(null)}
|
||||
okText="确认"
|
||||
>
|
||||
<Form form={installmentForm} layout="vertical">
|
||||
<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 }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择到期日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user