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

688 lines
25 KiB
TypeScript

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,
DeleteOutlined,
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<any[]>([]);
const [personalExpenses, setPersonalExpenses] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm();
const [personalForm] = 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);
// 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<Record<string, string>>({});
useEffect(() => {
api.get<Array<{ code: string; name: string; category: string }>>('/expense-types').then((types) => {
const roomTypes: { value: string; label: string }[] = [];
const personalTypes: { value: string; label: string }[] = [];
const map: Record<string, string> = {};
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 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) => <Tag>{typeMap[v] || v}</Tag>,
},
{ 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) => (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
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);
}}
>
</PermissionButton>
<Popconfirm
title="确定删除?"
onConfirm={async () => {
await api.delete(`/expenses/room/${record.id}`);
message.success('删除成功');
fetchData();
}}
>
<PermissionButton
permission="expense:delete"
size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
], [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) => <Tag color="orange">{typeMap[v] || v}</Tag>,
},
{ 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) => (
<Space>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
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);
}}
>
</PermissionButton>
<Popconfirm
title="确定删除?"
onConfirm={async () => {
await api.delete(`/expenses/personal/${record.id}`);
message.success('删除成功');
fetchData();
}}
>
<PermissionButton
permission="expense:delete"
size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
),
},
], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap]);
return (
<div>
<Tabs
items={[
{
key: 'room',
label: '宿舍费用',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索宿舍号"
allowClear
style={{ width: 160 }}
onSearch={(v) => setRoomSearch(v)}
onChange={(e) => {
if (!e.target.value) setRoomSearch('');
}}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={roomTypeFilter}
onChange={(v) => setRoomTypeFilter(v)}
options={typeOptions}
/>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/expenses/utility/import', formData);
if (res.errors?.length > 0) {
Modal.warning({
title: res.message,
content: res.errors.join('\n'),
width: 500,
});
} else {
message.success(res.message);
}
onSuccess?.(res);
fetchData();
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
}
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(() =>
message.error('下载失败'),
);
}}
>
</PermissionButton>
</Space>
<Space>
<Popconfirm
title={`确定删除选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchDeleteRoom}
okText="删除"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<DeleteOutlined />}
disabled={selectedRoomKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingRoom(null);
roomForm.resetFields();
setRoomModal(true);
}}
>
宿
</PermissionButton>
</Space>
</div>
<Table
columns={roomColumns}
dataSource={filteredRoomExpenses}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedRoomKeys,
onChange: (keys) => setSelectedRoomKeys(keys as number[]),
}}
/>
</>
),
},
{
key: 'personal',
label: '个人附加费',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 160 }}
onSearch={(v) => setPersonalSearch(v)}
onChange={(e) => {
if (!e.target.value) setPersonalSearch('');
}}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={personalTypeFilter}
onChange={(v) => setPersonalTypeFilter(v)}
options={personalTypeOptions}
/>
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
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);
}
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
<PermissionButton
permission="expense:view"
icon={<DownloadOutlined />}
onClick={() => {
downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
() => message.error('下载失败'),
);
}}
>
</PermissionButton>
<PermissionButton
permission="expense:view"
icon={<ExportOutlined />}
onClick={() => {
downloadBlob('/expenses/personal/export', '个人附加费导出.xlsx').catch(() =>
message.error('导出失败'),
);
}}
>
</PermissionButton>
</Space>
<Space>
<Popconfirm
title={`确定删除选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchDeletePersonal}
okText="删除"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<PermissionButton
permission="expense:delete"
danger
icon={<DeleteOutlined />}
disabled={selectedPersonalKeys.length === 0}
>
</PermissionButton>
</Popconfirm>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingPersonal(null);
personalForm.resetFields();
setPersonalModal(true);
}}
>
</PermissionButton>
</Space>
</div>
<Table
columns={personalColumns}
dataSource={filteredPersonalExpenses}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{
defaultPageSize: 15,
showSizeChanger: true,
pageSizeOptions: [15, 30, 50, 100],
showTotal: (total) => `${total}`,
}}
locale={{ emptyText: <Empty description="暂无数据" /> }}
rowSelection={{
selectedRowKeys: selectedPersonalKeys,
onChange: (keys) => setSelectedPersonalKeys(keys as number[]),
}}
/>
</>
),
},
]}
/>
<Modal
title={editingRoom ? '编辑宿舍费用' : '录入宿舍费用'}
open={roomModal}
onOk={handleRoomExpense}
onCancel={() => {
setRoomModal(false);
setEditingRoom(null);
}}
okText={editingRoom ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={roomForm} layout="vertical">
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({
value: r.id,
label: `${r.roomNumber} (${r.building || ''})`,
}))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={typeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
<RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
open={personalModal}
onOk={handlePersonalExpense}
onCancel={() => {
setPersonalModal(false);
setEditingPersonal(null);
}}
okText={editingPersonal ? '保存' : '确认录入'}
confirmLoading={saving}
>
<Form form={personalForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={students.map((s: any) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="roomId" label="关联宿舍">
<Select
allowClear
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={personalTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ExpensesPage;