// 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([]); const [selectionTouched, setSelectionTouched] = useState(false); const [eligibleRoomType, setEligibleRoomType] = useState(undefined); const queryClient = useQueryClient(); const [createModal, setCreateModal] = useState(false); const [batchModal, setBatchModal] = useState(false); const [refundModal, setRefundModal] = useState(null); const [detailModal, setDetailModal] = useState(null); const [installmentModal, setInstallmentModal] = useState(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(undefined); const [filterRoomType, setFilterRoomType] = useState(undefined); const [batchRoomType, setBatchRoomType] = useState('四人间'); 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('/deposits'), api.get('/deposits/student-lookups'), ]); return { data: validateResponse(depositsSchema, d), students: validateResponse(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) => api.post('/deposits', payload), { invalidate: invalidateDeposits }, ); const batchCreateMutation = useApiMutation( async (payload: Record) => api.post('/deposits/batch', payload), { invalidate: invalidateDeposits }, ); const refundMutation = useApiMutation( async ({ id, payload }: { id: number; payload: Record }) => api.put(`/deposits/${id}/refund`, payload), { invalidate: invalidateDeposits }, ); const addInstallmentMutation = useApiMutation( async ({ id, payload }: { id: number; payload: Record }) => 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({ queryKey: ['deposits', 'eligible', eligibleRoomType], queryFn: async () => { const params: Record = {}; if (eligibleRoomType) params.roomType = eligibleRoomType; return validateResponse( 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(); 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(`/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 (
setSearchText(v)} onChange={(e) => { setSearchText(e.target.value); }} /> setFilterStatus(v)} options={[ { value: 'paid', label: '有余额' }, { value: 'refunded', label: '已全退' }, { value: 'depleted', label: '已扣完' }, ]} /> void refetch()} /> } onClick={() => openBatchModal()} > 按房型批量收取 } onClick={openCreateDeposit} > 收取押金
{isError ? ( void refetch()} /> ) : filterRoomType && eligibleError ? ( void refetchEligible()} /> ) : ( setDetailModal(record)} onRefund={(record) => setRefundModal(record)} onArchive={(id) => archiveMutation.mutateAsync(id)} onPurge={(id) => purgeMutation.mutateAsync(id)} /> )} 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); }} />
); }; export default DepositsPage;