597 lines
21 KiB
TypeScript
597 lines
21 KiB
TypeScript
// 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<any>(null);
|
|
const [editingPersonal, setEditingPersonal] = useState<any>(null);
|
|
const [roomForm] = Form.useForm();
|
|
const [personalForm] = Form.useForm();
|
|
const [utilityForm] = Form.useForm();
|
|
const [roomSearch, setRoomSearch] = useState('');
|
|
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
|
|
const [personalSearch, setPersonalSearch] = useState('');
|
|
const [personalTypeFilter, setPersonalTypeFilter] = useState<string | undefined>(undefined);
|
|
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
|
|
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
|
|
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<string, string>;
|
|
}>({
|
|
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<string, unknown>) =>
|
|
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<string, unknown>) =>
|
|
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<string, unknown>) => 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 (
|
|
<div>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<Button
|
|
type={!showArchived ? 'primary' : 'default'}
|
|
onClick={() => changeArchiveView(false)}
|
|
>
|
|
正常费用
|
|
</Button>
|
|
<Button type={showArchived ? 'primary' : 'default'} onClick={() => changeArchiveView(true)}>
|
|
已归档费用
|
|
</Button>
|
|
</Space>
|
|
<Tabs
|
|
items={[
|
|
{
|
|
key: 'room',
|
|
label: '宿舍费用',
|
|
children: (
|
|
<ExpenseTablePanel
|
|
kind="room"
|
|
searchText={roomSearch}
|
|
onSearchChange={setRoomSearch}
|
|
typeFilter={roomTypeFilter}
|
|
onTypeFilterChange={setRoomTypeFilter}
|
|
typeOptions={typeOptions}
|
|
typeMap={typeMap}
|
|
data={filteredRoomExpenses}
|
|
loading={loading}
|
|
selectedKeys={selectedRoomKeys}
|
|
onSelect={setSelectedRoomKeys}
|
|
rooms={rooms}
|
|
students={students}
|
|
readonly={expenseViewPolicy.readonly}
|
|
showArchived={showArchived}
|
|
canPurgeExpense={canPurgeExpense}
|
|
batchLoading={batchLoading}
|
|
canImport={hasPermission('expense:create')}
|
|
onBatchRestore={handleBatchRestoreRoom}
|
|
onBatchPurge={handleBatchPurgeRoom}
|
|
onBatchDelete={handleBatchDeleteRoom}
|
|
onSaveCell={saveRoomCell}
|
|
onPeriodSave={async (id, periodStart, periodEnd) => {
|
|
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: (
|
|
<ExpenseTablePanel
|
|
kind="personal"
|
|
searchText={personalSearch}
|
|
onSearchChange={setPersonalSearch}
|
|
typeFilter={personalTypeFilter}
|
|
onTypeFilterChange={setPersonalTypeFilter}
|
|
typeOptions={personalTypeOptions}
|
|
typeMap={typeMap}
|
|
data={filteredPersonalExpenses}
|
|
loading={loading}
|
|
selectedKeys={selectedPersonalKeys}
|
|
onSelect={setSelectedPersonalKeys}
|
|
rooms={rooms}
|
|
students={students}
|
|
readonly={expenseViewPolicy.readonly}
|
|
showArchived={showArchived}
|
|
canPurgeExpense={canPurgeExpense}
|
|
batchLoading={batchLoading}
|
|
canImport={hasPermission('expense:create')}
|
|
onBatchRestore={handleBatchRestorePersonal}
|
|
onBatchPurge={handleBatchPurgePersonal}
|
|
onBatchDelete={handleBatchDeletePersonal}
|
|
onSaveCell={savePersonalCell}
|
|
onPeriodSave={async () => 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('导出失败'),
|
|
);
|
|
}}
|
|
/>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<RoomExpenseModal
|
|
open={roomModal}
|
|
editing={!!editingRoom}
|
|
saving={saving}
|
|
form={roomForm}
|
|
rooms={rooms}
|
|
typeOptions={typeOptions}
|
|
onOk={handleRoomExpense}
|
|
onCancel={() => {
|
|
setRoomModal(false);
|
|
setEditingRoom(null);
|
|
}}
|
|
/>
|
|
<UtilityModal
|
|
open={utilityModal}
|
|
saving={saving}
|
|
form={utilityForm}
|
|
students={students}
|
|
onOk={handleStudentUtility}
|
|
onCancel={() => setUtilityModal(false)}
|
|
/>
|
|
<PersonalExpenseModal
|
|
open={personalModal}
|
|
editing={!!editingPersonal}
|
|
saving={saving}
|
|
form={personalForm}
|
|
students={students}
|
|
rooms={rooms}
|
|
personalTypeOptions={personalTypeOptions}
|
|
onOk={handlePersonalExpense}
|
|
onCancel={() => {
|
|
setPersonalModal(false);
|
|
setEditingPersonal(null);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExpensesPage;
|