Files
gongxue-base/apps/admin/src/pages/Deposits/index.tsx

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;