// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化 import React, { useState, useMemo, useCallback } from 'react'; import { useQuery } from '@tanstack/react-query'; import { App, Button, Form, Space, Tabs } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import { useApiMutation } from '../../hooks/useApiMutation'; import { validateResponse } from '../../utils/validate'; import { expenseLookupsSchema, expenseRecordsSchema, expenseRoomsListSchema, expenseStudentLookupsSchema, } from '../../api/schemas'; import { archiveViewPolicy, expenseStatusForView } from '../archive-view'; import { ExpenseTablePanel } from './ExpenseTablePanel'; import { PersonalExpenseModal, RoomExpenseModal, UtilityModal } from './ExpenseModals'; const ExpensesPage: React.FC = () => { const { modal } = App.useApp(); const { hasPermission } = usePermission(); const canPurgeExpense = hasPermission('expense:purge'); const [roomModal, setRoomModal] = useState(false); const [personalModal, setPersonalModal] = useState(false); const [utilityModal, setUtilityModal] = useState(false); const [editingRoom, setEditingRoom] = useState(null); const [editingPersonal, setEditingPersonal] = useState(null); const [roomForm] = Form.useForm(); const [personalForm] = Form.useForm(); const [utilityForm] = Form.useForm(); const [roomSearch, setRoomSearch] = useState(''); const [roomTypeFilter, setRoomTypeFilter] = useState(undefined); const [personalSearch, setPersonalSearch] = useState(''); const [personalTypeFilter, setPersonalTypeFilter] = useState(undefined); const [selectedRoomKeys, setSelectedRoomKeys] = useState([]); const [selectedPersonalKeys, setSelectedPersonalKeys] = useState([]); const [saving, setSaving] = useState(false); const [batchLoading, setBatchLoading] = useState(false); const [showArchived, setShowArchived] = useState(false); const expenseViewPolicy = archiveViewPolicy(showArchived ? 'archived' : 'active'); const { data: typeLookups = { typeOptions: [], personalTypeOptions: [], typeMap: {} }, } = useQuery<{ typeOptions: { value: string; label: string }[]; personalTypeOptions: { value: string; label: string }[]; typeMap: Record; }>({ queryKey: ['expense-lookups'], queryFn: async () => { try { return validateResponse(expenseLookupsSchema, await api.get('/expenses/lookups')); } catch { return { typeOptions: [], personalTypeOptions: [], typeMap: {} }; } }, }); const { typeOptions, personalTypeOptions, typeMap } = typeLookups; const { data: expenseResult = { rooms: [], personal: [], students: [], roomsList: [] }, isLoading, isFetching, } = useQuery<{ rooms: any[]; personal: any[]; students: any[]; roomsList: any[]; }>({ queryKey: ['expenses', showArchived ? 'archived' : 'active'], queryFn: async () => { try { const [rooms, personal, students, roomsList] = await Promise.all([ api.get('/expenses/room', { params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/personal', { params: { status: expenseStatusForView(showArchived ? 'archived' : 'active') }, }), api.get('/expenses/student-lookups'), api.get('/rooms'), ]); return { rooms: validateResponse(expenseRecordsSchema, rooms), personal: validateResponse(expenseRecordsSchema, personal), students: validateResponse(expenseStudentLookupsSchema, students), roomsList: validateResponse(expenseRoomsListSchema, roomsList), }; } catch { message.error('加载费用数据失败'); return { rooms: [], personal: [], students: [], roomsList: [] }; } }, }); const roomExpenses = expenseResult.rooms; const personalExpenses = expenseResult.personal; const students = expenseResult.students; const rooms = expenseResult.roomsList; const loading = isLoading || isFetching; const mutations = { saveRoom: useApiMutation( async (payload: Record) => editingRoom ? api.put(`/expenses/room/${editingRoom.id}`, payload) : api.post('/expenses/room', payload), { invalidate: [['expenses']] }, ), saveRoomCell: useApiMutation( async ({ record, field, value }: { record: any; field: string; value: unknown }) => api.put(`/expenses/room/${record.id}`, { [field]: value }), { invalidate: [['expenses']] }, ), savePersonal: useApiMutation( async (payload: Record) => editingPersonal ? api.put(`/expenses/personal/${editingPersonal.id}`, payload) : api.post('/expenses/personal', payload), { invalidate: [['expenses']] }, ), savePersonalCell: useApiMutation( async ({ record, field, value }: { record: any; field: string; value: unknown }) => api.put(`/expenses/personal/${record.id}`, { [field]: value }), { invalidate: [['expenses']] }, ), period: useApiMutation( async ({ id, periodStart, periodEnd }: { id: number; periodStart: string; periodEnd: string }) => api.put(`/expenses/room/${id}`, { periodStart, periodEnd }), { invalidate: [['expenses']] }, ), utility: useApiMutation( async (payload: Record) => api.post('/expenses/student-utility', payload), { invalidate: [['expenses'], ['bills']] }, ), importUtility: useApiMutation( async (formData: FormData) => api.post('/expenses/utility/import', formData), { invalidate: [['expenses']] }, ), importPersonal: useApiMutation( async (formData: FormData) => api.post('/expenses/personal/import', formData), { invalidate: [['expenses']] }, ), archiveRoom: useApiMutation( async (id: number) => api.delete(`/expenses/room/${id}`), { invalidate: [['expenses']] }, ), archivePersonal: useApiMutation( async (id: number) => api.delete(`/expenses/personal/${id}`), { invalidate: [['expenses']] }, ), batchDeleteRoom: useApiMutation( async (ids: number[]) => api.post('/expenses/room/batch-delete', { ids }), { invalidate: [['expenses']] }, ), batchDeletePersonal: useApiMutation( async (ids: number[]) => api.post('/expenses/personal/batch-delete', { ids }), { invalidate: [['expenses']] }, ), batchRestoreRoom: useApiMutation( async (ids: number[]) => api.put('/expenses/room/batch-restore', { ids }), { invalidate: [['expenses']] }, ), batchRestorePersonal: useApiMutation( async (ids: number[]) => api.put('/expenses/personal/batch-restore', { ids }), { invalidate: [['expenses']] }, ), purgeRoom: useApiMutation( async (id: number) => api.delete(`/expenses/room/${id}/permanent`), { invalidate: [['expenses']] }, ), purgePersonal: useApiMutation( async (id: number) => api.delete(`/expenses/personal/${id}/permanent`), { invalidate: [['expenses']] }, ), }; const handleBatchDeleteRoom = async () => { setBatchLoading(true); try { await mutations.batchDeleteRoom.mutateAsync(selectedRoomKeys); message.success('批量归档成功'); setSelectedRoomKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchDeletePersonal = async () => { setBatchLoading(true); try { await mutations.batchDeletePersonal.mutateAsync(selectedPersonalKeys); message.success('批量归档成功'); setSelectedPersonalKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestoreRoom = async () => { setBatchLoading(true); try { await mutations.batchRestoreRoom.mutateAsync(selectedRoomKeys); message.success('批量恢复成功'); setSelectedRoomKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchRestorePersonal = async () => { setBatchLoading(true); try { await mutations.batchRestorePersonal.mutateAsync(selectedPersonalKeys); message.success('批量恢复成功'); setSelectedPersonalKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handlePurgeRoom = (id: number) => { modal.confirm({ title: '永久删除宿舍费用?', content: '删除后不可恢复。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await mutations.purgeRoom.mutateAsync(id); message.success('已永久删除'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }; const handlePurgePersonal = (id: number) => { modal.confirm({ title: '永久删除个人费用?', content: '删除后不可恢复。确定继续?', okText: '永久删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await mutations.purgePersonal.mutateAsync(id); message.success('已永久删除'); } catch { // 错误提示由 useApiMutation 统一处理 } }, }); }; const handleBatchPurgeRoom = async () => { setBatchLoading(true); try { await Promise.all(selectedRoomKeys.map((id) => mutations.purgeRoom.mutateAsync(id))); message.success('批量永久删除成功'); setSelectedRoomKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const handleBatchPurgePersonal = async () => { setBatchLoading(true); try { await Promise.all(selectedPersonalKeys.map((id) => mutations.purgePersonal.mutateAsync(id))); message.success('批量永久删除成功'); setSelectedPersonalKeys([]); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setBatchLoading(false); } }; const changeArchiveView = (archived: boolean) => { setShowArchived(archived); setSelectedRoomKeys([]); setSelectedPersonalKeys([]); }; const filteredRoomExpenses = useMemo(() => { return roomExpenses.filter((r: any) => { if (roomSearch) { const s = roomSearch.toLowerCase(); if (!r.room?.roomNumber?.toLowerCase().includes(s)) return false; } if (roomTypeFilter && r.expenseType !== roomTypeFilter) return false; return true; }); }, [roomExpenses, roomSearch, roomTypeFilter]); const filteredPersonalExpenses = useMemo(() => { return personalExpenses.filter((r: any) => { if (personalSearch) { const s = personalSearch.toLowerCase(); if (!r.student?.name?.toLowerCase().includes(s)) return false; } if (personalTypeFilter && r.expenseType !== personalTypeFilter) return false; return true; }); }, [personalExpenses, personalSearch, personalTypeFilter]); const handleRoomExpense = async () => { setSaving(true); try { const values = await roomForm.validateFields(); const payload = { roomId: values.roomId, expenseType: values.expenseType, amount: values.amount, periodStart: values.period[0].format('YYYY-MM-DD'), periodEnd: values.period[1].format('YYYY-MM-DD'), description: values.description, }; await mutations.saveRoom.mutateAsync(payload); message.success(editingRoom ? '更新成功' : '录入成功'); setRoomModal(false); setEditingRoom(null); roomForm.resetFields(); } catch { // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; const handleStudentUtility = async () => { const values = await utilityForm.validateFields(); setSaving(true); try { const result: any = await mutations.utility.mutateAsync({ studentId: values.studentId, expenseType: values.expenseType, amount: values.amount, periodStart: values.period[0].format('YYYY-MM-DD'), periodEnd: values.period[1].format('YYYY-MM-DD'), description: values.description, }); const bill = result.bill; message.success( `账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`, ); setUtilityModal(false); utilityForm.resetFields(); } catch { // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handlePersonalExpense = async () => { setSaving(true); try { const values = await personalForm.validateFields(); const payload = { studentId: values.studentId, roomId: values.roomId, expenseType: values.expenseType, amount: values.amount, expenseDate: values.expenseDate.format('YYYY-MM-DD'), description: values.description, }; await mutations.savePersonal.mutateAsync(payload); message.success(editingPersonal ? '更新成功' : '录入成功'); setPersonalModal(false); setEditingPersonal(null); personalForm.resetFields(); } catch { // 校验错误静默,接口错误由 useApiMutation 统一提示 } finally { setSaving(false); } }; const openEditRoom = (record: any) => { setEditingRoom(record); roomForm.setFieldsValue({ roomId: record.roomId, expenseType: record.expenseType, amount: Number(record.amount), period: record.periodStart ? [dayjs(record.periodStart), dayjs(record.periodEnd)] : undefined, description: record.description, }); setRoomModal(true); }; const openEditPersonal = (record: any) => { setEditingPersonal(record); personalForm.setFieldsValue({ studentId: record.studentId, roomId: record.roomId, expenseType: record.expenseType, amount: Number(record.amount), expenseDate: record.expenseDate ? dayjs(record.expenseDate) : undefined, description: record.description, }); setPersonalModal(true); }; const saveRoomCell = useCallback( async (record: any, field: string, value: unknown) => { try { await mutations.saveRoomCell.mutateAsync({ record, field, value }); message.success('已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [mutations.saveRoomCell], ); const savePersonalCell = useCallback( async (record: any, field: string, value: unknown) => { try { await mutations.savePersonalCell.mutateAsync({ record, field, value }); message.success('已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }, [mutations.savePersonalCell], ); return (
{ try { await mutations.period.mutateAsync({ id, periodStart, periodEnd }); message.success('已保存'); } catch { // 错误提示由 useApiMutation 统一处理 } }} onEdit={openEditRoom} onArchive={(id) => mutations.archiveRoom.mutateAsync(id)} onPurge={handlePurgeRoom} onImport={(formData) => mutations.importUtility.mutateAsync(formData)} onTemplateDownload={() => { void downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch( () => message.error('下载失败'), ); }} onAddUtility={() => setUtilityModal(true)} /> ), }, { key: 'personal', label: '个人附加费', children: ( undefined} onEdit={openEditPersonal} onArchive={(id) => mutations.archivePersonal.mutateAsync(id)} onPurge={handlePurgePersonal} onImport={(formData) => mutations.importPersonal.mutateAsync(formData)} onTemplateDownload={() => { void downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch( () => message.error('下载失败'), ); }} onExport={() => { downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => message.error('导出失败'), ); }} /> ), }, ]} /> { setRoomModal(false); setEditingRoom(null); }} /> setUtilityModal(false)} /> { setPersonalModal(false); setEditingPersonal(null); }} />
); }; export default ExpensesPage;