import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, Tag, Tabs, Popconfirm, Upload, Empty, } from 'antd'; import { PlusOutlined, InboxOutlined, EditOutlined, UploadOutlined, DownloadOutlined, ExportOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; const { RangePicker } = DatePicker; const isFormValidationError = (error: unknown) => typeof error === 'object' && error !== null && Array.isArray((error as { errorFields?: unknown }).errorFields); const ExpensesPage: React.FC = () => { const [roomExpenses, setRoomExpenses] = useState([]); const [personalExpenses, setPersonalExpenses] = useState([]); const [rooms, setRooms] = useState([]); const [students, setStudents] = useState([]); const [loading, setLoading] = useState(false); 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); // Dynamic expense type options from API const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]); const [personalTypeOptions, setPersonalTypeOptions] = useState< { value: string; label: string }[] >([]); const [typeMap, setTypeMap] = useState>({}); useEffect(() => { api .get>('/expense-types') .then((types) => { const roomTypes: { value: string; label: string }[] = []; const personalTypes: { value: string; label: string }[] = []; const map: Record = {}; for (const t of types) { map[t.code] = t.name; if (t.category === 'room' || t.category === 'both') { roomTypes.push({ value: t.code, label: t.name }); } if (t.category === 'personal' || t.category === 'both') { personalTypes.push({ value: t.code, label: t.name }); } } setTypeOptions(roomTypes); setPersonalTypeOptions(personalTypes); setTypeMap(map); }) .catch(() => {}); }, []); const handleBatchDeleteRoom = async () => { if (batchLoading) return; setBatchLoading(true); try { const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys }); message.success(res?.message || `已归档 ${selectedRoomKeys.length} 条`); setSelectedRoomKeys([]); fetchData(); } catch (e: any) { message.error(e?.message || '批量归档失败'); } finally { setBatchLoading(false); } }; const handleBatchDeletePersonal = async () => { if (batchLoading) return; setBatchLoading(true); try { const res: any = await api.post('/expenses/personal/batch-delete', { ids: selectedPersonalKeys, }); message.success(res?.message || `已归档 ${selectedPersonalKeys.length} 条`); setSelectedPersonalKeys([]); fetchData(); } catch (e: any) { message.error(e?.message || '批量归档失败'); } finally { setBatchLoading(false); } }; const fetchData = useCallback(async () => { setLoading(true); try { const [re, pe, lookups]: any[] = await Promise.all([ api.get('/expenses/room'), api.get('/expenses/personal'), api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })), ]); setRoomExpenses(re); setPersonalExpenses(pe); setRooms(lookups.rooms || []); setStudents(lookups.students || []); } catch (e: any) { message.error(e?.message || '加载失败,请稍后重试'); } setLoading(false); }, []); useEffect(() => { fetchData(); }, [fetchData]); 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((p: any) => { if (personalSearch) { const s = personalSearch.toLowerCase(); if (!p.student?.name?.toLowerCase().includes(s)) return false; } if (personalTypeFilter && p.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, }; if (editingRoom) { await api.put(`/expenses/room/${editingRoom.id}`, payload); message.success('更新成功'); } else { await api.post('/expenses/room', payload); message.success('录入成功'); } setRoomModal(false); setEditingRoom(null); roomForm.resetFields(); fetchData(); } catch (e: any) { if (!isFormValidationError(e)) { message.error(e?.message || '操作失败'); } } finally { setSaving(false); } }; const handleStudentUtility = async () => { const values = await utilityForm.validateFields(); setSaving(true); try { const result: any = await api.post('/expenses/student-utility', { 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(); fetchData(); } catch (e: any) { message.error(e?.message || '水电费出账失败'); } 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, }; if (editingPersonal) { await api.put(`/expenses/personal/${editingPersonal.id}`, payload); message.success('更新成功'); } else { await api.post('/expenses/personal', payload); message.success('录入成功'); } setPersonalModal(false); setEditingPersonal(null); personalForm.resetFields(); fetchData(); } catch (e: any) { if (!isFormValidationError(e)) { message.error(e?.message || '操作失败'); } } finally { setSaving(false); } }; const roomColumns = useMemo( () => [ { title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' }, { title: '费用类型', width: 100, dataIndex: 'expenseType', render: (v: string) => {typeMap[v] || v}, }, { title: '金额', dataIndex: 'amount', width: 100, render: (v: number) => `¥${Number(v).toFixed(2)}`, }, { title: '账单周期', width: 200, render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}`, }, { title: '说明', dataIndex: 'description', width: 150 }, { title: '录入时间', width: 160, dataIndex: 'createdAt', render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), }, { title: '操作', width: 120, render: (_: any, record: any) => ( } onClick={() => { setEditingRoom(record); roomForm.setFieldsValue({ roomId: record.roomId, expenseType: record.expenseType, amount: Number(record.amount), period: [dayjs(record.periodStart), dayjs(record.periodEnd)], description: record.description, }); setRoomModal(true); }} > 编辑 { await api.delete(`/expenses/room/${record.id}`); message.success('归档成功'); fetchData(); }} > } > 归档 ), }, ], [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap], ); const personalColumns = useMemo( () => [ { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '费用类型', width: 100, dataIndex: 'expenseType', render: (v: string) => {typeMap[v] || v}, }, { title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` }, { title: '日期', dataIndex: 'expenseDate', width: 110 }, { title: '说明', dataIndex: 'description', width: 150 }, { title: '操作', width: 120, render: (_: any, record: any) => ( } onClick={() => { setEditingPersonal(record); personalForm.setFieldsValue({ studentId: record.studentId, roomId: record.roomId, expenseType: record.expenseType, amount: Number(record.amount), expenseDate: dayjs(record.expenseDate), description: record.description, }); setPersonalModal(true); }} > 编辑 { await api.delete(`/expenses/personal/${record.id}`); message.success('归档成功'); fetchData(); }} > } > 归档 ), }, ], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap], ); return (
setRoomSearch(v)} onChange={(e) => { if (!e.target.value) setRoomSearch(''); }} /> setPersonalTypeFilter(v)} options={personalTypeOptions} /> { try { const formData = new FormData(); formData.append('file', file); const res: any = await api.post('/expenses/personal/import', formData); message.success(res.message || '导入完成'); if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e)); fetchData(); onSuccess?.(res); } catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); } }} > } onClick={() => { downloadBlob( '/expenses/personal/template', '个人附加费导入模板.xlsx', ).catch(() => message.error('下载失败')); }} > 下载模板 } onClick={() => { downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() => message.error('导出失败'), ); }} > 导出 } disabled={selectedPersonalKeys.length === 0} > 批量归档 } onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }} > 添加学生水电费 } onClick={() => { setEditingPersonal(null); personalForm.resetFields(); setPersonalModal(true); }} > 录入个人费用
`共 ${total} 条`, }} locale={{ emptyText: }} rowSelection={{ selectedRowKeys: selectedPersonalKeys, onChange: (keys) => setSelectedPersonalKeys(keys as number[]), }} /> ), }, ]} /> { setRoomModal(false); setEditingRoom(null); }} okText={editingRoom ? '保存' : '确认录入'} confirmLoading={saving} >
setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving} >
{ setPersonalModal(false); setEditingPersonal(null); }} okText={editingPersonal ? '保存' : '确认录入'} confirmLoading={saving} >
({ value: r.id, label: r.roomNumber }))} />