518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
import {
|
|
Form,
|
|
Input,
|
|
Select,
|
|
Space,
|
|
} from 'antd';
|
|
import { PlusOutlined, 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 { RefreshButton } from '../../components/RefreshButton';
|
|
import { buildDepositStudentOptions, type DepositStudentLookup } from './deposit-student-option';
|
|
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';
|
|
import { QueryErrorState } from '../../components/QueryState';
|
|
import { useVisibleRefetch } from '../../hooks/usePageVisible';
|
|
|
|
const DepositsPage: React.FC = () => {
|
|
const { hasPermission } = usePermission();
|
|
const canPurgeDeposit = hasPermission('deposit:purge');
|
|
const [selectedEligibleStudentIds, setSelectedEligibleStudentIds] = useState<number[]>([]);
|
|
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);
|
|
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 {
|
|
data: fetchResult = { data: [], students: [] },
|
|
isLoading,
|
|
isFetching,
|
|
isError,
|
|
refetch,
|
|
} = useQuery<{ data: DepositRecord[]; students: DepositStudentLookup[] }>({
|
|
queryKey: ['deposits'],
|
|
queryFn: async () => {
|
|
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),
|
|
};
|
|
},
|
|
});
|
|
const data = fetchResult.data;
|
|
const students = fetchResult.students;
|
|
const loading = isLoading || isFetching;
|
|
// RouteKeeper 保活页面切回时刷新押金列表
|
|
useVisibleRefetch(['deposits']);
|
|
|
|
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.put(`/deposits/installments/${installmentId}`, {
|
|
status: 'paid',
|
|
paidDate: dayjs().format('YYYY-MM-DD'),
|
|
}),
|
|
{ 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 },
|
|
);
|
|
|
|
const {
|
|
data: eligibleStudents = [],
|
|
isFetching: eligibleFetching,
|
|
isError: eligibleError,
|
|
refetch: refetchEligible,
|
|
} = 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-students', { 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],
|
|
);
|
|
|
|
const changeFilterRoomType = (value: string | undefined) => {
|
|
setFilterRoomType(value);
|
|
setSelectionTouched(false);
|
|
fetchEligibleStudents(value);
|
|
};
|
|
|
|
const depositByStudentId = useMemo(() => {
|
|
const map = new Map<number, DepositRecord>();
|
|
for (const item of data) map.set(item.studentId, item);
|
|
return map;
|
|
}, [data]);
|
|
|
|
const filteredData = useMemo(() => {
|
|
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;
|
|
}
|
|
if (filterStatus && d.status !== filterStatus) return false;
|
|
return true;
|
|
});
|
|
}, [data, depositByStudentId, eligibleStudents, filterRoomType, filterStatus, searchText]);
|
|
|
|
const studentOptions = useMemo(() => buildDepositStudentOptions(students), [students]);
|
|
|
|
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);
|
|
};
|
|
|
|
const openCreateDeposit = () => {
|
|
createForm.resetFields();
|
|
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
|
setCreateModal(true);
|
|
};
|
|
|
|
const handleBatchRoomTypeChange = (roomType: string) => {
|
|
setBatchRoomType(roomType);
|
|
// 切换房型后候选学生列表会变化,重置勾选状态,避免把上一房型的选择提交到新房型
|
|
setSelectionTouched(false);
|
|
setSelectedEligibleStudentIds([]);
|
|
batchForm.setFieldsValue({
|
|
amount: suggestedDepositByRoomType[roomType] ?? batchForm.getFieldValue('amount') ?? 100,
|
|
});
|
|
fetchEligibleStudents(roomType);
|
|
};
|
|
|
|
const handleCreate = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const values = await createForm.validateFields();
|
|
await createMutation.mutateAsync({
|
|
studentId: values.studentId,
|
|
amount: values.amount,
|
|
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
|
notes: values.notes,
|
|
});
|
|
message.success('押金收取成功');
|
|
setCreateModal(false);
|
|
createForm.resetFields();
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleBatchCreate = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const values = await batchForm.validateFields();
|
|
await batchCreateMutation.mutateAsync({
|
|
studentIds: effectiveSelectedEligibleIds,
|
|
amount: values.amount,
|
|
paidDate: values.paidDate.format('YYYY-MM-DD'),
|
|
notes: values.notes,
|
|
roomType: values.roomType,
|
|
});
|
|
message.success('批量收取成功');
|
|
setBatchModal(false);
|
|
batchForm.resetFields();
|
|
setSelectionTouched(false);
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleRefund = async () => {
|
|
if (!refundModal) return;
|
|
setSaving(true);
|
|
try {
|
|
const values = await refundForm.validateFields();
|
|
await refundMutation.mutateAsync({
|
|
id: refundModal.id,
|
|
payload: {
|
|
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
|
notes: values.notes,
|
|
},
|
|
});
|
|
message.success('退还操作完成');
|
|
setRefundModal(null);
|
|
refundForm.resetFields();
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleAddInstallment = async () => {
|
|
if (installmentModal == null) return;
|
|
setSaving(true);
|
|
try {
|
|
const values = await installmentForm.validateFields();
|
|
await addInstallmentMutation.mutateAsync({
|
|
id: installmentModal,
|
|
payload: {
|
|
amount: values.amount,
|
|
dueDate: values.dueDate.format('YYYY-MM-DD'),
|
|
},
|
|
});
|
|
message.success('分期已添加');
|
|
setInstallmentModal(null);
|
|
installmentForm.resetFields();
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handlePayInstallment = async (installmentId: number) => {
|
|
try {
|
|
await payInstallmentMutation.mutateAsync(installmentId);
|
|
message.success('分期已标记为已缴');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const saveInstallmentCell = async (
|
|
installmentId: number,
|
|
field: 'status' | 'paidDate',
|
|
value: unknown,
|
|
) => {
|
|
try {
|
|
await saveInstallmentCellMutation.mutateAsync({ installmentId, field, value });
|
|
message.success('分期记录已保存');
|
|
if (detailModal) {
|
|
const refreshed = await api.get<DepositRecord>(`/deposits/${detailModal.id}`);
|
|
setDetailModal(refreshed);
|
|
}
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
const handleDeleteInstallment = async (installmentId: number) => {
|
|
try {
|
|
await deleteInstallmentMutation.mutateAsync(installmentId);
|
|
message.success('分期已归档');
|
|
} catch {
|
|
// 错误提示由 useApiMutation 统一处理
|
|
}
|
|
};
|
|
|
|
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) => {
|
|
setSearchText(e.target.value);
|
|
}}
|
|
/>
|
|
<Select
|
|
placeholder="房型筛选"
|
|
allowClear
|
|
style={{ width: 130 }}
|
|
value={filterRoomType}
|
|
onChange={changeFilterRoomType}
|
|
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>
|
|
<RefreshButton loading={isFetching} onRefresh={() => void refetch()} />
|
|
<PermissionButton
|
|
permission="deposit:create"
|
|
icon={<TeamOutlined />}
|
|
onClick={() => openBatchModal()}
|
|
>
|
|
按房型批量收取
|
|
</PermissionButton>
|
|
<PermissionButton
|
|
permission="deposit:create"
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
onClick={openCreateDeposit}
|
|
>
|
|
收取押金
|
|
</PermissionButton>
|
|
</Space>
|
|
</div>
|
|
{isError ? (
|
|
<QueryErrorState
|
|
title="押金数据加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetch()}
|
|
/>
|
|
) : filterRoomType && eligibleError ? (
|
|
<QueryErrorState
|
|
title="可收取押金的学生加载失败"
|
|
description="请检查网络后重试。"
|
|
onRetry={() => void refetchEligible()}
|
|
/>
|
|
) : (
|
|
<DepositTable
|
|
data={filteredData}
|
|
loading={loading || (!!filterRoomType && eligibleLoading)}
|
|
canPurgeDeposit={canPurgeDeposit}
|
|
canCreateDeposit={hasPermission('deposit:create')}
|
|
onCreateDeposit={openCreateDeposit}
|
|
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);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default DepositsPage;
|