feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
435
apps/admin/src/pages/Expenses/index.tsx
Normal file
435
apps/admin/src/pages/Expenses/index.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, message, Tag, Tabs, Popconfirm, Upload } 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';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const expenseTypeOptions = [
|
||||
{ value: 'water', label: '水费' },
|
||||
{ value: 'electricity', label: '电费' },
|
||||
{ value: 'cleaning', label: '保洁费' },
|
||||
{ value: 'damage', label: '损坏赔偿' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const personalExpenseTypeOptions = [
|
||||
{ value: 'damage', label: '物品损坏' },
|
||||
{ value: 'cleaning', label: '保洁费' },
|
||||
{ value: 'penalty', label: '罚款' },
|
||||
{ value: 'key', label: '钥匙费' },
|
||||
{ value: 'remote', label: '空调遥控器' },
|
||||
{ value: 'deposit_deduction', label: '押金扣除' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他' };
|
||||
|
||||
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 handleBatchDeleteRoom = async () => {
|
||||
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 || '批量删除失败'); }
|
||||
};
|
||||
|
||||
const handleBatchDeletePersonal = async () => {
|
||||
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 || '批量删除失败'); }
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [re, pe, rm, st]: any[] = await Promise.all([
|
||||
api.get('/expenses/room'),
|
||||
api.get('/expenses/personal'),
|
||||
api.get('/rooms'),
|
||||
api.get('/students'),
|
||||
]);
|
||||
setRoomExpenses(re);
|
||||
setPersonalExpenses(pe);
|
||||
setRooms(rm);
|
||||
setStudents(st);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { 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 () => {
|
||||
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,
|
||||
};
|
||||
try {
|
||||
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) { message.error(e?.message || '操作失败'); }
|
||||
};
|
||||
|
||||
const handlePersonalExpense = async () => {
|
||||
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,
|
||||
};
|
||||
try {
|
||||
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) { message.error(e?.message || '操作失败'); }
|
||||
};
|
||||
|
||||
const roomColumns = [
|
||||
{ title: '宿舍', render: (_: any, r: any) => r.room?.roomNumber || '-' },
|
||||
{ title: '费用类型', dataIndex: 'expenseType', render: (v: string) => <Tag>{typeMap[v] || v}</Tag> },
|
||||
{ title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{ title: '录入时间', 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>
|
||||
<PermissionButton permission="expense:delete">
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/room/${record.id}`); message.success('删除成功'); fetchData(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const personalColumns = [
|
||||
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '费用类型', 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' },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{
|
||||
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>
|
||||
<PermissionButton permission="expense:delete">
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/personal/${record.id}`); message.success('删除成功'); fetchData(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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={expenseTypeOptions}
|
||||
/>
|
||||
<PermissionButton permission="expense:create">
|
||||
<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, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
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>
|
||||
<PermissionButton permission="expense:view" icon={<DownloadOutlined />} onClick={() => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`${baseURL}/expenses/utility/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '水电费导入模板.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => message.error('下载失败'));
|
||||
}}>下载水电费模板</PermissionButton>
|
||||
</Space>
|
||||
<Space>
|
||||
<PermissionButton permission="expense:delete">
|
||||
<Popconfirm title={`确定删除选中的 ${selectedRoomKeys.length} 条费用?`} onConfirm={handleBatchDeleteRoom} okText="删除" cancelText="取消" disabled={selectedRoomKeys.length === 0}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={selectedRoomKeys.length === 0}>批量删除</Button>
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
<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} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
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={personalExpenseTypeOptions}
|
||||
/>
|
||||
<PermissionButton permission="expense:create">
|
||||
<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>
|
||||
<PermissionButton permission="expense:view" icon={<DownloadOutlined />} onClick={() => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`${baseURL}/expenses/personal/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '个人附加费导入模板.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => message.error('下载失败'));
|
||||
}}>下载模板</PermissionButton>
|
||||
<PermissionButton permission="expense:view" icon={<ExportOutlined />} onClick={() => {
|
||||
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
fetch(`${baseURL}/expenses/personal/export`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(res => res.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = '个人附加费导出.xlsx';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(() => message.error('导出失败'));
|
||||
}}>导出</PermissionButton>
|
||||
</Space>
|
||||
<Space>
|
||||
<PermissionButton permission="expense:delete">
|
||||
<Popconfirm title={`确定删除选中的 ${selectedPersonalKeys.length} 条个人费用?`} onConfirm={handleBatchDeletePersonal} okText="删除" cancelText="取消" disabled={selectedPersonalKeys.length === 0}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={selectedPersonalKeys.length === 0}>批量删除</Button>
|
||||
</Popconfirm>
|
||||
</PermissionButton>
|
||||
<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} pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
rowSelection={{ selectedRowKeys: selectedPersonalKeys, onChange: (keys) => setSelectedPersonalKeys(keys as number[]) }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
|
||||
<Modal title={editingRoom ? '编辑宿舍费用' : '录入宿舍费用'} open={roomModal} onOk={handleRoomExpense} onCancel={() => { setRoomModal(false); setEditingRoom(null); }} okText={editingRoom ? '保存' : '确认录入'}>
|
||||
<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={expenseTypeOptions} />
|
||||
</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 ? '保存' : '确认录入'}>
|
||||
<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={personalExpenseTypeOptions} />
|
||||
</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;
|
||||
Reference in New Issue
Block a user