feat: refine admin forms, attendance and finance workflows
Squash merge PR #23. Included changes: - complete occupancy check-in required fields/default payload - improve responsive admin management pages - fix attendance edge cases and attendance period config - refine wallet/finance-related workflow handling Checks: - npm run typecheck -w apps/admin - npm run typecheck -w apps/server
This commit is contained in:
@@ -32,11 +32,9 @@ 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);
|
||||
|
||||
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||
|
||||
const ExpensesPage: React.FC = () => {
|
||||
const [roomExpenses, setRoomExpenses] = useState<any[]>([]);
|
||||
@@ -63,27 +61,32 @@ const ExpensesPage: React.FC = () => {
|
||||
|
||||
// Dynamic expense type options from API
|
||||
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
const [personalTypeOptions, setPersonalTypeOptions] = 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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
if (t.category === 'personal' || t.category === 'both') {
|
||||
personalTypes.push({ value: t.code, label: t.name });
|
||||
}
|
||||
}
|
||||
setTypeOptions(roomTypes);
|
||||
setPersonalTypeOptions(personalTypes);
|
||||
setTypeMap(map);
|
||||
}).catch(() => {});
|
||||
setTypeOptions(roomTypes);
|
||||
setPersonalTypeOptions(personalTypes);
|
||||
setTypeMap(map);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleBatchDeleteRoom = async () => {
|
||||
@@ -207,12 +210,17 @@ const ExpensesPage: React.FC = () => {
|
||||
description: values.description,
|
||||
});
|
||||
const bill = result.bill;
|
||||
message.success(`账单已生成,已从余额扣除 ¥${Number(bill.paidAmount || 0).toFixed(2)},待补缴 ¥${Number(bill.outstandingAmount || 0).toFixed(2)}`);
|
||||
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); }
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '水电费出账失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonalExpense = async () => {
|
||||
@@ -247,121 +255,139 @@ const ExpensesPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}}
|
||||
>
|
||||
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:delete"
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
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>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [setEditingRoom, roomForm, setRoomModal, fetchData, typeMap]);
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/room/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
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:delete"
|
||||
permission="expense:edit"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
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>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap]);
|
||||
<Popconfirm
|
||||
title="确定归档?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/expenses/personal/${record.id}`);
|
||||
message.success('归档成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="expense:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[setEditingPersonal, personalForm, setPersonalModal, fetchData, typeMap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -430,8 +456,8 @@ const ExpensesPage: React.FC = () => {
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(() =>
|
||||
message.error('下载失败'),
|
||||
downloadBlob('/expenses/utility/template', '水电费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
@@ -547,9 +573,10 @@ const ExpensesPage: React.FC = () => {
|
||||
permission="expense:view"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => {
|
||||
downloadBlob('/expenses/personal/template', '个人附加费导入模板.xlsx').catch(
|
||||
() => message.error('下载失败'),
|
||||
);
|
||||
downloadBlob(
|
||||
'/expenses/personal/template',
|
||||
'个人附加费导入模板.xlsx',
|
||||
).catch(() => message.error('下载失败'));
|
||||
}}
|
||||
>
|
||||
下载模板
|
||||
@@ -586,7 +613,10 @@ const ExpensesPage: React.FC = () => {
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => { utilityForm.resetFields(); setUtilityModal(true); }}
|
||||
onClick={() => {
|
||||
utilityForm.resetFields();
|
||||
setUtilityModal(true);
|
||||
}}
|
||||
>
|
||||
添加学生水电费
|
||||
</PermissionButton>
|
||||
@@ -654,7 +684,7 @@ const ExpensesPage: React.FC = () => {
|
||||
<Select options={typeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
|
||||
<RangePicker
|
||||
@@ -669,16 +699,42 @@ const ExpensesPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
<Modal title="添加学生水电费并立即出账" open={utilityModal} onOk={handleStudentUtility} onCancel={() => setUtilityModal(false)} okText="生成账单并扣余额" confirmLoading={saving}>
|
||||
<Modal
|
||||
title="添加学生水电费并立即出账"
|
||||
open={utilityModal}
|
||||
onOk={handleStudentUtility}
|
||||
onCancel={() => setUtilityModal(false)}
|
||||
okText="生成账单并扣余额"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={utilityForm} layout="vertical">
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={students.map((student: any) => ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} />
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={students.map((student: any) => ({
|
||||
value: student.id,
|
||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'water', label: '水费' },
|
||||
{ value: 'electricity', label: '电费' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
|
||||
<RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} maxLength={300} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}><Select options={[{ value: 'water', label: '水费' }, { value: 'electricity', label: '电费' }]} /></Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}><RangePicker style={{ width: '100%' }} format="YYYY-MM-DD" /></Form.Item>
|
||||
<Form.Item name="description" label="说明"><Input.TextArea rows={2} maxLength={300} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -713,7 +769,7 @@ const ExpensesPage: React.FC = () => {
|
||||
<Select options={personalTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
|
||||
|
||||
Reference in New Issue
Block a user